feat: add worksheet studio with comprehensive features

Add complete worksheet creation system with:

**Core Infrastructure:**
- WorksheetFormState type system with V4 config support
- Config serialization/deserialization with versioning
- Auto-save hooks with debouncing
- Worksheet generation and preview hooks
- Layout calculation utilities

**UI Components:**
- Three-panel resizable layout (config/preview/actions)
- Tab-based navigation (Operator/Difficulty/Scaffolding/Layout)
- Real-time preview with page virtualization
- Floating page indicators
- Error boundaries and generation error display

**Configuration Controls:**
- Operator selection (addition/subtraction/mixed) with checkboxes
- Difficulty controls with preset dropdown
- Smart mode with displayRules
- Manual mode with boolean flags
- Digit range selector (1-5 digits)
- Scaffolding options (carry boxes, ten frames, etc.)
- Layout controls (orientation, pages, cols, spacing)

**Advanced Features:**
- Mastery mode with skill progression
- Progressive difficulty option
- Regrouping frequency controls
- Custom mix modal for fine-tuning
- All skills modal showing progression path

**Share Infrastructure:**
- Shared worksheet viewer page at /worksheets/shared/[id]
- "Open in Editor" button loads config into creator
- SessionStorage-based config transfer
- Server-side data fetching with view tracking

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-11 11:48:58 -06:00
parent dd9587f8cd
commit d5672bdddf
46 changed files with 11810 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
'use client'
import { stack } from '@styled/patterns'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
import { WorksheetConfigProvider } from './WorksheetConfigContext'
import { DifficultyMethodSelector } from './DifficultyMethodSelector'
import { StudentNameInput } from './config-panel/StudentNameInput'
import { OperatorSection } from './config-panel/OperatorSection'
import { ProgressiveDifficultyToggle } from './config-panel/ProgressiveDifficultyToggle'
import { SmartModeControls } from './config-panel/SmartModeControls'
import { MasteryModePanel } from './config-panel/MasteryModePanel'
import { DisplayControlsPanel } from './DisplayControlsPanel'
interface ConfigPanelProps {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
isDark?: boolean
}
export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanelProps) {
// Handler for difficulty method switching (smart vs mastery)
const handleMethodChange = (newMethod: 'smart' | 'mastery') => {
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'smart'
if (currentMethod === newMethod) {
return // No change needed
}
// Preserve displayRules when switching
const displayRules = formState.displayRules ?? defaultAdditionConfig.displayRules
if (newMethod === 'smart') {
onChange({
mode: 'smart',
displayRules,
difficultyProfile: 'earlyLearner',
} as unknown as Partial<WorksheetFormState>)
} else {
onChange({
mode: 'mastery',
displayRules,
} as unknown as Partial<WorksheetFormState>)
}
}
// Determine current method for selector
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'smart'
return (
<WorksheetConfigProvider formState={formState} onChange={onChange}>
<div data-component="config-panel" className={stack({ gap: '3' })}>
{/* Student Name */}
<StudentNameInput
value={formState.name}
onChange={(name) => onChange({ name })}
isDark={isDark}
/>
{/* Operator Selector */}
<OperatorSection
operator={formState.operator}
onChange={(operator) => onChange({ operator })}
isDark={isDark}
/>
{/* Progressive Difficulty Toggle */}
<ProgressiveDifficultyToggle
interpolate={formState.interpolate}
onChange={(interpolate) => onChange({ interpolate })}
isDark={isDark}
/>
{/* Display Controls - Always visible for manual adjustment */}
<DisplayControlsPanel formState={formState} onChange={onChange} isDark={isDark} />
{/* Difficulty Method Selector (Smart vs Mastery) */}
<DifficultyMethodSelector
currentMethod={currentMethod}
onChange={handleMethodChange}
isDark={isDark}
/>
{/* Method-specific preset controls */}
{currentMethod === 'smart' && (
<SmartModeControls formState={formState} onChange={onChange} />
)}
{currentMethod === 'mastery' && (
<MasteryModePanel formState={formState} onChange={onChange} isDark={isDark} />
)}
</div>
</WorksheetConfigProvider>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import { css } from '@styled/css'
interface DifficultyMethodSelectorProps {
currentMethod: 'smart' | 'mastery'
onChange: (method: 'smart' | 'mastery') => void
isDark?: boolean
}
export function DifficultyMethodSelector({
currentMethod,
onChange,
isDark = false,
}: DifficultyMethodSelectorProps) {
return (
<div data-component="difficulty-method-selector">
{/* Tab buttons */}
<div
className={css({
display: 'flex',
gap: '0',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Smart Difficulty Tab */}
<button
type="button"
data-action="select-smart"
onClick={() => onChange('smart')}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2',
px: '5',
py: '3',
flex: '1',
bg: currentMethod === 'smart' ? (isDark ? 'gray.800' : 'white') : 'transparent',
color:
currentMethod === 'smart'
? isDark
? 'brand.300'
: 'brand.600'
: isDark
? 'gray.500'
: 'gray.500',
fontWeight: currentMethod === 'smart' ? 'bold' : 'medium',
fontSize: 'sm',
borderTopLeftRadius: 'lg',
borderTopRightRadius: 'lg',
cursor: 'pointer',
transition: 'all 0.2s',
borderBottom: '3px solid',
borderColor:
currentMethod === 'smart' ? (isDark ? 'brand.500' : 'brand.500') : 'transparent',
mb: '-2px',
_hover: {
color:
currentMethod === 'smart'
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.400'
: 'gray.600',
bg:
currentMethod === 'smart'
? isDark
? 'gray.800'
: 'white'
: isDark
? 'gray.800/30'
: 'gray.50',
},
})}
>
<span>🎯</span>
<span>Smart Difficulty</span>
</button>
{/* Mastery Progression Tab */}
<button
type="button"
data-action="select-mastery"
onClick={() => onChange('mastery')}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2',
px: '5',
py: '3',
flex: '1',
bg: currentMethod === 'mastery' ? (isDark ? 'gray.800' : 'white') : 'transparent',
color:
currentMethod === 'mastery'
? isDark
? 'brand.300'
: 'brand.600'
: isDark
? 'gray.500'
: 'gray.500',
fontWeight: currentMethod === 'mastery' ? 'bold' : 'medium',
fontSize: 'sm',
borderTopLeftRadius: 'lg',
borderTopRightRadius: 'lg',
cursor: 'pointer',
transition: 'all 0.2s',
borderBottom: '3px solid',
borderColor:
currentMethod === 'mastery' ? (isDark ? 'brand.500' : 'brand.500') : 'transparent',
mb: '-2px',
_hover: {
color:
currentMethod === 'mastery'
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.400'
: 'gray.600',
bg:
currentMethod === 'mastery'
? isDark
? 'gray.800'
: 'white'
: isDark
? 'gray.800/30'
: 'gray.50',
},
})}
>
<span>🎓</span>
<span>Mastery Progression</span>
</button>
</div>
{/* Description text */}
<div
className={css({
mt: '2',
px: '1',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
textAlign: 'center',
})}
>
{currentMethod === 'smart'
? 'Choose a difficulty preset, then customize display options below'
: 'Follow a structured skill progression with recommended scaffolding'}
</div>
</div>
)
}

View File

@@ -0,0 +1,307 @@
'use client'
import { useState } from 'react'
import * as Collapsible from '@radix-ui/react-collapsible'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import type { DisplayRules } from '../displayRules'
import { defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
import { RuleThermometer } from './config-panel/RuleThermometer'
import { DisplayOptionsPreview } from './DisplayOptionsPreview'
export interface DisplayControlsPanelProps {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
isDark?: boolean
}
export function DisplayControlsPanel({
formState,
onChange,
isDark = false,
}: DisplayControlsPanelProps) {
const [isOpen, setIsOpen] = useState(false)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
// Get current displayRules or use defaults
const displayRules: DisplayRules = formState.displayRules ?? defaultAdditionConfig.displayRules
// Helper to update a single display rule
const updateRule = (key: keyof DisplayRules, value: DisplayRules[keyof DisplayRules]) => {
onChange({
displayRules: {
...displayRules,
[key]: value,
},
})
}
return (
<Collapsible.Root open={isOpen} onOpenChange={setIsOpen}>
<div data-section="display-controls" className={stack({ gap: '3' })}>
<Collapsible.Trigger asChild>
<button
type="button"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
cursor: 'pointer',
bg: 'transparent',
border: 'none',
_hover: {
'& > div:first-child': {
color: isDark ? 'gray.300' : 'gray.600',
},
},
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
transition: 'color 0.2s',
})}
>
Pedagogical Scaffolding
</div>
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.500' : 'gray.400',
fontStyle: 'italic',
})}
>
(Advanced)
</div>
</div>
<div
className={css({
fontSize: 'sm',
color: isDark ? 'gray.500' : 'gray.400',
transition: 'transform 0.2s',
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
})}
>
</div>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<div className={stack({ gap: '3' })}>
<div className={css({ display: 'flex', gap: '1.5', justifyContent: 'flex-end' })}>
<button
onClick={() =>
onChange({
displayRules: {
...displayRules,
carryBoxes: 'always',
answerBoxes: 'always',
placeValueColors: 'always',
tenFrames: 'always',
borrowNotation: 'always',
borrowingHints: 'always',
},
})
}
className={css({
px: '2',
py: '0.5',
fontSize: '2xs',
color: isDark ? 'brand.300' : 'brand.600',
border: '1px solid',
borderColor: isDark ? 'brand.500' : 'brand.300',
bg: isDark ? 'gray.700' : 'white',
rounded: 'md',
cursor: 'pointer',
_hover: { bg: isDark ? 'gray.600' : 'brand.50' },
})}
>
All Always
</button>
<button
onClick={() =>
onChange({
displayRules: {
...displayRules,
carryBoxes: 'never',
answerBoxes: 'never',
placeValueColors: 'never',
tenFrames: 'never',
borrowNotation: 'never',
borrowingHints: 'never',
},
})
}
className={css({
px: '2',
py: '0.5',
fontSize: '2xs',
color: isDark ? 'gray.300' : 'gray.600',
border: '1px solid',
borderColor: isDark ? 'gray.500' : 'gray.300',
bg: isDark ? 'gray.700' : 'white',
rounded: 'md',
cursor: 'pointer',
_hover: { bg: isDark ? 'gray.600' : 'gray.50' },
})}
>
Minimal
</button>
</div>
{/* Pedagogical scaffolding thermometers */}
<div className={stack({ gap: '3' })}>
<RuleThermometer
label="Answer Boxes"
description="Guide students to write organized, aligned answers"
value={displayRules.answerBoxes}
onChange={(value) => updateRule('answerBoxes', value)}
isDark={isDark}
/>
<RuleThermometer
label="Place Value Colors"
description="Reinforce place value understanding visually"
value={displayRules.placeValueColors}
onChange={(value) => updateRule('placeValueColors', value)}
isDark={isDark}
/>
<RuleThermometer
label={
formState.operator === 'subtraction'
? 'Borrow Boxes'
: formState.operator === 'mixed'
? 'Carry/Borrow Boxes'
: 'Carry Boxes'
}
description={
formState.operator === 'subtraction'
? 'Help students track borrowing during subtraction'
: formState.operator === 'mixed'
? 'Help students track regrouping (carrying in addition, borrowing in subtraction)'
: 'Help students track regrouping during addition'
}
value={displayRules.carryBoxes}
onChange={(value) => updateRule('carryBoxes', value)}
isDark={isDark}
/>
{(formState.operator === 'subtraction' || formState.operator === 'mixed') && (
<RuleThermometer
label="Borrowed 10s Box"
description="Box for adding 10 to borrowing digit"
value={displayRules.borrowNotation}
onChange={(value) => updateRule('borrowNotation', value)}
isDark={isDark}
/>
)}
{(formState.operator === 'subtraction' || formState.operator === 'mixed') && (
<RuleThermometer
label="Borrowing Hints"
description="Show arrows and calculations guiding the borrowing process"
value={displayRules.borrowingHints}
onChange={(value) => updateRule('borrowingHints', value)}
isDark={isDark}
/>
)}
<RuleThermometer
label="Ten-Frames"
description="Visualize regrouping with concrete counting tools"
value={displayRules.tenFrames}
onChange={(value) => updateRule('tenFrames', value)}
isDark={isDark}
/>
</div>
{/* Live Preview - Collapsible */}
<Collapsible.Root open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<Collapsible.Trigger asChild>
<button
type="button"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
cursor: 'pointer',
bg: 'transparent',
border: 'none',
mt: '2',
_hover: {
'& > div:first-child': {
color: isDark ? 'gray.300' : 'gray.600',
},
},
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
transition: 'color 0.2s',
})}
>
Live Preview
</div>
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.500' : 'gray.400',
fontStyle: 'italic',
})}
>
(Optional)
</div>
</div>
<div
className={css({
fontSize: 'sm',
color: isDark ? 'gray.500' : 'gray.400',
transition: 'transform 0.2s',
transform: isPreviewOpen ? 'rotate(180deg)' : 'rotate(0deg)',
})}
>
</div>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<div className={css({ mt: '2' })}>
<DisplayOptionsPreview formState={formState} />
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
</Collapsible.Content>
</div>
</Collapsible.Root>
)
}

View File

@@ -0,0 +1,329 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { css } from '@styled/css'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
interface DisplayOptionsPreviewProps {
formState: WorksheetFormState
}
interface MathSentenceProps {
operands: number[]
operator: string
onChange: (operands: number[]) => void
labels?: string[]
}
/**
* Flexible math sentence component supporting operators with arity 1-3
* Examples:
* Arity 1 (unary): [64] with "√" → "√64"
* Arity 2 (binary): [45, 27] with "+" → "45 + 27"
* Arity 3 (ternary): [5, 10, 15] with "between" → "5 < 10 < 15"
*/
function MathSentence({ operands, operator, onChange, labels }: MathSentenceProps) {
const handleOperandChange = (index: number, value: string) => {
const numValue = Number.parseInt(value, 10)
if (!Number.isNaN(numValue) && numValue >= 0 && numValue <= 99) {
const newOperands = [...operands]
newOperands[index] = numValue
onChange(newOperands)
}
}
const renderInput = (value: number, index: number) => (
<input
key={index}
type="number"
min="0"
max="99"
value={value}
onChange={(e) => handleOperandChange(index, e.target.value)}
aria-label={labels?.[index] || `operand ${index + 1}`}
className={css({
width: '3.5em',
px: '1',
py: '0.5',
fontSize: 'sm',
fontWeight: 'medium',
textAlign: 'center',
border: '1px solid',
borderColor: 'transparent',
rounded: 'sm',
outline: 'none',
transition: 'border-color 0.2s',
_hover: {
borderColor: 'gray.300',
},
_focus: {
borderColor: 'brand.500',
ring: '1px',
ringColor: 'brand.200',
},
})}
/>
)
// Render based on arity
if (operands.length === 1) {
// Unary operator (prefix): √64 or ±5
return (
<div
data-component="math-sentence"
className={css({
display: 'flex',
alignItems: 'center',
gap: '1',
fontSize: 'sm',
fontWeight: 'medium',
})}
>
<span>{operator}</span>
{renderInput(operands[0], 0)}
</div>
)
}
if (operands.length === 2) {
// Binary operator (infix): 45 + 27
return (
<div
data-component="math-sentence"
className={css({
display: 'flex',
alignItems: 'center',
gap: '1',
fontSize: 'sm',
fontWeight: 'medium',
})}
>
{renderInput(operands[0], 0)}
<span>{operator}</span>
{renderInput(operands[1], 1)}
</div>
)
}
if (operands.length === 3) {
// Ternary operator: 5 < 10 < 15 or similar
return (
<div
data-component="math-sentence"
className={css({
display: 'flex',
alignItems: 'center',
gap: '1',
fontSize: 'sm',
fontWeight: 'medium',
})}
>
{renderInput(operands[0], 0)}
<span>{operator}</span>
{renderInput(operands[1], 1)}
<span>{operator}</span>
{renderInput(operands[2], 2)}
</div>
)
}
return null
}
async function fetchExample(options: {
showCarryBoxes: boolean
showAnswerBoxes: boolean
showPlaceValueColors: boolean
showProblemNumbers: boolean
showCellBorder: boolean
showTenFrames: boolean
showTenFramesForAll: boolean
showBorrowNotation: boolean
operator: 'addition' | 'subtraction' | 'mixed'
addend1?: number
addend2?: number
minuend?: number
subtrahend?: number
}): Promise<string> {
const response = await fetch('/api/create/worksheets/example', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...options,
fontSize: 16,
}),
})
if (!response.ok) {
throw new Error('Failed to fetch example')
}
const data = await response.json()
return data.svg
}
export function DisplayOptionsPreview({ formState }: DisplayOptionsPreviewProps) {
const operator = formState.operator ?? 'addition'
// Local state for operands (not debounced - we want immediate feedback)
const [operands, setOperands] = useState([45, 27])
// Build options based on operator type
const buildOptions = () => {
// Get displayRules from formState (all modes now use displayRules)
const displayRules = formState.displayRules ?? {
carryBoxes: 'always',
answerBoxes: 'always',
placeValueColors: 'always',
tenFrames: 'never',
problemNumbers: 'always',
cellBorders: 'always',
borrowNotation: 'always',
borrowingHints: 'never',
}
// The API expects boolean flags, so we evaluate displayRules against the preview problem
// For preview purposes, we'll assume a problem that requires regrouping with 2 digits
const previewProblemMeta = {
requiresRegrouping: true,
regroupCount: 1,
maxDigits: 2,
}
const evaluateRule = (mode: string) => {
switch (mode) {
case 'always':
return true
case 'never':
return false
case 'whenRegrouping':
return previewProblemMeta.requiresRegrouping
case 'whenMultipleRegroups':
return previewProblemMeta.regroupCount >= 2
case 'when3PlusDigits':
return previewProblemMeta.maxDigits >= 3
default:
return false
}
}
const base = {
showCarryBoxes: evaluateRule(displayRules.carryBoxes),
showAnswerBoxes: evaluateRule(displayRules.answerBoxes),
showPlaceValueColors: evaluateRule(displayRules.placeValueColors),
showProblemNumbers: evaluateRule(displayRules.problemNumbers),
showCellBorder: evaluateRule(displayRules.cellBorders),
showTenFrames: evaluateRule(displayRules.tenFrames),
showTenFramesForAll: false, // Deprecated in V4
showBorrowNotation: evaluateRule(displayRules.borrowNotation),
showBorrowingHints: evaluateRule(displayRules.borrowingHints),
operator,
}
if (operator === 'addition') {
return {
...base,
addend1: operands[0],
addend2: operands[1],
}
} else {
// Subtraction (mixed mode shows subtraction in preview)
return {
...base,
minuend: operands[0],
subtrahend: operands[1],
}
}
}
// Debounce the display options to avoid hammering the server
const [debouncedOptions, setDebouncedOptions] = useState(buildOptions())
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedOptions(buildOptions())
}, 300) // 300ms debounce
return () => clearTimeout(timer)
}, [formState.displayRules, formState.operator, operands])
const { data: svg, isLoading } = useQuery({
queryKey: ['display-example', debouncedOptions],
queryFn: () => fetchExample(debouncedOptions),
staleTime: 5 * 60 * 1000, // 5 minutes
})
return (
<div
data-component="display-options-preview"
className={css({
p: '3',
bg: 'white',
rounded: 'xl',
border: '2px solid',
borderColor: 'brand.200',
display: 'flex',
flexDirection: 'column',
gap: '2',
width: 'fit-content',
maxWidth: '100%',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Preview
</div>
<MathSentence
operands={operands}
operator={operator === 'addition' ? '+' : ''}
onChange={setOperands}
labels={operator === 'addition' ? ['addend', 'addend'] : ['minuend', 'subtrahend']}
/>
</div>
{isLoading ? (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minH: '200px',
color: 'gray.400',
fontSize: 'sm',
})}
>
Generating preview...
</div>
) : svg ? (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minH: '200px',
'& svg': {
maxW: 'full',
h: 'auto',
},
})}
dangerouslySetInnerHTML={{ __html: svg }}
/>
) : null}
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import { useState } from 'react'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
interface FloatingPageIndicatorProps {
currentPage: number
totalPages: number
onJumpToPage: (pageIndex: number) => void
isScrolling?: boolean
}
export function FloatingPageIndicator({
currentPage,
totalPages,
onJumpToPage,
isScrolling = false,
}: FloatingPageIndicatorProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [isHovered, setIsHovered] = useState(false)
if (totalPages <= 1) return null
const isActive = isHovered || isScrolling
return (
<div
data-component="floating-page-indicator"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={css({
position: 'sticky',
top: '4',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 10,
bg: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(8px)',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
rounded: 'full',
px: '4',
py: '2',
shadow: 'lg',
display: 'inline-flex',
alignItems: 'center',
gap: '3',
opacity: isActive ? 1 : 0.7,
transition: 'opacity 0.3s ease-in-out',
})}
>
<button
onClick={() => onJumpToPage(Math.max(0, currentPage - 1))}
disabled={currentPage === 0}
className={css({
px: '2',
py: '1',
rounded: 'md',
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
cursor: 'pointer',
transition: 'all 0.2s',
_disabled: {
opacity: 0.3,
cursor: 'not-allowed',
},
_hover: {
bg: isDark ? 'gray.700' : 'gray.100',
},
})}
>
</button>
<span
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.100' : 'gray.900',
minW: '20',
textAlign: 'center',
})}
>
Page {currentPage + 1} of {totalPages}
</span>
<button
onClick={() => onJumpToPage(Math.min(totalPages - 1, currentPage + 1))}
disabled={currentPage === totalPages - 1}
className={css({
px: '2',
py: '1',
rounded: 'md',
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
cursor: 'pointer',
transition: 'all 0.2s',
_disabled: {
opacity: 0.3,
cursor: 'not-allowed',
},
_hover: {
bg: isDark ? 'gray.700' : 'gray.100',
},
})}
>
</button>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import { useTranslations } from 'next-intl'
import { css } from '@styled/css'
import { hstack } from '@styled/patterns'
type GenerationStatus = 'idle' | 'generating' | 'error'
interface GenerateButtonProps {
status: GenerationStatus
onGenerate: () => void
isDark?: boolean
}
/**
* Button to trigger worksheet PDF generation
* Shows loading state during generation
*/
export function GenerateButton({ status, onGenerate, isDark = false }: GenerateButtonProps) {
const t = useTranslations('create.worksheets.addition')
const isGenerating = status === 'generating'
return (
<button
type="button"
data-action="generate-worksheet"
onClick={onGenerate}
disabled={isGenerating}
className={css({
w: 'full',
px: '6',
py: '4',
bg: 'brand.600',
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
rounded: 'xl',
shadow: 'md',
transition: 'all 0.2s',
cursor: isGenerating ? 'not-allowed' : 'pointer',
opacity: isGenerating ? '0.7' : '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2',
border: '2px solid',
borderColor: 'brand.700',
_hover: isGenerating
? {}
: {
bg: 'brand.700',
borderColor: 'brand.800',
transform: 'translateY(-1px)',
shadow: 'lg',
},
_active: {
transform: 'translateY(0)',
},
})}
>
{isGenerating ? (
<>
<div
className={css({
w: '5',
h: '5',
border: '2px solid',
borderColor: 'white',
borderTopColor: 'transparent',
rounded: 'full',
animation: 'spin 1s linear infinite',
})}
/>
<span>Generating PDF...</span>
</>
) : (
<>
<span className={css({ fontSize: 'xl' })}></span>
<span>Download PDF</span>
</>
)}
</button>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { useTranslations } from 'next-intl'
import { css } from '@styled/css'
import { stack, hstack } from '@styled/patterns'
interface GenerationErrorDisplayProps {
error: string | null
visible: boolean
onRetry: () => void
}
/**
* Display generation errors with retry button
* Only visible when error state is active
*/
export function GenerationErrorDisplay({ error, visible, onRetry }: GenerationErrorDisplayProps) {
const t = useTranslations('create.worksheets.addition')
if (!visible || !error) {
return null
}
return (
<div
data-status="error"
className={css({
bg: 'red.50',
border: '1px solid',
borderColor: 'red.200',
rounded: '2xl',
p: '8',
mt: '8',
})}
>
<div className={stack({ gap: '4' })}>
<div className={hstack({ gap: '3', alignItems: 'center' })}>
<div className={css({ fontSize: '2xl' })}></div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'semibold',
color: 'red.800',
})}
>
{t('error.title')}
</h3>
</div>
<pre
className={css({
color: 'red.700',
lineHeight: 'relaxed',
whiteSpace: 'pre-wrap',
fontFamily: 'mono',
fontSize: 'sm',
overflowX: 'auto',
})}
>
{error}
</pre>
<button
type="button"
data-action="try-again"
onClick={onRetry}
className={css({
alignSelf: 'start',
px: '4',
py: '2',
bg: 'red.600',
color: 'white',
fontWeight: 'medium',
rounded: 'lg',
transition: 'all',
_hover: { bg: 'red.700' },
})}
>
{t('error.tryAgain')}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import { css } from '@styled/css'
interface ModeSelectorProps {
currentMode: 'smart' | 'manual' | 'mastery'
onChange: (mode: 'smart' | 'manual' | 'mastery') => void
isDark?: boolean
}
/**
* Mode selector tabs for worksheet generation
* Large, prominent tabs that switch between Smart Difficulty, Manual Control, and Mastery Progression modes
*/
export function ModeSelector({ currentMode, onChange, isDark = false }: ModeSelectorProps) {
const modes = [
{
id: 'smart' as const,
emoji: '🎯',
label: 'Smart Difficulty',
description: 'Research-backed progressive difficulty with adaptive scaffolding per problem',
},
{
id: 'manual' as const,
emoji: '🎛️',
label: 'Manual Control',
description: 'Full control over display options with uniform scaffolding across all problems',
},
{
id: 'mastery' as const,
emoji: '🎓',
label: 'Mastery Progression',
description: 'Skill-based progression with automatic review mixing for pedagogical practice',
},
]
const currentModeData = modes.find((m) => m.id === currentMode)
return (
<div data-component="mode-selector-tabs">
{/* Tab buttons */}
<div
className={css({
display: 'flex',
gap: '0.5rem',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
{modes.map((mode) => {
const isActive = currentMode === mode.id
return (
<button
key={mode.id}
type="button"
data-action={`select-${mode.id}-mode`}
data-selected={isActive}
onClick={() => onChange(mode.id)}
className={css({
flex: 1,
padding: '1rem 1.5rem',
border: 'none',
borderBottom: '3px solid',
borderBottomColor: isActive ? 'blue.500' : 'transparent',
backgroundColor: isActive
? isDark
? 'gray.700'
: 'white'
: isDark
? 'gray.800'
: 'gray.50',
color: isActive
? isDark
? 'blue.300'
: 'blue.600'
: isDark
? 'gray.400'
: 'gray.600',
cursor: 'pointer',
transition: 'all 0.2s',
fontSize: '0.95rem',
fontWeight: isActive ? '700' : '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
_hover: {
backgroundColor: isActive
? isDark
? 'gray.700'
: 'white'
: isDark
? 'gray.700'
: 'gray.100',
borderBottomColor: isActive ? 'blue.500' : isDark ? 'gray.500' : 'gray.400',
color: isActive
? isDark
? 'blue.300'
: 'blue.600'
: isDark
? 'gray.300'
: 'gray.700',
},
})}
>
<span className={css({ fontSize: '1.25rem' })}>{mode.emoji}</span>
<span>{mode.label}</span>
</button>
)
})}
</div>
{/* Description of active mode */}
{currentModeData && (
<p
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
mt: '3',
mb: '3',
px: '2',
lineHeight: '1.5',
})}
>
{currentModeData.description}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,889 @@
'use client'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { css } from '@styled/css'
import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations'
interface OrientationPanelProps {
orientation: 'portrait' | 'landscape'
problemsPerPage: number
pages: number
cols: number
onOrientationChange: (
orientation: 'portrait' | 'landscape',
problemsPerPage: number,
cols: number
) => void
onProblemsPerPageChange: (problemsPerPage: number, cols: number) => void
onPagesChange: (pages: number) => void
isDark?: boolean
// Layout options
problemNumbers?: 'always' | 'never'
cellBorders?: 'always' | 'never'
onProblemNumbersChange?: (value: 'always' | 'never') => void
onCellBordersChange?: (value: 'always' | 'never') => void
}
/**
* Orientation, pages, and problems per page controls
* Compact layout with grid visualizations in dropdown
*/
export function OrientationPanel({
orientation,
problemsPerPage,
pages,
cols,
onOrientationChange,
onProblemsPerPageChange,
onPagesChange,
isDark = false,
problemNumbers = 'always',
cellBorders = 'always',
onProblemNumbersChange,
onCellBordersChange,
}: OrientationPanelProps) {
const handleOrientationChange = (newOrientation: 'portrait' | 'landscape') => {
const newProblemsPerPage = newOrientation === 'portrait' ? 15 : 20
const newCols = getDefaultColsForProblemsPerPage(newProblemsPerPage, newOrientation)
onOrientationChange(newOrientation, newProblemsPerPage, newCols)
}
const handleProblemsPerPageChange = (count: number) => {
const newCols = getDefaultColsForProblemsPerPage(count, orientation)
onProblemsPerPageChange(count, newCols)
}
const total = problemsPerPage * pages
const problemsForOrientation =
orientation === 'portrait' ? [6, 8, 10, 12, 15] : [8, 10, 12, 15, 16, 20]
return (
<div
data-section="orientation-panel"
className={css({
bg: isDark ? 'gray.800' : 'white',
rounded: '2xl',
shadow: 'card',
p: '4',
minWidth: 0,
overflow: 'hidden',
'@media (max-width: 400px)': {
p: '3',
},
'@media (max-width: 300px)': {
p: '2',
},
})}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '3' })}>
{/* Row 1: Orientation + Pages */}
<div
className={css({
display: 'flex',
flexDirection: 'row',
gap: '3',
alignItems: 'end',
'@media (max-width: 444px)': {
flexDirection: 'column',
gap: '2',
},
})}
>
{/* Orientation */}
<div
className={css({
flex: '1',
minWidth: 0,
})}
>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1.5',
})}
>
Orientation
</div>
<div
className={css({
display: 'flex',
gap: '1.5',
'@media (max-width: 400px)': {
gap: '1',
},
})}
>
<button
type="button"
data-action="select-portrait"
onClick={() => handleOrientationChange('portrait')}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
flex: '1',
px: '2',
py: '1.5',
border: '2px solid',
borderColor:
orientation === 'portrait' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
bg:
orientation === 'portrait'
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
justifyContent: 'center',
minWidth: 0,
_hover: {
borderColor: 'brand.400',
},
'@media (max-width: 400px)': {
px: '1.5',
py: '1',
gap: '1',
},
'@media (max-width: 200px)': {
px: '1',
py: '0.5',
gap: '0.5',
},
})}
>
{/* Portrait page icon */}
<svg
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={css({
flexShrink: 0,
'@media (max-width: 300px)': {
width: '12px',
height: '16px',
},
})}
>
<rect
x="1"
y="1"
width="14"
height="18"
rx="1"
stroke="currentColor"
strokeWidth="2"
fill="none"
/>
<line
x1="3"
y1="4"
x2="13"
y2="4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="3"
y1="7"
x2="13"
y2="7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="3"
y1="10"
x2="10"
y2="10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color:
orientation === 'portrait'
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.300'
: 'gray.600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
'@media (max-width: 200px)': {
fontSize: '2xs',
},
'@media (max-width: 150px)': {
display: 'none',
},
})}
>
Portrait
</div>
</button>
<button
type="button"
data-action="select-landscape"
onClick={() => handleOrientationChange('landscape')}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
flex: '1',
px: '2',
py: '1.5',
border: '2px solid',
borderColor:
orientation === 'landscape' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
bg:
orientation === 'landscape'
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
justifyContent: 'center',
minWidth: 0,
_hover: {
borderColor: 'brand.400',
},
'@media (max-width: 400px)': {
px: '1.5',
py: '1',
gap: '1',
},
'@media (max-width: 200px)': {
px: '1',
py: '0.5',
gap: '0.5',
},
})}
>
{/* Landscape page icon */}
<svg
width="20"
height="16"
viewBox="0 0 20 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={css({
flexShrink: 0,
'@media (max-width: 300px)': {
width: '16px',
height: '12px',
},
})}
>
<rect
x="1"
y="1"
width="18"
height="14"
rx="1"
stroke="currentColor"
strokeWidth="2"
fill="none"
/>
<line
x1="3"
y1="4"
x2="17"
y2="4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="3"
y1="7"
x2="17"
y2="7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="3"
y1="10"
x2="13"
y2="10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color:
orientation === 'landscape'
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.300'
: 'gray.600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
'@media (max-width: 200px)': {
fontSize: '2xs',
},
'@media (max-width: 150px)': {
display: 'none',
},
})}
>
Landscape
</div>
</button>
</div>
</div>
{/* Pages */}
<div
className={css({
flexShrink: 0,
'@media (max-width: 444px)': {
width: '100%',
},
})}
>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1.5',
})}
>
Pages
</div>
<div
className={css({
display: 'flex',
gap: '1',
'@media (max-width: 444px)': {
width: '100%',
gap: '0.5',
},
})}
>
{[1, 2, 3, 4].map((pageCount) => {
const isSelected = pages === pageCount
return (
<button
key={pageCount}
type="button"
data-action={`select-pages-${pageCount}`}
onClick={() => onPagesChange(pageCount)}
className={css({
w: '8',
h: '8',
border: '2px solid',
borderColor: isSelected ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
bg: isSelected
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
rounded: 'lg',
cursor: 'pointer',
fontSize: 'xs',
fontWeight: 'bold',
color: isSelected
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.300'
: 'gray.600',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
_hover: {
borderColor: 'brand.400',
},
'@media (max-width: 444px)': {
w: '6',
h: '6',
fontSize: '2xs',
},
'@media (max-width: 300px)': {
w: '5',
h: '5',
fontSize: '2xs',
borderWidth: '1px',
},
})}
>
{pageCount}
</button>
)
})}
</div>
</div>
</div>
{/* Row 2: Problems per page dropdown + Total badge */}
<div
className={css({
display: 'flex',
flexDirection: 'row',
gap: '3',
alignItems: 'center',
'@media (max-width: 444px)': {
flexDirection: 'column',
gap: '2',
},
})}
>
<div
className={css({
flex: '1',
minWidth: 0,
})}
>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
display: 'block',
mb: '1.5',
})}
>
Problems per Page
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
data-action="open-problems-dropdown"
className={css({
w: 'full',
px: '2',
py: '2',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
bg: isDark ? 'gray.700' : 'white',
rounded: 'lg',
cursor: 'pointer',
fontSize: 'xs',
fontWeight: 'medium',
color: isDark ? 'gray.200' : 'gray.700',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minWidth: 0,
gap: '2',
_hover: {
borderColor: 'brand.400',
},
'@media (max-width: 200px)': {
px: '1',
fontSize: '2xs',
},
})}
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
})}
>
<span
className={css({
'@media (max-width: 250px)': {
display: 'none',
},
})}
>
{problemsPerPage} problems ({cols} cols × {Math.ceil(problemsPerPage / cols)}{' '}
rows)
</span>
<span
className={css({
display: 'none',
'@media (max-width: 250px)': {
display: 'inline',
},
})}
>
{problemsPerPage} ({cols}×{Math.ceil(problemsPerPage / cols)})
</span>
</span>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.500' : 'gray.400',
flexShrink: 0,
})}
>
</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={css({
bg: isDark ? 'gray.800' : 'white',
rounded: 'lg',
shadow: 'modal',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
p: '2',
minW: '64',
maxH: '96',
overflowY: 'auto',
zIndex: 50,
})}
sideOffset={5}
>
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1',
})}
>
{problemsForOrientation.map((count) => {
const itemCols = getDefaultColsForProblemsPerPage(count, orientation)
const rows = Math.ceil(count / itemCols)
const isSelected = problemsPerPage === count
return (
<DropdownMenu.Item
key={count}
data-action={`select-problems-${count}`}
onSelect={() => handleProblemsPerPageChange(count)}
className={
isDark
? css({
display: 'flex',
alignItems: 'center',
gap: '3',
px: '3',
py: '2',
rounded: 'md',
cursor: 'pointer',
outline: 'none',
bg: isSelected ? 'gray.700' : 'transparent',
_hover: {
bg: 'gray.700',
},
_focus: {
bg: 'gray.600',
},
})
: css({
display: 'flex',
alignItems: 'center',
gap: '3',
px: '3',
py: '2',
rounded: 'md',
cursor: 'pointer',
outline: 'none',
bg: isSelected ? 'brand.50' : 'transparent',
_hover: {
bg: 'brand.50',
},
_focus: {
bg: 'brand.100',
},
})
}
>
{/* Grid visualization */}
<div
className={css({
display: 'grid',
placeItems: 'center',
w: '12',
h: '12',
flexShrink: 0,
})}
style={{
gridTemplateColumns: `repeat(${itemCols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: '2px',
}}
>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className={css({
w: '1.5',
h: '1.5',
bg: isSelected ? 'brand.500' : isDark ? 'gray.500' : 'gray.400',
rounded: 'full',
})}
/>
))}
</div>
{/* Text description */}
<div className={css({ flex: 1 })}>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isSelected
? isDark
? 'white'
: 'brand.700'
: isDark
? 'gray.200'
: 'gray.700',
})}
>
{count} problems
</div>
<div
className={css({
fontSize: 'xs',
color: isSelected
? isDark
? 'gray.200'
: 'brand.600'
: isDark
? 'gray.400'
: 'gray.500',
})}
>
{itemCols} cols × {rows} rows
</div>
</div>
</DropdownMenu.Item>
)
})}
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/* Total problems badge */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1',
flexShrink: 0,
'@media (max-width: 444px)': {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
},
})}
>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Total
</div>
<div
className={css({
px: '4',
py: '2',
bg: 'brand.100',
rounded: 'full',
fontSize: 'lg',
fontWeight: 'bold',
color: 'brand.700',
})}
>
{total}
</div>
</div>
</div>
{/* Row 3: Layout Options */}
<div
className={css({
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
pt: '3',
mt: '1',
})}
>
<div
className={css({
fontSize: '2xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '2',
})}
>
Layout Options
</div>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2' })}>
{/* Problem Numbers Toggle */}
<label
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
})}
>
<div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.200' : 'gray.800',
})}
>
Problem Numbers
</div>
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Show problem numbers for reference
</div>
</div>
<button
type="button"
onClick={() => {
onProblemNumbersChange?.(problemNumbers === 'always' ? 'never' : 'always')
}}
className={css({
position: 'relative',
w: '12',
h: '6',
bg: problemNumbers === 'always' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
rounded: 'full',
cursor: 'pointer',
transition: 'background 0.2s',
flexShrink: 0,
})}
>
<div
className={css({
position: 'absolute',
top: '1',
left: problemNumbers === 'always' ? '7' : '1',
w: '4',
h: '4',
bg: 'white',
rounded: 'full',
transition: 'left 0.2s',
})}
/>
</button>
</label>
{/* Cell Borders Toggle */}
<label
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
})}
>
<div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.200' : 'gray.800',
})}
>
Cell Borders
</div>
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Show borders around answer cells
</div>
</div>
<button
type="button"
onClick={() => {
onCellBordersChange?.(cellBorders === 'always' ? 'never' : 'always')
}}
className={css({
position: 'relative',
w: '12',
h: '6',
bg: cellBorders === 'always' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
rounded: 'full',
cursor: 'pointer',
transition: 'background 0.2s',
flexShrink: 0,
})}
>
<div
className={css({
position: 'absolute',
top: '1',
left: cellBorders === 'always' ? '7' : '1',
w: '4',
h: '4',
bg: 'white',
rounded: 'full',
transition: 'left 0.2s',
})}
/>
</button>
</label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
interface PagePlaceholderProps {
pageNumber: number
}
export function PagePlaceholder({ pageNumber }: PagePlaceholderProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
return (
<div
data-component="page-placeholder"
data-page-number={pageNumber}
className={css({
bg: isDark ? 'gray.800' : 'gray.100',
border: '2px dashed',
borderColor: isDark ? 'gray.600' : 'gray.300',
rounded: 'lg',
minHeight: '800px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '4',
animation: 'pulse 2s ease-in-out infinite',
})}
>
<div
className={css({
fontSize: '4xl',
color: isDark ? 'gray.600' : 'gray.400',
})}
>
📄
</div>
<div
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
color: isDark ? 'gray.500' : 'gray.500',
})}
>
Page {pageNumber}
</div>
<div
className={css({
fontSize: 'sm',
color: isDark ? 'gray.600' : 'gray.400',
})}
>
Loading...
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { createContext, useContext, useMemo } from 'react'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
/**
* Context for worksheet configuration state
* Eliminates prop drilling for formState, onChange, and operator
*/
export interface WorksheetConfigContextValue {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
operator: 'addition' | 'subtraction' | 'mixed'
}
export const WorksheetConfigContext = createContext<WorksheetConfigContextValue | null>(null)
/**
* Hook to access worksheet configuration context
* @throws Error if used outside of WorksheetConfigProvider
*/
export function useWorksheetConfig() {
const context = useContext(WorksheetConfigContext)
if (!context) {
throw new Error('useWorksheetConfig must be used within WorksheetConfigProvider')
}
return context
}
export interface WorksheetConfigProviderProps {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
children: React.ReactNode
}
/**
* Provider component for worksheet configuration context
* Wrap config panel components to provide access to formState, onChange, and operator
*/
export function WorksheetConfigProvider({
formState,
onChange,
children,
}: WorksheetConfigProviderProps) {
const value = useMemo(
() => ({
formState,
onChange,
operator: formState.operator || 'addition',
}),
[formState, onChange]
)
return <WorksheetConfigContext.Provider value={value}>{children}</WorksheetConfigContext.Provider>
}

View File

@@ -0,0 +1,240 @@
'use client'
import React from 'react'
import { css } from '@styled/css'
import { stack, hstack } from '@styled/patterns'
interface Props {
children: React.ReactNode
}
interface State {
hasError: boolean
error: Error | null
errorInfo: React.ErrorInfo | null
}
/**
* Error Boundary for worksheet pages
* Catches JavaScript errors anywhere in the child component tree
* and displays a fallback UI instead of crashing the whole app.
*
* This is critical for production - users should NEVER have to open
* the browser console to understand what went wrong.
*/
export class WorksheetErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null, errorInfo: null }
}
static getDerivedStateFromError(error: Error): Partial<State> {
// Update state so the next render will show the fallback UI
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log error to console for debugging
console.error('Worksheet Error Boundary caught an error:', error, errorInfo)
// Store error info in state for display
this.setState({
error,
errorInfo,
})
// TODO: Send error to error reporting service (Sentry, etc.)
// Example:
// Sentry.captureException(error, {
// contexts: {
// react: {
// componentStack: errorInfo.componentStack,
// },
// },
// })
}
handleReset = () => {
// Clear error state and try to recover
this.setState({ hasError: false, error: null, errorInfo: null })
// Reload the page to get fresh state
window.location.reload()
}
render() {
if (this.state.hasError && this.state.error) {
const error = this.state.error
return (
<div
data-component="error-boundary"
className={css({
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'gray.50',
p: '8',
})}
>
<div
className={css({
maxW: '2xl',
w: 'full',
bg: 'white',
rounded: '2xl',
shadow: 'modal',
p: '8',
})}
>
<div className={stack({ gap: '6' })}>
{/* Error Icon & Title */}
<div className={stack({ gap: '3', textAlign: 'center' })}>
<div className={css({ fontSize: '6xl' })}></div>
<h1
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'red.700',
})}
>
Something went wrong
</h1>
<p className={css({ fontSize: 'md', color: 'gray.600' })}>
We encountered an unexpected error while loading the worksheet creator. This
shouldn't happen, and we apologize for the inconvenience.
</p>
</div>
{/* Error Details */}
<div
className={css({
bg: 'red.50',
border: '1px solid',
borderColor: 'red.200',
rounded: 'lg',
p: '4',
})}
>
<div className={stack({ gap: '2' })}>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'red.800',
})}
>
Error Details:
</div>
<pre
className={css({
fontSize: 'xs',
fontFamily: 'mono',
color: 'red.700',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowX: 'auto',
})}
>
{error.toString()}
</pre>
{this.state.errorInfo?.componentStack && (
<details className={css({ mt: '2' })}>
<summary
className={css({
fontSize: 'xs',
fontWeight: 'medium',
color: 'red.700',
cursor: 'pointer',
_hover: { color: 'red.800' },
})}
>
Component Stack (for developers)
</summary>
<pre
className={css({
fontSize: '2xs',
fontFamily: 'mono',
color: 'red.600',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
mt: '2',
maxH: '40',
overflowY: 'auto',
})}
>
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
</div>
{/* Action Buttons */}
<div className={hstack({ gap: '3', justify: 'center' })}>
<button
onClick={this.handleReset}
className={css({
px: '6',
py: '3',
bg: 'brand.600',
color: 'white',
fontWeight: 'semibold',
rounded: 'lg',
shadow: 'card',
transition: 'all',
cursor: 'pointer',
_hover: {
bg: 'brand.700',
transform: 'translateY(-1px)',
shadow: 'modal',
},
})}
>
Reload Page
</button>
<a
href="/"
className={css({
px: '6',
py: '3',
bg: 'white',
color: 'gray.700',
fontWeight: 'semibold',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.300',
shadow: 'card',
transition: 'all',
cursor: 'pointer',
textDecoration: 'none',
_hover: {
bg: 'gray.50',
borderColor: 'gray.400',
},
})}
>
Go to Home
</a>
</div>
{/* Help Text */}
<p
className={css({
fontSize: 'sm',
color: 'gray.500',
textAlign: 'center',
})}
>
If this problem persists, please report it via GitHub Issues.
</p>
</div>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,561 @@
'use client'
import { Suspense, useState, useEffect, useRef, Component, type ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { css } from '@styled/css'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { FloatingPageIndicator } from './FloatingPageIndicator'
import { PagePlaceholder } from './PagePlaceholder'
import { useTheme } from '@/contexts/ThemeContext'
interface WorksheetPreviewProps {
formState: WorksheetFormState
initialData?: string[]
isScrolling?: boolean
}
function getDefaultDate(): string {
const now = new Date()
return now.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<string[]> {
// Set current date for preview
const configWithDate = {
...formState,
date: getDefaultDate(),
}
// Use absolute URL for SSR compatibility
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'
const url = `${baseUrl}/api/create/worksheets/preview`
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(configWithDate),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMsg = errorData.error || errorData.message || 'Failed to fetch preview'
const details = errorData.details ? `\n\n${errorData.details}` : ''
const errors = errorData.errors ? `\n\nErrors:\n${errorData.errors.join('\n')}` : ''
throw new Error(errorMsg + details + errors)
}
const data = await response.json()
return data.pages
}
function PreviewContent({ formState, initialData, isScrolling = false }: WorksheetPreviewProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [visiblePages, setVisiblePages] = useState<Set<number>>(new Set([0]))
const [currentPage, setCurrentPage] = useState(0)
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
// Track if we've used the initial data (so we only use it once)
const initialDataUsed = useRef(false)
// Only use initialData on the very first query, not on subsequent fetches
const queryInitialData = !initialDataUsed.current && initialData ? initialData : undefined
if (queryInitialData) {
initialDataUsed.current = true
}
// Use Suspense Query - will suspend during loading
const { data: pages } = useSuspenseQuery({
queryKey: [
'worksheet-preview',
// PRIMARY state
formState.problemsPerPage,
formState.cols,
formState.pages,
formState.orientation,
// V4: Problem size (CRITICAL - affects column layout and problem generation)
formState.digitRange?.min,
formState.digitRange?.max,
// V4: Operator selection (addition, subtraction, or mixed)
formState.operator,
// V4: Mode and conditional display settings
formState.mode,
formState.displayRules, // Smart mode: conditional scaffolding
formState.difficultyProfile, // Smart mode: difficulty preset
formState.manualPreset, // Manual mode: manual preset
// Mastery mode: skill IDs (CRITICAL for mastery+mixed mode)
formState.currentAdditionSkillId,
formState.currentSubtractionSkillId,
formState.currentStepId,
// Other settings that affect appearance
formState.name,
formState.pAnyStart,
formState.pAllStart,
formState.interpolate,
formState.showCarryBoxes,
formState.showAnswerBoxes,
formState.showPlaceValueColors,
formState.showProblemNumbers,
formState.showCellBorder,
formState.showTenFrames,
formState.showTenFramesForAll,
formState.seed, // Include seed to bust cache when problem set regenerates
// Note: fontSize, date, rows, total intentionally excluded
// (rows and total are derived from primary state)
],
queryFn: () => fetchWorksheetPreview(formState),
initialData: queryInitialData, // Only use on first render
})
const totalPages = pages.length
// Track when refs are fully populated
const [refsReady, setRefsReady] = useState(false)
// Reset to first page and visible pages when preview updates
useEffect(() => {
setCurrentPage(0)
setVisiblePages(new Set([0]))
pageRefs.current = []
setRefsReady(false)
}, [pages])
// Check if all refs are populated after each render
useEffect(() => {
if (totalPages > 1 && pageRefs.current.length === totalPages) {
const allPopulated = pageRefs.current.every((ref) => ref !== null)
if (allPopulated && !refsReady) {
setRefsReady(true)
}
}
})
// Intersection Observer to track visible pages
useEffect(() => {
if (totalPages <= 1) {
return // No need for virtualization with single page
}
// Wait for refs to be populated
if (!refsReady) {
return
}
const observer = new IntersectionObserver(
(entries) => {
setVisiblePages((prev) => {
const next = new Set(prev)
entries.forEach((entry) => {
const pageIndex = Number(entry.target.getAttribute('data-page-index'))
if (entry.isIntersecting) {
// Add visible page
next.add(pageIndex)
// Preload adjacent pages for smooth scrolling
if (pageIndex > 0) next.add(pageIndex - 1)
if (pageIndex < totalPages - 1) next.add(pageIndex + 1)
// Update current page indicator based on most visible page
if (entry.intersectionRatio > 0.5) {
setCurrentPage(pageIndex)
}
}
})
return next
})
},
{
root: null, // Use viewport as root (scrolling happens in parent)
rootMargin: '50% 0px', // Start loading when page is 50% away from viewport
threshold: [0, 0.5, 1],
}
)
// Observe all page containers
pageRefs.current.forEach((ref) => {
if (ref) {
observer.observe(ref)
}
})
return () => {
observer.disconnect()
}
}, [totalPages, refsReady])
// Jump to page function for floating indicator
const jumpToPage = (pageIndex: number) => {
pageRefs.current[pageIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return (
<div
data-component="worksheet-preview"
className={css({
bg: isDark ? 'gray.700' : 'white',
rounded: 'lg',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
position: 'relative',
minHeight: 'full',
})}
>
{/* Floating page indicator */}
{totalPages > 1 && (
<FloatingPageIndicator
currentPage={currentPage}
totalPages={totalPages}
onJumpToPage={jumpToPage}
isScrolling={isScrolling}
/>
)}
{/* Page containers */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '6',
p: '4',
})}
>
{pages.map((page, index) => (
<div
key={index}
ref={(el) => (pageRefs.current[index] = el)}
data-page-index={index}
data-element="page-container"
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
{visiblePages.has(index) ? (
<div
className={css({
'& svg': {
maxWidth: '100%',
height: 'auto',
width: 'auto',
},
})}
dangerouslySetInnerHTML={{ __html: page }}
/>
) : (
<PagePlaceholder pageNumber={index + 1} />
)}
</div>
))}
</div>
</div>
)
}
function PreviewFallback() {
return (
<div
data-component="worksheet-preview-loading"
className={css({
bg: 'white',
rounded: '2xl',
p: '6',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '600px',
})}
>
<p
className={css({
fontSize: 'lg',
color: 'gray.400',
textAlign: 'center',
})}
>
Generating preview...
</p>
</div>
)
}
function PreviewErrorFallback({ error, onRetry }: { error: Error; onRetry: () => void }) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Log full error details to console
useEffect(() => {
console.error('[WorksheetPreview] Preview generation failed:', {
message: error.message,
stack: error.stack,
error,
})
}, [error])
return (
<div
data-component="worksheet-preview-error"
className={css({
bg: isDark ? 'gray.800' : 'white',
rounded: 'xl',
p: '6',
border: '2px solid',
borderColor: 'red.300',
display: 'flex',
flexDirection: 'column',
gap: '4',
minHeight: '400px',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
gap: '3',
})}
>
<div
className={css({
fontSize: '3xl',
flexShrink: 0,
})}
>
</div>
<div className={css({ flex: 1 })}>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: isDark ? 'red.300' : 'red.600',
mb: '2',
})}
>
Preview Generation Failed
</h3>
<p
className={css({
fontSize: 'sm',
color: isDark ? 'gray.300' : 'gray.600',
mb: '3',
lineHeight: '1.6',
})}
>
The worksheet preview could not be generated. This usually happens due to invalid
settings or a temporary server issue. Your settings are still saved.
</p>
{/* Actionable suggestions */}
<div
className={css({
bg: isDark ? 'gray.900' : 'gray.50',
p: '4',
rounded: 'lg',
fontSize: 'sm',
mb: '3',
})}
>
<h4
className={css({
fontWeight: 'semibold',
color: isDark ? 'gray.200' : 'gray.800',
mb: '2',
})}
>
Try these steps:
</h4>
<ul
className={css({
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: '2',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
<li className={css({ display: 'flex', gap: '2' })}>
<span>1.</span>
<span>Click the "Retry Preview" button below to try generating again</span>
</li>
<li className={css({ display: 'flex', gap: '2' })}>
<span>2.</span>
<span>
Try adjusting your worksheet settings (e.g., reduce problems per page or number of
pages)
</span>
</li>
<li className={css({ display: 'flex', gap: '2' })}>
<span>3.</span>
<span>
Check if you have extreme values in difficulty settings that might be causing
issues
</span>
</li>
<li className={css({ display: 'flex', gap: '2' })}>
<span>4.</span>
<span>
If the preview continues to fail, you can still try generating the full worksheet
PDF
</span>
</li>
</ul>
</div>
{/* Retry button */}
<button
onClick={onRetry}
className={css({
px: '4',
py: '2',
bg: isDark ? 'blue.600' : 'blue.500',
color: 'white',
rounded: 'lg',
fontWeight: 'medium',
fontSize: 'sm',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: isDark ? 'blue.700' : 'blue.600',
transform: 'translateY(-1px)',
boxShadow: 'md',
},
_active: {
transform: 'translateY(0)',
},
})}
>
🔄 Retry Preview
</button>
</div>
</div>
{/* Technical details (collapsible) */}
<details
className={css({
bg: isDark ? 'gray.900' : 'gray.50',
p: '3',
rounded: 'md',
fontSize: 'sm',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<summary
className={css({
cursor: 'pointer',
fontWeight: 'medium',
color: isDark ? 'gray.400' : 'gray.600',
_hover: {
color: isDark ? 'gray.300' : 'gray.900',
},
})}
>
Technical Details (for debugging)
</summary>
<div className={css({ mt: '3' })}>
<div
className={css({
mb: '2',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Error message:
</div>
<pre
className={css({
p: '2',
bg: isDark ? 'gray.950' : 'white',
rounded: 'sm',
fontSize: 'xs',
overflow: 'auto',
color: isDark ? 'red.300' : 'red.600',
mb: '3',
border: '1px solid',
borderColor: isDark ? 'gray.800' : 'gray.200',
})}
>
{error.message}
</pre>
{error.stack && (
<>
<div
className={css({
mb: '2',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Stack trace (also logged to browser console):
</div>
<pre
className={css({
p: '2',
bg: isDark ? 'gray.950' : 'white',
rounded: 'sm',
fontSize: 'xs',
overflow: 'auto',
maxHeight: '200px',
color: isDark ? 'gray.400' : 'gray.600',
border: '1px solid',
borderColor: isDark ? 'gray.800' : 'gray.200',
})}
>
{error.stack}
</pre>
</>
)}
</div>
</details>
</div>
)
}
class PreviewErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode }) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[WorksheetPreview] Error caught by boundary:', error, errorInfo)
}
handleRetry = () => {
console.log('[WorksheetPreview] Retry requested - resetting error boundary')
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError && this.state.error) {
return <PreviewErrorFallback error={this.state.error} onRetry={this.handleRetry} />
}
return this.props.children
}
}
export function WorksheetPreview({ formState, initialData, isScrolling }: WorksheetPreviewProps) {
return (
<PreviewErrorBoundary>
<Suspense fallback={<PreviewFallback />}>
<PreviewContent formState={formState} initialData={initialData} isScrolling={isScrolling} />
</Suspense>
</PreviewErrorBoundary>
)
}

View File

@@ -0,0 +1,591 @@
'use client'
import { useState } from 'react'
import * as Tabs from '@radix-ui/react-tabs'
import * as Accordion from '@radix-ui/react-accordion'
import * as Progress from '@radix-ui/react-progress'
import * as Checkbox from '@radix-ui/react-checkbox'
import * as Tooltip from '@radix-ui/react-tooltip'
import { css } from '@styled/css'
import type { SkillId, SkillDefinition } from '../../skills'
interface AllSkillsModalProps {
isOpen: boolean
onClose: () => void
skills: SkillDefinition[]
currentSkillId: SkillId
masteryStates: Map<SkillId, boolean>
onSelectSkill: (skillId: SkillId) => void
onToggleMastery: (skillId: SkillId, isMastered: boolean) => void
isDark?: boolean
}
type FilterTab = 'all' | 'mastered' | 'available' | 'locked'
/**
* All Skills Modal - Skills Mastery Dashboard
*
* Synthesized design combining:
* - Progress overview at top
* - Tabbed filtering (All/Mastered/Available/Locked)
* - Grouped accordion sections
* - Streamlined skill cards with color-coded stripes
* - Quick mastery checkboxes + practice buttons
*/
export function AllSkillsModal({
isOpen,
onClose,
skills,
currentSkillId,
masteryStates,
onSelectSkill,
onToggleMastery,
isDark = false,
}: AllSkillsModalProps) {
const [activeTab, setActiveTab] = useState<FilterTab>('all')
if (!isOpen) return null
// Calculate progress
const masteredCount = skills.filter((s) => masteryStates.get(s.id) === true).length
const totalCount = skills.length
const progressPercentage = totalCount > 0 ? (masteredCount / totalCount) * 100 : 0
// Categorize skills
const masteredSkills = skills.filter((s) => masteryStates.get(s.id) === true)
const availableSkills = skills.filter((s) => {
const isMastered = masteryStates.get(s.id) === true
const prerequisitesMet = s.prerequisites.every(
(prereqId) => masteryStates.get(prereqId) === true
)
const isAvailable = s.prerequisites.length === 0 || prerequisitesMet
return !isMastered && isAvailable
})
const lockedSkills = skills.filter((s) => {
const isMastered = masteryStates.get(s.id) === true
const prerequisitesMet = s.prerequisites.every(
(prereqId) => masteryStates.get(prereqId) === true
)
const isAvailable = s.prerequisites.length === 0 || prerequisitesMet
return !isMastered && !isAvailable
})
// Filter skills based on active tab
const getFilteredSkills = (): SkillDefinition[] => {
switch (activeTab) {
case 'mastered':
return masteredSkills
case 'available':
return availableSkills
case 'locked':
return lockedSkills
case 'all':
default:
return skills
}
}
const filteredSkills = getFilteredSkills()
// Helper to render a skill card
const renderSkillCard = (skill: SkillDefinition) => {
const isMastered = masteryStates.get(skill.id) === true
const isCurrent = skill.id === currentSkillId
const prerequisitesMet = skill.prerequisites.every(
(prereqId) => masteryStates.get(prereqId) === true
)
const isAvailable = skill.prerequisites.length === 0 || prerequisitesMet
const isLocked = !isAvailable
// Determine stripe color
const stripeColor = isMastered
? 'green.500'
: isCurrent
? 'blue.500'
: isLocked
? 'gray.400'
: 'gray.300'
// Determine icon
const icon = isMastered ? '✓' : isCurrent ? '⭐' : isLocked ? '🔒' : '○'
return (
<div
key={skill.id}
data-skill-id={skill.id}
className={css({
position: 'relative',
padding: '1rem',
paddingLeft: '1.25rem',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
backgroundColor: isDark ? 'gray.700' : 'white',
opacity: isLocked ? 0.7 : 1,
transition: 'all 0.2s',
_hover: {
borderColor: isDark ? 'gray.500' : 'gray.300',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)',
},
})}
>
{/* Color-coded left stripe */}
<div
className={css({
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '4px',
borderTopLeftRadius: '8px',
borderBottomLeftRadius: '8px',
backgroundColor: stripeColor,
})}
/>
<div
className={css({
display: 'flex',
gap: '1rem',
alignItems: 'flex-start',
})}
>
{/* Icon + Name + Description */}
<div className={css({ flex: 1, minWidth: 0 })}>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.25rem',
})}
>
<span className={css({ fontSize: '1.125rem', lineHeight: '1' })}>{icon}</span>
<h4
className={css({
fontSize: '0.9375rem',
fontWeight: 600,
color: isDark ? 'white' : 'gray.900',
})}
>
{skill.name}
{isCurrent && (
<span
className={css({
marginLeft: '0.5rem',
fontSize: '0.75rem',
fontWeight: 500,
color: 'blue.600',
})}
>
(Current)
</span>
)}
</h4>
</div>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.600',
lineHeight: '1.4',
})}
>
{skill.description}
</p>
{/* Prerequisites for locked skills */}
{isLocked && skill.prerequisites.length > 0 && (
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginTop: '0.5rem',
fontStyle: 'italic',
})}
>
Requires:{' '}
{skill.prerequisites
.map((prereqId) => {
const prereq = skills.find((s) => s.id === prereqId)
return prereq?.name || prereqId
})
.join(', ')}
</p>
)}
</div>
{/* Actions: Checkbox + Practice Button */}
<div
className={css({
display: 'flex',
gap: '0.75rem',
alignItems: 'center',
flexShrink: 0,
})}
>
{/* Mastery Checkbox */}
{isAvailable && (
<Tooltip.Root delayDuration={200}>
<Tooltip.Trigger asChild>
<div>
<Checkbox.Root
checked={isMastered}
onCheckedChange={(checked) => onToggleMastery(skill.id, checked === true)}
className={css({
width: '20px',
height: '20px',
borderRadius: '4px',
border: '2px solid',
borderColor: isMastered ? 'green.500' : isDark ? 'gray.500' : 'gray.300',
backgroundColor: isMastered ? 'green.500' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: isMastered ? 'green.600' : 'blue.400',
},
})}
>
<Checkbox.Indicator
className={css({
color: 'white',
fontSize: '0.75rem',
fontWeight: 'bold',
})}
>
</Checkbox.Indicator>
</Checkbox.Root>
</div>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className={css({
backgroundColor: isDark ? 'gray.800' : 'gray.900',
color: 'white',
padding: '0.5rem 0.75rem',
borderRadius: '6px',
fontSize: '0.75rem',
zIndex: 10002,
})}
>
{isMastered ? 'Mark as not mastered' : 'Mark as mastered'}
<Tooltip.Arrow
className={css({
fill: isDark ? 'gray.800' : 'gray.900',
})}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)}
{/* Practice Button */}
{!isCurrent && isAvailable && (
<button
type="button"
data-action="select-skill"
onClick={() => {
onSelectSkill(skill.id)
onClose()
}}
className={css({
padding: '0.5rem 0.75rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.500' : 'gray.300',
backgroundColor: isDark ? 'gray.600' : 'white',
color: isDark ? 'gray.200' : 'gray.700',
fontSize: '0.75rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
whiteSpace: 'nowrap',
_hover: {
borderColor: 'blue.400',
backgroundColor: 'blue.50',
color: 'blue.700',
},
})}
>
Practice
</button>
)}
</div>
</div>
</div>
)
}
return (
<div
data-component="all-skills-modal-overlay"
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
padding: '1rem',
})}
onClick={onClose}
>
<div
data-component="all-skills-modal"
className={css({
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
maxWidth: '700px',
width: '100%',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
})}
onClick={(e) => e.stopPropagation()}
>
{/* Header with Progress */}
<div
className={css({
padding: '1.5rem',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
})}
>
<div className={css({ flex: 1 })}>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 600,
color: isDark ? 'white' : 'gray.900',
marginBottom: '0.5rem',
})}
>
Skills Mastery Dashboard
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '0.75rem',
})}
>
{skills[0]?.operator === 'addition' ? 'Addition' : 'Subtraction'} {masteredCount}/
{totalCount} skills mastered
</p>
{/* Progress Bar */}
<Progress.Root
value={progressPercentage}
className={css({
width: '100%',
height: '8px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '999px',
overflow: 'hidden',
})}
>
<Progress.Indicator
className={css({
width: '100%',
height: '100%',
backgroundColor: 'green.500',
transition: 'transform 0.3s ease',
})}
style={{
transform: `translateX(-${100 - progressPercentage}%)`,
}}
/>
</Progress.Root>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginTop: '0.25rem',
})}
>
{Math.round(progressPercentage)}% complete
</p>
</div>
<button
type="button"
data-action="close-modal"
onClick={onClose}
className={css({
padding: '0.5rem',
marginLeft: '1rem',
borderRadius: '6px',
border: 'none',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.600',
cursor: 'pointer',
fontSize: '1.5rem',
lineHeight: '1',
transition: 'all 0.2s',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
color: isDark ? 'gray.200' : 'gray.900',
},
})}
>
×
</button>
</div>
</div>
{/* Tabs for Filtering */}
<Tooltip.Provider delayDuration={300}>
<Tabs.Root
value={activeTab}
onValueChange={(value) => setActiveTab(value as FilterTab)}
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
})}
>
<Tabs.List
className={css({
display: 'flex',
gap: '0.5rem',
padding: '1rem 1.5rem 0 1.5rem',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
flexShrink: 0,
})}
>
{[
{ value: 'all', label: 'All', count: skills.length },
{
value: 'mastered',
label: 'Mastered',
count: masteredSkills.length,
},
{
value: 'available',
label: 'Available',
count: availableSkills.length,
},
{
value: 'locked',
label: 'Locked',
count: lockedSkills.length,
},
].map((tab) => (
<Tabs.Trigger key={tab.value} value={tab.value} asChild>
<button
type="button"
className={css({
padding: '0.5rem 1rem',
fontSize: '0.875rem',
fontWeight: 500,
border: 'none',
borderBottom: '2px solid',
borderColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.600',
cursor: 'pointer',
transition: 'all 0.2s',
backgroundColor: 'transparent',
_hover: {
color: isDark ? 'gray.200' : 'gray.900',
},
'&[data-state=active]': {
color: 'blue.600',
borderColor: 'blue.600',
},
})}
>
{tab.label} ({tab.count})
</button>
</Tabs.Trigger>
))}
</Tabs.List>
{/* Tab Content - Skills List */}
<Tabs.Content
value={activeTab}
className={css({
flex: 1,
overflowY: 'auto',
padding: '1rem 1.5rem',
minHeight: 0,
})}
>
{filteredSkills.length === 0 ? (
<div
className={css({
padding: '2rem',
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.600',
fontSize: '0.875rem',
})}
>
No {activeTab} skills
</div>
) : (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
{filteredSkills.map((skill) => renderSkillCard(skill))}
</div>
)}
</Tabs.Content>
</Tabs.Root>
</Tooltip.Provider>
{/* Footer */}
<div
className={css({
padding: '1rem 1.5rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
justifyContent: 'flex-end',
})}
>
<button
type="button"
data-action="close-modal"
onClick={onClose}
className={css({
padding: '0.75rem 1.5rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.200' : 'gray.700',
fontSize: '0.875rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.50',
},
})}
>
Close
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,481 @@
'use client'
import { useState, useEffect } from 'react'
import { css } from '@styled/css'
import type { SkillId, SkillDefinition } from '../../skills'
interface CustomizeMixModalProps {
isOpen: boolean
onClose: () => void
currentSkill: SkillDefinition
masteryStates: Map<SkillId, boolean>
currentMixRatio: number // 0-1, where 0.25 = 25% review
currentSelectedReviewSkills?: SkillId[]
onApply: (mixRatio: number, selectedReviewSkills: SkillId[]) => void
isDark?: boolean
}
/**
* Customize Mix Modal
*
* Allows users to customize the worksheet mix:
* - Adjust review ratio (0-100% review)
* - Select which mastered skills to include in review
* - Reset to defaults (75% current, all recommended review skills)
*/
export function CustomizeMixModal({
isOpen,
onClose,
currentSkill,
masteryStates,
currentMixRatio,
currentSelectedReviewSkills,
onApply,
isDark = false,
}: CustomizeMixModalProps) {
const [mixRatio, setMixRatio] = useState(currentMixRatio)
const [selectedReviewSkills, setSelectedReviewSkills] = useState<Set<SkillId>>(new Set())
// Get mastered skills from recommendedReview
const masteredReviewSkills = currentSkill.recommendedReview.filter(
(skillId) => masteryStates.get(skillId) === true
)
// Initialize state when modal opens
useEffect(() => {
if (isOpen) {
setMixRatio(currentMixRatio)
// Get mastered review skills at the time modal opens
const mastered = currentSkill.recommendedReview.filter(
(skillId) => masteryStates.get(skillId) === true
)
if (currentSelectedReviewSkills && currentSelectedReviewSkills.length > 0) {
// Use user's custom selection
setSelectedReviewSkills(new Set(currentSelectedReviewSkills))
} else {
// Default to all mastered review skills
setSelectedReviewSkills(new Set(mastered))
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]) // Only run when modal opens, not when props change
if (!isOpen) return null
const handleReset = () => {
setMixRatio(0.25) // Default 25% review
setSelectedReviewSkills(new Set(masteredReviewSkills)) // All recommended
}
const handleApply = () => {
onApply(mixRatio, Array.from(selectedReviewSkills))
onClose()
}
const toggleReviewSkill = (skillId: SkillId) => {
const newSet = new Set(selectedReviewSkills)
if (newSet.has(skillId)) {
newSet.delete(skillId)
} else {
newSet.add(skillId)
}
setSelectedReviewSkills(newSet)
}
// Calculate problem counts based on a 20-problem worksheet
const totalProblems = 20
const reviewCount = Math.floor(totalProblems * mixRatio)
const currentCount = totalProblems - reviewCount
return (
<div
data-component="customize-mix-modal-overlay"
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
padding: '1rem',
})}
onClick={onClose}
>
<div
data-component="customize-mix-modal"
className={css({
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
maxWidth: '500px',
width: '100%',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
})}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className={css({
padding: '1.5rem',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 600,
color: isDark ? 'white' : 'gray.900',
marginBottom: '0.25rem',
})}
>
Customize Worksheet Mix
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{currentSkill.name}
</p>
</div>
<button
type="button"
data-action="close-modal"
onClick={onClose}
className={css({
padding: '0.5rem',
borderRadius: '6px',
border: 'none',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.600',
cursor: 'pointer',
fontSize: '1.5rem',
lineHeight: '1',
transition: 'all 0.2s',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
color: isDark ? 'gray.200' : 'gray.900',
},
})}
>
×
</button>
</div>
{/* Content */}
<div
className={css({
flex: 1,
overflowY: 'auto',
padding: '1.5rem',
})}
>
{/* Mix Ratio Section */}
<div className={css({ marginBottom: '2rem' })}>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: 600,
color: isDark ? 'gray.200' : 'gray.700',
marginBottom: '1rem',
})}
>
Mix Ratio
</h3>
{/* Visual breakdown */}
<div
className={css({
display: 'flex',
gap: '0.5rem',
marginBottom: '1rem',
})}
>
<div
className={css({
flex: 1,
padding: '0.75rem',
borderRadius: '6px',
backgroundColor: 'blue.50',
border: '1px solid',
borderColor: 'blue.200',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 600,
color: 'blue.700',
marginBottom: '0.25rem',
})}
>
Current Skill: {Math.round((1 - mixRatio) * 100)}%
</div>
<div
className={css({
fontSize: '0.875rem',
fontWeight: 600,
color: 'blue.800',
})}
>
{currentCount} problems
</div>
</div>
<div
className={css({
flex: 1,
padding: '0.75rem',
borderRadius: '6px',
backgroundColor: 'green.50',
border: '1px solid',
borderColor: 'green.200',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 600,
color: 'green.700',
marginBottom: '0.25rem',
})}
>
Review: {Math.round(mixRatio * 100)}%
</div>
<div
className={css({
fontSize: '0.875rem',
fontWeight: 600,
color: 'green.800',
})}
>
{reviewCount} problems
</div>
</div>
</div>
{/* Slider */}
<div className={css({ marginBottom: '0.5rem' })}>
<input
type="range"
min="0"
max="100"
step="5"
value={Math.round(mixRatio * 100)}
onChange={(e) => setMixRatio(Number.parseInt(e.target.value) / 100)}
className={css({
width: '100%',
cursor: 'pointer',
})}
/>
</div>
{/* Slider labels */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
<span>More current skill</span>
<span>More review</span>
</div>
</div>
{/* Review Skills Section */}
{masteredReviewSkills.length > 0 && (
<div>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: 600,
color: isDark ? 'gray.200' : 'gray.700',
marginBottom: '0.75rem',
})}
>
Review Skills ({selectedReviewSkills.size} selected)
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{masteredReviewSkills.map((skillId) => {
// Find skill definition to get name
const skill = currentSkill.recommendedReview
.map((id) => {
// This is a bit inefficient, but works for now
// In a real app, we'd pass skills as a prop
return { id, name: skillId } // Placeholder
})
.find((s) => s.id === skillId)
const isSelected = selectedReviewSkills.has(skillId)
return (
<label
key={skillId}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isSelected ? 'green.300' : isDark ? 'gray.600' : 'gray.200',
backgroundColor: isSelected ? 'green.50' : isDark ? 'gray.700' : 'white',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'green.400',
},
})}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleReviewSkill(skillId)}
className={css({
cursor: 'pointer',
})}
/>
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
{skillId}
</span>
</label>
)
})}
</div>
</div>
)}
{masteredReviewSkills.length === 0 && (
<div
className={css({
padding: '1rem',
borderRadius: '6px',
backgroundColor: isDark ? 'gray.700' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
textAlign: 'center',
})}
>
No mastered review skills available. Mark prerequisite skills as mastered to enable
review mixing.
</p>
</div>
)}
</div>
{/* Footer */}
<div
className={css({
padding: '1rem 1.5rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
justifyContent: 'space-between',
gap: '0.75rem',
})}
>
<button
type="button"
data-action="reset-to-default"
onClick={handleReset}
className={css({
padding: '0.75rem 1.5rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: 'transparent',
color: isDark ? 'gray.300' : 'gray.700',
fontSize: '0.875rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.50',
},
})}
>
Reset to Default
</button>
<div className={css({ display: 'flex', gap: '0.75rem' })}>
<button
type="button"
data-action="cancel"
onClick={onClose}
className={css({
padding: '0.75rem 1.5rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.200' : 'gray.700',
fontSize: '0.875rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.50',
},
})}
>
Cancel
</button>
<button
type="button"
data-action="apply"
onClick={handleApply}
className={css({
padding: '0.75rem 1.5rem',
borderRadius: '6px',
border: 'none',
backgroundColor: 'blue.500',
color: 'white',
fontSize: '0.875rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
backgroundColor: 'blue.600',
},
})}
>
Apply
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,320 @@
'use client'
import type React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { css } from '@styled/css'
import {
DIFFICULTY_PROFILES,
DIFFICULTY_PROGRESSION,
calculateRegroupingIntensity,
type DifficultyLevel,
} from '../../difficultyProfiles'
import type { DisplayRules } from '../../displayRules'
import { getScaffoldingSummary } from './utils'
import { useWorksheetConfig } from '../WorksheetConfigContext'
import { useTheme } from '@/contexts/ThemeContext'
export interface DifficultyPresetDropdownProps {
currentProfile: DifficultyLevel | null | undefined
isCustom: boolean
nearestEasier: DifficultyLevel | null | undefined
nearestHarder: DifficultyLevel | null | undefined
customDescription: React.ReactNode
hoverPreview: {
pAnyStart: number
pAllStart: number
displayRules: DisplayRules
matchedProfile: string | 'custom'
} | null
onChange: (updates: {
difficultyProfile: DifficultyLevel
pAnyStart: number
pAllStart: number
displayRules: DisplayRules
}) => void
}
export function DifficultyPresetDropdown({
currentProfile,
isCustom,
nearestEasier,
nearestHarder,
customDescription,
hoverPreview,
onChange,
}: DifficultyPresetDropdownProps) {
const { operator } = useWorksheetConfig()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
return (
<div className={css({ mb: '3' })}>
<div
className={css({
fontSize: 'xs',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
mb: '2',
})}
>
Difficulty Preset
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
data-action="open-preset-dropdown"
className={css({
w: 'full',
h: '24',
px: '3',
py: '2.5',
border: '2px solid',
borderColor: isCustom
? isDark
? 'orange.500'
: 'orange.400'
: isDark
? 'gray.600'
: 'gray.300',
bg: isCustom ? (isDark ? 'orange.900' : 'orange.50') : isDark ? 'gray.800' : 'white',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
textAlign: 'left',
gap: '2',
_hover: {
borderColor: isCustom ? 'orange.500' : 'brand.400',
},
})}
>
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '1',
})}
>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: hoverPreview
? isDark
? 'orange.300'
: 'orange.700'
: isDark
? 'gray.200'
: 'gray.700',
})}
>
{hoverPreview ? (
<>
{hoverPreview.matchedProfile !== 'custom' ? (
<>
{DIFFICULTY_PROFILES[hoverPreview.matchedProfile].label}{' '}
<span
className={css({
fontSize: 'xs',
color: 'orange.500',
})}
>
(hover preview)
</span>
</>
) : (
<>
Custom{' '}
<span
className={css({
fontSize: 'xs',
color: 'orange.500',
})}
>
(hover preview)
</span>
</>
)}
</>
) : isCustom ? (
nearestEasier && nearestHarder ? (
<>
{DIFFICULTY_PROFILES[nearestEasier].label}
{' ↔ '}
{DIFFICULTY_PROFILES[nearestHarder].label}
</>
) : (
'✨ Custom'
)
) : currentProfile ? (
DIFFICULTY_PROFILES[currentProfile].label
) : (
'Early Learner'
)}
</div>
<div
className={css({
fontSize: 'xs',
color: hoverPreview
? isDark
? 'orange.400'
: 'orange.600'
: isCustom
? isDark
? 'orange.400'
: 'orange.600'
: isDark
? 'gray.400'
: 'gray.500',
lineHeight: '1.3',
h: '14',
display: 'flex',
flexDirection: 'column',
gap: '0.5',
overflow: 'hidden',
})}
>
{hoverPreview ? (
(() => {
const regroupingPercent = Math.round(hoverPreview.pAnyStart * 100)
const scaffoldingSummary = getScaffoldingSummary(
hoverPreview.displayRules,
operator
)
return (
<>
<div>{regroupingPercent}% regrouping</div>
{scaffoldingSummary}
</>
)
})()
) : isCustom ? (
customDescription
) : currentProfile ? (
(() => {
const preset = DIFFICULTY_PROFILES[currentProfile]
const regroupingPercent = Math.round(preset.regrouping.pAnyStart * 100)
const scaffoldingSummary = getScaffoldingSummary(preset.displayRules, operator)
return (
<>
<div>{regroupingPercent}% regrouping</div>
{scaffoldingSummary}
</>
)
})()
) : (
<>
<div>25% regrouping</div>
<div>Always: carry boxes, answer boxes, place value colors, ten-frames</div>
</>
)}
</div>
</div>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.500' : 'gray.400',
flexShrink: 0,
})}
>
</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={css({
bg: isDark ? 'gray.800' : 'white',
rounded: 'lg',
shadow: 'modal',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
p: '2',
minW: '64',
maxH: '96',
overflowY: 'auto',
zIndex: 50,
})}
sideOffset={5}
>
{DIFFICULTY_PROGRESSION.map((presetName) => {
const preset = DIFFICULTY_PROFILES[presetName]
const isSelected = currentProfile === presetName && !isCustom
// Generate preset description
const regroupingPercent = Math.round(
calculateRegroupingIntensity(
preset.regrouping.pAnyStart,
preset.regrouping.pAllStart
) * 10
)
const scaffoldingSummary = getScaffoldingSummary(preset.displayRules, operator)
const presetDescription = (
<>
<div>{regroupingPercent}% regrouping</div>
{scaffoldingSummary}
</>
)
return (
<DropdownMenu.Item
key={presetName}
data-action={`select-preset-${presetName}`}
onSelect={() => {
// Apply preset configuration
onChange({
difficultyProfile: presetName,
pAnyStart: preset.regrouping.pAnyStart,
pAllStart: preset.regrouping.pAllStart,
displayRules: preset.displayRules,
})
}}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1',
px: '3',
py: '2.5',
rounded: 'md',
cursor: 'pointer',
outline: 'none',
bg: isSelected ? 'brand.50' : 'transparent',
_hover: {
bg: 'brand.50',
},
_focus: {
bg: 'brand.100',
},
})}
>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isSelected ? 'brand.700' : isDark ? 'gray.200' : 'gray.700',
})}
>
{preset.label}
</div>
<div
className={css({
fontSize: 'xs',
color: isSelected ? 'brand.600' : isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.3',
})}
>
{presetDescription}
</div>
</DropdownMenu.Item>
)
})}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
)
}

View File

@@ -0,0 +1,162 @@
import * as Slider from '@radix-ui/react-slider'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
export interface DigitRangeSectionProps {
digitRange: { min: number; max: number } | undefined
onChange: (digitRange: { min: number; max: number }) => void
}
export function DigitRangeSection({ digitRange, onChange }: DigitRangeSectionProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const min = digitRange?.min ?? 2
const max = digitRange?.max ?? 2
return (
<div
data-section="digit-range"
className={css({
bg: isDark ? 'gray.700' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
rounded: 'lg',
p: '3',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: '2',
})}
>
<label
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
Digits per Number
</label>
<span
className={css({
fontSize: 'xs',
fontWeight: 'bold',
color: 'brand.600',
})}
>
{min === max ? `${min}` : `${min}-${max}`}
</span>
</div>
{/* Compact slider with inline labels */}
<div className={css({ position: 'relative' })}>
<Slider.Root
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
width: 'full',
height: '5',
})}
value={[min, max]}
onValueChange={(values) => {
onChange({
min: values[0],
max: values[1],
})
}}
min={1}
max={5}
step={1}
minStepsBetweenThumbs={0}
>
<Slider.Track
className={css({
position: 'relative',
flexGrow: 1,
bg: isDark ? 'gray.600' : 'gray.200',
rounded: 'full',
height: '1.5',
})}
>
<Slider.Range
className={css({
position: 'absolute',
bg: 'brand.500',
rounded: 'full',
height: 'full',
})}
/>
</Slider.Track>
<Slider.Thumb
className={css({
display: 'block',
width: '3.5',
height: '3.5',
bg: 'white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
rounded: 'full',
border: '2px solid',
borderColor: 'brand.500',
cursor: 'grab',
_hover: { transform: 'scale(1.1)' },
_focus: {
outline: 'none',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.2)',
},
_active: { cursor: 'grabbing' },
})}
/>
<Slider.Thumb
className={css({
display: 'block',
width: '3.5',
height: '3.5',
bg: 'white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
rounded: 'full',
border: '2px solid',
borderColor: 'brand.600',
cursor: 'grab',
_hover: { transform: 'scale(1.1)' },
_focus: {
outline: 'none',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.2)',
},
_active: { cursor: 'grabbing' },
})}
/>
</Slider.Root>
{/* Tick marks below slider */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
mt: '1',
px: '0.5',
})}
>
{[1, 2, 3, 4, 5].map((digit) => (
<span
key={`tick-${digit}`}
className={css({
fontSize: '2xs',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
{digit}
</span>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,335 @@
'use client'
import type React from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { css } from '@styled/css'
import type { DifficultyMode } from '../../difficultyProfiles'
import { useTheme } from '@/contexts/ThemeContext'
export interface DifficultyChangeResult {
changeDescription: string
pAnyStart: number
pAllStart: number
displayRules: any
difficultyProfile?: string
}
export interface MakeEasierHarderButtonsProps {
easierResultBoth: DifficultyChangeResult
easierResultChallenge: DifficultyChangeResult
easierResultSupport: DifficultyChangeResult
harderResultBoth: DifficultyChangeResult
harderResultChallenge: DifficultyChangeResult
harderResultSupport: DifficultyChangeResult
canMakeEasierBoth: boolean
canMakeEasierChallenge: boolean
canMakeEasierSupport: boolean
canMakeHarderBoth: boolean
canMakeHarderChallenge: boolean
canMakeHarderSupport: boolean
onEasier: (mode: DifficultyMode) => void
onHarder: (mode: DifficultyMode) => void
}
export function MakeEasierHarderButtons({
easierResultBoth,
easierResultChallenge,
easierResultSupport,
harderResultBoth,
harderResultChallenge,
harderResultSupport,
canMakeEasierBoth,
canMakeEasierChallenge,
canMakeEasierSupport,
canMakeHarderBoth,
canMakeHarderChallenge,
canMakeHarderSupport,
onEasier,
onHarder,
}: MakeEasierHarderButtonsProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Determine which mode is alternative for easier
const easierAlternativeMode =
easierResultBoth.changeDescription === easierResultChallenge.changeDescription
? 'support'
: 'challenge'
const easierAlternativeResult =
easierAlternativeMode === 'support' ? easierResultSupport : easierResultChallenge
const easierAlternativeLabel =
easierAlternativeMode === 'support' ? '↑ More support' : '← Less challenge'
const canEasierAlternative =
easierAlternativeMode === 'support' ? canMakeEasierSupport : canMakeEasierChallenge
// Determine which mode is alternative for harder
const harderAlternativeMode =
harderResultBoth.changeDescription === harderResultChallenge.changeDescription
? 'support'
: 'challenge'
const harderAlternativeResult =
harderAlternativeMode === 'support' ? harderResultSupport : harderResultChallenge
const harderAlternativeLabel =
harderAlternativeMode === 'support' ? '↓ Less support' : '→ More challenge'
const canHarderAlternative =
harderAlternativeMode === 'support' ? canMakeHarderSupport : canMakeHarderChallenge
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2',
pt: '1',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Four-Button Layout: [Alt-35%][Rec-65%][Rec-65%][Alt-35%] */}
<Tooltip.Provider delayDuration={300}>
<div className={css({ display: 'flex', gap: '2' })}>
{/* EASIER SECTION */}
<div className={css({ display: 'flex', flex: '1' })}>
{/* Alternative Easier Button - Hidden if disabled and main is enabled */}
{canEasierAlternative && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => onEasier(easierAlternativeMode)}
disabled={!canEasierAlternative}
data-action={`easier-${easierAlternativeMode}`}
className={css({
flexShrink: 0,
width: '10',
h: '16',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2xs',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
bg: isDark ? 'gray.700' : 'gray.100',
border: '1.5px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRight: 'none',
borderTopLeftRadius: 'lg',
borderBottomLeftRadius: 'lg',
cursor: 'pointer',
_hover: {
bg: isDark ? 'gray.600' : 'gray.200',
},
})}
>
{easierAlternativeLabel}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className={css({
bg: 'gray.800',
color: 'white',
px: '3',
py: '2',
rounded: 'md',
fontSize: 'xs',
maxW: '250px',
shadow: 'lg',
zIndex: 1000,
})}
>
{easierAlternativeResult.changeDescription}
<Tooltip.Arrow className={css({ fill: 'gray.800' })} />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)}
{/* Recommended Easier Button - Expands to full width if alternative is hidden */}
<button
onClick={() => onEasier('both')}
disabled={!canMakeEasierBoth}
data-action="easier-both"
className={css({
flex: '1',
h: '16',
px: '3',
py: '2',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '0.5',
color: canMakeEasierBoth ? 'brand.700' : isDark ? 'gray.500' : 'gray.400',
bg: isDark ? 'gray.800' : 'white',
border: '1.5px solid',
borderColor: canMakeEasierBoth ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
borderTopLeftRadius: canEasierAlternative ? 'none' : 'lg',
borderBottomLeftRadius: canEasierAlternative ? 'none' : 'lg',
borderTopRightRadius: 'lg',
borderBottomRightRadius: 'lg',
cursor: canMakeEasierBoth ? 'pointer' : 'not-allowed',
opacity: canMakeEasierBoth ? 1 : 0.5,
_hover: canMakeEasierBoth
? {
bg: isDark ? 'gray.700' : 'brand.50',
}
: {},
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
flexShrink: 0,
})}
>
Make Easier
</div>
{canMakeEasierBoth && (
<div
className={css({
fontSize: '2xs',
fontWeight: 'normal',
lineHeight: '1.3',
textAlign: 'left',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
})}
style={
{
WebkitBoxOrient: 'vertical',
} as React.CSSProperties
}
>
{easierResultBoth.changeDescription}
</div>
)}
</button>
</div>
{/* HARDER SECTION */}
<div className={css({ display: 'flex', flex: '1' })}>
{/* Recommended Harder Button - Expands to full width if alternative is hidden */}
<button
onClick={() => onHarder('both')}
disabled={!canMakeHarderBoth}
data-action="harder-both"
className={css({
flex: '1',
h: '16',
px: '3',
py: '2',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '0.5',
color: canMakeHarderBoth ? 'brand.700' : isDark ? 'gray.500' : 'gray.400',
bg: isDark ? 'gray.800' : 'white',
border: '1.5px solid',
borderColor: canMakeHarderBoth ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
borderTopLeftRadius: 'lg',
borderBottomLeftRadius: 'lg',
borderTopRightRadius: canHarderAlternative ? 'none' : 'lg',
borderBottomRightRadius: canHarderAlternative ? 'none' : 'lg',
cursor: canMakeHarderBoth ? 'pointer' : 'not-allowed',
opacity: canMakeHarderBoth ? 1 : 0.5,
_hover: canMakeHarderBoth
? {
bg: isDark ? 'gray.700' : 'brand.50',
}
: {},
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
flexShrink: 0,
})}
>
Make Harder
</div>
{canMakeHarderBoth && (
<div
className={css({
fontSize: '2xs',
fontWeight: 'normal',
lineHeight: '1.3',
textAlign: 'left',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
})}
style={
{
WebkitBoxOrient: 'vertical',
} as React.CSSProperties
}
>
{harderResultBoth.changeDescription}
</div>
)}
</button>
{/* Alternative Harder Button - Hidden if disabled and main is enabled */}
{canHarderAlternative && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => onHarder(harderAlternativeMode)}
disabled={!canHarderAlternative}
data-action={`harder-${harderAlternativeMode}`}
className={css({
flexShrink: 0,
width: '10',
h: '16',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2xs',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
bg: isDark ? 'gray.700' : 'gray.100',
border: '1.5px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderLeft: 'none',
borderTopRightRadius: 'lg',
borderBottomRightRadius: 'lg',
cursor: 'pointer',
_hover: {
bg: isDark ? 'gray.600' : 'gray.200',
},
})}
>
{harderAlternativeLabel}
</button>
</Tooltip.Trigger>
{canHarderAlternative && (
<Tooltip.Portal>
<Tooltip.Content
side="top"
className={css({
bg: 'gray.800',
color: 'white',
px: '3',
py: '2',
rounded: 'md',
fontSize: 'xs',
maxW: '250px',
shadow: 'lg',
zIndex: 1000,
})}
>
{harderAlternativeResult.changeDescription}
<Tooltip.Arrow className={css({ fill: 'gray.800' })} />
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
)}
</div>
</div>
</Tooltip.Provider>
</div>
)
}

View File

@@ -0,0 +1,346 @@
'use client'
import * as Slider from '@radix-ui/react-slider'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import type { WorksheetFormState } from '../../types'
import type { DisplayRules } from '../../displayRules'
import { defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
import { RuleThermometer } from './RuleThermometer'
import { DigitRangeSection } from './DigitRangeSection'
import { DisplayOptionsPreview } from '../DisplayOptionsPreview'
export interface ManualModeControlsProps {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
isDark?: boolean
}
export function ManualModeControls({
formState,
onChange,
isDark = false,
}: ManualModeControlsProps) {
// Get current displayRules or use defaults
const displayRules: DisplayRules = formState.displayRules ?? defaultAdditionConfig.displayRules
// Helper to update a single display rule
const updateRule = (key: keyof DisplayRules, value: DisplayRules[keyof DisplayRules]) => {
onChange({
displayRules: {
...displayRules,
[key]: value,
},
})
}
return (
<div data-section="manual-mode" className={stack({ gap: '3' })}>
{/* Digit Range Selector */}
<DigitRangeSection
digitRange={formState.digitRange}
onChange={(digitRange) => onChange({ digitRange })}
/>
{/* Pedagogical Scaffolding Options */}
<div data-section="scaffolding" className={stack({ gap: '3' })}>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Pedagogical Scaffolding
</div>
<div className={css({ display: 'flex', gap: '1.5' })}>
<button
onClick={() =>
onChange({
displayRules: {
...displayRules,
carryBoxes: 'always',
answerBoxes: 'always',
placeValueColors: 'always',
tenFrames: 'always',
borrowNotation: 'always',
borrowingHints: 'always',
},
})
}
className={css({
px: '2',
py: '0.5',
fontSize: '2xs',
color: isDark ? 'brand.300' : 'brand.600',
border: '1px solid',
borderColor: isDark ? 'brand.500' : 'brand.300',
bg: isDark ? 'gray.700' : 'white',
rounded: 'md',
cursor: 'pointer',
_hover: { bg: isDark ? 'gray.600' : 'brand.50' },
})}
>
All Always
</button>
<button
onClick={() =>
onChange({
displayRules: {
...displayRules,
carryBoxes: 'never',
answerBoxes: 'never',
placeValueColors: 'never',
tenFrames: 'never',
borrowNotation: 'never',
borrowingHints: 'never',
},
})
}
className={css({
px: '2',
py: '0.5',
fontSize: '2xs',
color: isDark ? 'gray.300' : 'gray.600',
border: '1px solid',
borderColor: isDark ? 'gray.500' : 'gray.300',
bg: isDark ? 'gray.700' : 'white',
rounded: 'md',
cursor: 'pointer',
_hover: { bg: isDark ? 'gray.600' : 'gray.50' },
})}
>
Minimal
</button>
</div>
</div>
{/* Pedagogical scaffolding thermometers */}
<div className={stack({ gap: '3' })}>
<RuleThermometer
label="Answer Boxes"
description="Guide students to write organized, aligned answers"
value={displayRules.answerBoxes}
onChange={(value) => updateRule('answerBoxes', value)}
isDark={isDark}
/>
<RuleThermometer
label="Place Value Colors"
description="Reinforce place value understanding visually"
value={displayRules.placeValueColors}
onChange={(value) => updateRule('placeValueColors', value)}
isDark={isDark}
/>
<RuleThermometer
label={
formState.operator === 'subtraction'
? 'Borrow Boxes'
: formState.operator === 'mixed'
? 'Carry/Borrow Boxes'
: 'Carry Boxes'
}
description={
formState.operator === 'subtraction'
? 'Help students track borrowing during subtraction'
: formState.operator === 'mixed'
? 'Help students track regrouping (carrying in addition, borrowing in subtraction)'
: 'Help students track regrouping during addition'
}
value={displayRules.carryBoxes}
onChange={(value) => updateRule('carryBoxes', value)}
isDark={isDark}
/>
{(formState.operator === 'subtraction' || formState.operator === 'mixed') && (
<RuleThermometer
label="Borrowed 10s Box"
description="Box for adding 10 to borrowing digit"
value={displayRules.borrowNotation}
onChange={(value) => updateRule('borrowNotation', value)}
isDark={isDark}
/>
)}
{(formState.operator === 'subtraction' || formState.operator === 'mixed') && (
<RuleThermometer
label="Borrowing Hints"
description="Show arrows and calculations guiding the borrowing process"
value={displayRules.borrowingHints}
onChange={(value) => updateRule('borrowingHints', value)}
isDark={isDark}
/>
)}
<RuleThermometer
label="Ten-Frames"
description="Visualize regrouping with concrete counting tools"
value={displayRules.tenFrames}
onChange={(value) => updateRule('tenFrames', value)}
isDark={isDark}
/>
</div>
</div>
{/* Regrouping Frequency Card - Manual Mode */}
<div
data-section="regrouping"
className={css({
bg: isDark ? 'gray.800' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
rounded: 'xl',
p: '3',
mt: '3',
})}
>
<div className={stack({ gap: '2.5' })}>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Regrouping Frequency
</div>
{/* Current values display */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
<div>
Both:{' '}
<span
className={css({
color: 'brand.600',
fontWeight: 'semibold',
})}
>
{Math.round((formState.pAllStart || 0) * 100)}%
</span>
</div>
<div>
Any:{' '}
<span
className={css({
color: 'brand.600',
fontWeight: 'semibold',
})}
>
{Math.round((formState.pAnyStart || 0.25) * 100)}%
</span>
</div>
</div>
{/* Double-thumbed range slider */}
<Slider.Root
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
width: 'full',
height: '6',
})}
value={[(formState.pAllStart || 0) * 100, (formState.pAnyStart || 0.25) * 100]}
onValueChange={(values) => {
onChange({
pAllStart: values[0] / 100,
pAnyStart: values[1] / 100,
})
}}
min={0}
max={100}
step={5}
minStepsBetweenThumbs={0}
>
<Slider.Track
className={css({
position: 'relative',
flexGrow: 1,
bg: 'gray.200',
rounded: 'full',
height: '1.5',
})}
>
<Slider.Range
className={css({
position: 'absolute',
bg: 'brand.500',
rounded: 'full',
height: 'full',
})}
/>
</Slider.Track>
<Slider.Thumb
className={css({
display: 'block',
width: '3.5',
height: '3.5',
bg: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
rounded: 'full',
border: '2px solid',
borderColor: 'brand.500',
cursor: 'pointer',
_hover: { transform: 'scale(1.1)' },
_focus: {
outline: 'none',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.3)',
},
})}
/>
<Slider.Thumb
className={css({
display: 'block',
width: '3.5',
height: '3.5',
bg: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
rounded: 'full',
border: '2px solid',
borderColor: 'brand.600',
cursor: 'pointer',
_hover: { transform: 'scale(1.1)' },
_focus: {
outline: 'none',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.3)',
},
})}
/>
</Slider.Root>
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.3',
})}
>
Regrouping difficulty at worksheet start (Both = all columns regroup, Any = at least one
column regroups)
</div>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,257 @@
'use client'
import * as Slider from '@radix-ui/react-slider'
import { css } from '@styled/css'
import {
DIFFICULTY_PROFILES,
DIFFICULTY_PROGRESSION,
calculateOverallDifficulty,
calculateRegroupingIntensity,
calculateScaffoldingLevel,
REGROUPING_PROGRESSION,
SCAFFOLDING_PROGRESSION,
findNearestValidState,
getProfileFromConfig,
type DifficultyLevel,
} from '../../difficultyProfiles'
import type { DisplayRules } from '../../displayRules'
import { useTheme } from '@/contexts/ThemeContext'
export interface OverallDifficultySliderProps {
currentDifficulty: number
onChange: (updates: {
pAnyStart: number
pAllStart: number
displayRules: DisplayRules
difficultyProfile?: DifficultyLevel
}) => void
}
export function OverallDifficultySlider({
currentDifficulty,
onChange,
}: OverallDifficultySliderProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const handleValueChange = (value: number[]) => {
const targetDifficulty = value[0] / 10
// Calculate preset positions in 2D space
const presetPoints = DIFFICULTY_PROGRESSION.map((presetName) => {
const preset = DIFFICULTY_PROFILES[presetName]
const regrouping = calculateRegroupingIntensity(
preset.regrouping.pAnyStart,
preset.regrouping.pAllStart
)
const scaffolding = calculateScaffoldingLevel(preset.displayRules, regrouping)
const difficulty = calculateOverallDifficulty(
preset.regrouping.pAnyStart,
preset.regrouping.pAllStart,
preset.displayRules
)
return {
regrouping,
scaffolding,
difficulty,
name: presetName,
}
})
// Find which path segment we're on and interpolate
let idealRegrouping = 0
let idealScaffolding = 10
for (let i = 0; i < presetPoints.length - 1; i++) {
const start = presetPoints[i]
const end = presetPoints[i + 1]
if (targetDifficulty >= start.difficulty && targetDifficulty <= end.difficulty) {
// Interpolate between start and end
const t = (targetDifficulty - start.difficulty) / (end.difficulty - start.difficulty)
idealRegrouping = start.regrouping + t * (end.regrouping - start.regrouping)
idealScaffolding = start.scaffolding + t * (end.scaffolding - start.scaffolding)
console.log('[Slider] Interpolating between', start.name, 'and', end.name, {
t,
idealRegrouping,
idealScaffolding,
})
break
}
}
// Handle edge cases (before first or after last preset)
if (targetDifficulty < presetPoints[0].difficulty) {
idealRegrouping = presetPoints[0].regrouping
idealScaffolding = presetPoints[0].scaffolding
} else if (targetDifficulty > presetPoints[presetPoints.length - 1].difficulty) {
idealRegrouping = presetPoints[presetPoints.length - 1].regrouping
idealScaffolding = presetPoints[presetPoints.length - 1].scaffolding
}
// Find valid configuration closest to ideal point on path
let closestConfig: {
pAnyStart: number
pAllStart: number
displayRules: any
distance: number
} | null = null
for (let regIdx = 0; regIdx < REGROUPING_PROGRESSION.length; regIdx++) {
for (let scaffIdx = 0; scaffIdx < SCAFFOLDING_PROGRESSION.length; scaffIdx++) {
const validState = findNearestValidState(regIdx, scaffIdx)
if (validState.regroupingIdx !== regIdx || validState.scaffoldingIdx !== scaffIdx) {
continue
}
const regrouping = REGROUPING_PROGRESSION[regIdx]
const displayRules = SCAFFOLDING_PROGRESSION[scaffIdx]
const actualRegrouping = calculateRegroupingIntensity(
regrouping.pAnyStart,
regrouping.pAllStart
)
const actualScaffolding = calculateScaffoldingLevel(displayRules, actualRegrouping)
// Euclidean distance to ideal point on pedagogical path
const distance = Math.sqrt(
(actualRegrouping - idealRegrouping) ** 2 + (actualScaffolding - idealScaffolding) ** 2
)
if (closestConfig === null || distance < closestConfig.distance) {
closestConfig = {
pAnyStart: regrouping.pAnyStart,
pAllStart: regrouping.pAllStart,
displayRules,
distance,
}
}
}
}
if (closestConfig) {
console.log('[Slider] Closest config:', {
...closestConfig,
regrouping: calculateRegroupingIntensity(closestConfig.pAnyStart, closestConfig.pAllStart),
scaffolding: calculateScaffoldingLevel(
closestConfig.displayRules,
calculateRegroupingIntensity(closestConfig.pAnyStart, closestConfig.pAllStart)
),
})
const matchedProfile = getProfileFromConfig(
closestConfig.pAllStart,
closestConfig.pAnyStart,
closestConfig.displayRules
)
onChange({
pAnyStart: closestConfig.pAnyStart,
pAllStart: closestConfig.pAllStart,
displayRules: closestConfig.displayRules,
difficultyProfile:
matchedProfile !== 'custom' ? (matchedProfile as DifficultyLevel) : undefined,
})
}
}
return (
<div className={css({ mb: '2' })}>
<div
className={css({
fontSize: 'xs',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
mb: '1.5',
})}
>
Overall Difficulty: {currentDifficulty.toFixed(1)} / 10
</div>
{/* Difficulty Slider */}
<div className={css({ position: 'relative', px: '2' })}>
<Slider.Root
value={[currentDifficulty * 10]}
max={100}
step={1}
onValueChange={handleValueChange}
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
h: '8',
})}
>
<Slider.Track
className={css({
bg: isDark ? 'gray.700' : 'gray.100',
position: 'relative',
flexGrow: 1,
h: '2',
rounded: 'full',
})}
>
<Slider.Range
className={css({
position: 'absolute',
bg: 'brand.500',
h: 'full',
rounded: 'full',
})}
/>
{/* Preset markers on track */}
{DIFFICULTY_PROGRESSION.map((profileName) => {
const p = DIFFICULTY_PROFILES[profileName]
const presetDifficulty = calculateOverallDifficulty(
p.regrouping.pAnyStart,
p.regrouping.pAllStart,
p.displayRules
)
const position = (presetDifficulty / 10) * 100
return (
<div
key={profileName}
className={css({
position: 'absolute',
top: '50%',
left: `${position}%`,
transform: 'translate(-50%, -50%)',
w: '1.5',
h: '1.5',
bg: 'gray.400',
rounded: 'full',
pointerEvents: 'none',
zIndex: 1,
})}
title={p.label}
/>
)
})}
</Slider.Track>
<Slider.Thumb
className={css({
display: 'block',
w: '5',
h: '5',
bg: 'white',
border: '2px solid',
borderColor: 'brand.500',
rounded: 'full',
cursor: 'pointer',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
_hover: {
bg: 'brand.50',
},
_focus: {
outline: 'none',
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.1)',
},
})}
/>
</Slider.Root>
</div>
</div>
)
}

View File

@@ -0,0 +1,277 @@
# Prop Drilling Audit: SmartModeControls
## Executive Summary
**Current State:** SmartModeControls has significant prop drilling, primarily around:
1. `isDark` - Passed to ALL child components (now can use `useTheme()`)
2. `formState` - Passed to components that only need 1-2 fields
3. `onChange` - Passed to components that only update 1-2 fields
**Key Finding:** We can use `WorksheetConfigContext` much more effectively!
---
## Component Tree & Prop Flow
```
SmartModeControls
├── Props: formState, onChange, isDark
├─→ DigitRangeSection
│ ├── digitRange: formState.digitRange ← EXTRACTED (good)
│ ├── onChange: (digitRange) => onChange({...}) ← WRAPPED (good)
│ └── isDark ❌ USE useTheme()
├─→ DifficultyPresetDropdown
│ ├── currentProfile ← COMPUTED (ok, specific to this component)
│ ├── isCustom ← COMPUTED (ok, specific to this component)
│ ├── nearestEasier ← COMPUTED (ok, specific to this component)
│ ├── nearestHarder ← COMPUTED (ok, specific to this component)
│ ├── customDescription ← COMPUTED (ok, specific to this component)
│ ├── hoverPreview ← LOCAL STATE (ok)
│ └── onChange ✅ ALREADY USES CONTEXT
├─→ MakeEasierHarderButtons
│ ├── easierResultBoth ← COMPUTED (ok, specific to this component)
│ ├── easierResultChallenge ← COMPUTED (ok, specific to this component)
│ ├── easierResultSupport ← COMPUTED (ok, specific to this component)
│ ├── harderResultBoth ← COMPUTED (ok, specific to this component)
│ ├── harderResultChallenge ← COMPUTED (ok, specific to this component)
│ ├── harderResultSupport ← COMPUTED (ok, specific to this component)
│ ├── canMakeEasierBoth ← COMPUTED (ok, specific to this component)
│ ├── canMakeEasierChallenge ← COMPUTED (ok, specific to this component)
│ ├── canMakeEasierSupport ← COMPUTED (ok, specific to this component)
│ ├── canMakeHarderBoth ← COMPUTED (ok, specific to this component)
│ ├── canMakeHarderChallenge ← COMPUTED (ok, specific to this component)
│ ├── canMakeHarderSupport ← COMPUTED (ok, specific to this component)
│ ├── onEasier: (mode) => handleDifficultyChange ← WRAPPED (ok)
│ └── onHarder: (mode) => handleDifficultyChange ← WRAPPED (ok)
├─→ OverallDifficultySlider
│ ├── currentDifficulty ← COMPUTED (ok, specific to this component)
│ └── onChange ✅ ALREADY USES CONTEXT
└─→ RegroupingFrequencyPanel
├── formState ❌ ENTIRE OBJECT (only uses 2 fields!)
├── onChange ❌ ENTIRE FUNCTION (could use context)
└── isDark ❌ USE useTheme()
```
---
## Detailed Analysis
### ✅ Already Using Context Effectively
**DifficultyPresetDropdown, MakeEasierHarderButtons, OverallDifficultySlider:**
- Use `useWorksheetConfig()` for `operator` and `onChange`
- Use `useTheme()` for `isDark`
- Props are component-specific computed values (good!)
### ❌ Problem #1: DigitRangeSection
**Current Props:**
```typescript
interface DigitRangeSectionProps {
digitRange: { min: number; max: number } | undefined
onChange: (digitRange: { min: number; max: number }) => void
isDark?: boolean // ← Should use useTheme()
}
```
**Recommendation:** Remove `isDark` prop, use `useTheme()` inside component
**Impact:**
- ✅ Removes 1 prop
- ✅ Consistent with other refactored components
---
### ❌ Problem #2: RegroupingFrequencyPanel
**Current Props:**
```typescript
interface RegroupingFrequencyPanelProps {
formState: WorksheetFormState // ← ENTIRE OBJECT! Only uses 2 fields
onChange: (updates: Partial<WorksheetFormState>) => void
isDark?: boolean // ← Should use useTheme()
}
```
**What it actually uses from formState:**
- `formState.pAllStart` - for display
- `formState.pAnyStart` - for display
- (Updates via onChange when sliders change)
**Recommendation:** Refactor to use context
**Option A: Use context directly**
```typescript
export function RegroupingFrequencyPanel() {
const { formState, onChange } = useWorksheetConfig()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// All data comes from context, no props needed!
}
// Called from SmartModeControls:
<RegroupingFrequencyPanel />
```
**Option B: Extract only needed values (more explicit)**
```typescript
interface RegroupingFrequencyPanelProps {
pAllStart: number
pAnyStart: number
onChangeRegrouping: (updates: { pAllStart?: number; pAnyStart?: number }) => void
}
export function RegroupingFrequencyPanel({
pAllStart,
pAnyStart,
onChangeRegrouping,
}: RegroupingFrequencyPanelProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// ...
}
// Called from SmartModeControls:
<RegroupingFrequencyPanel
pAllStart={formState.pAllStart || 0}
pAnyStart={formState.pAnyStart || 0.25}
onChangeRegrouping={onChange}
/>
```
**Recommendation: Option A** - Since RegroupingFrequencyPanel is tightly coupled to worksheet config, using context makes sense.
**Impact:**
- ✅ Removes ALL 3 props from RegroupingFrequencyPanel
- ✅ Consistent with WorksheetConfigContext pattern
- ✅ Simpler call site in SmartModeControls
---
### ❌ Problem #3: SmartModeControls still receives isDark
**Current:**
```typescript
export interface SmartModeControlsProps {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
isDark?: boolean // ← Only used for inline styling in SmartModeControls itself
}
```
**Where isDark is used in SmartModeControls:**
- Line 87: Passed to `<DigitRangeSection isDark={isDark} />`
- Line 96: Inline style: `color: isDark ? 'gray.400' : 'gray.500'`
- Line 313: Inline style: `bg: isDark ? 'blue.950' : 'blue.50'`
- Line 315: Inline style: `borderColor: isDark ? 'blue.800' : 'blue.200'`
- Line 699: Passed to `<RegroupingFrequencyPanel isDark={isDark} />`
**Recommendation:** SmartModeControls should use `useTheme()` directly
```typescript
export function SmartModeControls({ formState, onChange }: SmartModeControlsProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// ... rest of component
}
```
**Impact:**
- ✅ Removes `isDark` from SmartModeControls props
- ✅ No longer passed from ConfigPanel
- ✅ Consistent with using global theme context
---
## Summary of Recommendations
### Immediate Wins (Low Effort, High Impact)
1. **DigitRangeSection**: Remove `isDark` prop, use `useTheme()`
2. **RegroupingFrequencyPanel**: Remove all props, use `useWorksheetConfig()` and `useTheme()` ✅✅✅
3. **SmartModeControls**: Remove `isDark` prop, use `useTheme()` internally ✅
### Before vs After
**Before:**
```typescript
// ConfigPanel
<SmartModeControls formState={formState} onChange={onChange} isDark={isDark} />
// SmartModeControls
<DigitRangeSection digitRange={formState.digitRange} onChange={...} isDark={isDark} />
<RegroupingFrequencyPanel formState={formState} onChange={onChange} isDark={isDark} />
```
**After:**
```typescript
// ConfigPanel
<SmartModeControls formState={formState} onChange={onChange} />
// SmartModeControls (uses useTheme() internally)
<DigitRangeSection digitRange={formState.digitRange} onChange={...} />
<RegroupingFrequencyPanel />
```
### Impact Summary
- **Total props removed:** 5 props across 3 components
- **Components simplified:** 3 (DigitRangeSection, RegroupingFrequencyPanel, SmartModeControls)
- **Consistency:** All components now use `useTheme()` for theme access
- **Maintainability:** Theme changes propagate automatically via context
---
## Considerations
### Why NOT use context for computed values?
Components like **DifficultyPresetDropdown** and **MakeEasierHarderButtons** receive many computed props. Should these use context too?
**Answer: NO - Current approach is correct!**
**Reasons:**
1. **Separation of concerns**: Computation logic stays in parent (SmartModeControls)
2. **Component reusability**: These components could be used with different computation logic
3. **Testability**: Easy to test by passing mock props
4. **Clarity**: Props make dependencies explicit
**Rule of thumb:**
-**Use context for:** Shared state (formState, onChange), theme (isDark), operator
-**Don't use context for:** Component-specific computed values, callbacks with logic
### Why RegroupingFrequencyPanel CAN use context?
RegroupingFrequencyPanel is tightly coupled to worksheet configuration and:
- Only exists within worksheet config UI
- Directly reads/writes worksheet state
- Has no reusability requirements
- Simplifies the component hierarchy
This is the exact use case for context!
---
## Next Steps
1. ✅ Refactor DigitRangeSection to use `useTheme()`
2. ✅ Refactor RegroupingFrequencyPanel to use both contexts
3. ✅ Refactor SmartModeControls to use `useTheme()` internally
4. ✅ Remove `isDark` from ConfigPanel → SmartModeControls call
5. ✅ Test all changes
6. ✅ Run pre-commit checks
---
## Files to Modify
1. `/src/app/create/worksheets/addition/components/config-panel/DigitRangeSection.tsx`
2. `/src/app/create/worksheets/addition/components/config-panel/RegroupingFrequencyPanel.tsx`
3. `/src/app/create/worksheets/addition/components/config-panel/SmartModeControls.tsx`
4. `/src/app/create/worksheets/addition/components/ConfigPanel.tsx`

View File

@@ -0,0 +1,648 @@
'use client'
import { useState, useEffect } from 'react'
import { css } from '@styled/css'
import type { WorksheetFormState } from '../../types'
import {
SINGLE_CARRY_PATH,
getStepFromSliderValue,
getSliderValueFromStep,
findNearestStep,
getStepById,
} from '../../progressionPath'
interface ProgressionModePanelProps {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
isDark?: boolean
}
interface MasteryState {
stepId: string
isMastered: boolean
attempts: number
correctCount: number
}
/**
* Progression Mode Panel
*
* Slider-based UI that follows a curated learning path through 3D space:
* - Digit count (1-5 digits)
* - Regrouping difficulty (0-100%)
* - Scaffolding level (full → minimal)
*
* Key feature: Scaffolding cycles as complexity increases (ten-frames return!)
*/
export function ProgressionModePanel({
formState,
onChange,
isDark = false,
}: ProgressionModePanelProps) {
// Get current step from formState or default to first step
const currentStepId = formState.currentStepId ?? SINGLE_CARRY_PATH[0].id
const currentStep = getStepById(currentStepId, SINGLE_CARRY_PATH) ?? SINGLE_CARRY_PATH[0]
// Derive slider value from current step
const sliderValue = getSliderValueFromStep(currentStep.stepNumber, SINGLE_CARRY_PATH.length)
// Track whether advanced controls are expanded
const [showAdvanced, setShowAdvanced] = useState(false)
// Track mastery states for all steps
const [masteryStates, setMasteryStates] = useState<Map<string, MasteryState>>(new Map())
const [isLoadingMastery, setIsLoadingMastery] = useState(true)
// Load mastery data from API
useEffect(() => {
async function loadMasteryStates() {
try {
setIsLoadingMastery(true)
const response = await fetch('/api/worksheets/mastery?operator=addition')
if (!response.ok) {
throw new Error('Failed to load mastery states')
}
const data = await response.json()
// Convert to Map<stepId, MasteryState>
// The API returns data with skill IDs, we'll use them as step IDs for now
const statesMap = new Map<string, MasteryState>()
for (const record of data.masteryStates) {
statesMap.set(record.skillId, {
stepId: record.skillId,
isMastered: record.isMastered,
attempts: record.attempts ?? 0,
correctCount: record.correctCount ?? 0,
})
}
setMasteryStates(statesMap)
} catch (error) {
console.error('Failed to load mastery states:', error)
} finally {
setIsLoadingMastery(false)
}
}
loadMasteryStates()
}, [])
// Apply current step's configuration to form state when step changes
useEffect(() => {
console.log('[ProgressionModePanel] Applying step config:', {
stepId: currentStep.id,
stepName: currentStep.name,
stepNumber: currentStep.stepNumber,
config: currentStep.config,
})
onChange({
currentStepId: currentStep.id,
...currentStep.config,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep.id]) // Only run when step ID changes
// Handler: Slider value changes
const handleSliderChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Number(event.target.value)
const newStep = getStepFromSliderValue(newValue, SINGLE_CARRY_PATH)
console.log('[ProgressionModePanel] Slider changed:', {
sliderValue: newValue,
newStepNumber: newStep.stepNumber,
newStepId: newStep.id,
})
// Apply new step's config
onChange({
currentStepId: newStep.id,
...newStep.config,
})
}
// Handler: Manual digit count change
const handleDigitChange = (digits: number) => {
const updatedConfig = {
...formState,
digitRange: { min: digits, max: digits },
}
// Find nearest step matching new config
const nearestStep = findNearestStep(updatedConfig, SINGLE_CARRY_PATH)
console.log('[ProgressionModePanel] Manual digit change:', {
digits,
nearestStepId: nearestStep.id,
nearestStepNumber: nearestStep.stepNumber,
})
// Apply nearest step's full config (not just digit range)
onChange({
currentStepId: nearestStep.id,
...nearestStep.config,
})
}
// Handler: Manual scaffolding change
const handleScaffoldingChange = (tenFrames: 'whenRegrouping' | 'never') => {
// Build complete displayRules with the new tenFrames value
const displayRules = currentStep.config.displayRules
? { ...currentStep.config.displayRules, tenFrames }
: undefined
const updatedConfig = {
...formState,
displayRules,
}
// Find nearest step matching new config
const nearestStep = findNearestStep(updatedConfig, SINGLE_CARRY_PATH)
console.log('[ProgressionModePanel] Manual scaffolding change:', {
tenFrames,
nearestStepId: nearestStep.id,
nearestStepNumber: nearestStep.stepNumber,
})
// Apply nearest step's full config
onChange({
currentStepId: nearestStep.id,
...nearestStep.config,
})
}
// Determine scaffolding level description
const hasFullScaffolding = currentStep.config.displayRules?.tenFrames === 'whenRegrouping'
const scaffoldingDesc = hasFullScaffolding
? 'Full scaffolding (ten-frames shown)'
: 'Independent practice (no ten-frames)'
// Get next step info
const nextStep = currentStep.nextStepId
? getStepById(currentStep.nextStepId, SINGLE_CARRY_PATH)
: null
return (
<div
data-component="progression-mode-panel"
className={css({
padding: '1.5rem',
backgroundColor: isDark ? 'gray.700' : 'gray.50',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
{/* Header */}
<div className={css({ marginBottom: '1.5rem' })}>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: 600,
color: isDark ? 'gray.200' : 'gray.700',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Difficulty Progression
</h3>
</div>
{/* Slider */}
<div
data-element="slider-container"
className={css({
marginBottom: '1.5rem',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '0.5rem',
})}
>
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Easier
</span>
<input
type="range"
min={0}
max={100}
value={sliderValue}
onChange={handleSliderChange}
data-element="difficulty-slider"
className={css({
flex: 1,
cursor: 'pointer',
})}
/>
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Harder
</span>
</div>
</div>
{/* Current Status */}
<div
data-element="current-status"
className={css({
marginBottom: '1.5rem',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: 600,
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.75rem',
})}
>
Currently practicing:
</h4>
<ul
className={css({
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
<li
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'gray.800',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span className={css({ color: isDark ? 'blue.400' : 'blue.600' })}></span>
{currentStep.config.digitRange?.min}-digit problems
</li>
<li
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'gray.800',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span className={css({ color: isDark ? 'blue.400' : 'blue.600' })}></span>
{currentStep.name}
</li>
<li
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'gray.800',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span className={css({ color: isDark ? 'blue.400' : 'blue.600' })}></span>
{scaffoldingDesc}
</li>
</ul>
</div>
{/* Progress Dots */}
<div
data-element="progress-dots"
className={css({
marginBottom: '1.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
})}
>
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Progress:
</span>
<div
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
})}
>
{SINGLE_CARRY_PATH.map((step) => {
const isMastered = masteryStates.get(step.id)?.isMastered ?? false
const isCurrent = step.id === currentStep.id
return (
<span
key={step.id}
data-step={step.stepNumber}
data-current={isCurrent}
data-mastered={isMastered}
className={css({
width: '0.75rem',
height: '0.75rem',
borderRadius: '50%',
backgroundColor: isMastered
? isDark
? 'green.400'
: 'green.600' // Mastered = green
: step.stepNumber <= currentStep.stepNumber
? isDark
? 'blue.400'
: 'blue.600' // Current/past = blue
: isDark
? 'gray.600'
: 'gray.300', // Future = gray
border: isCurrent ? '2px solid' : 'none',
borderColor: isCurrent ? (isDark ? 'yellow.400' : 'yellow.600') : undefined,
transition: 'all 0.2s',
cursor: 'pointer',
})}
title={`${step.name}${isMastered ? ' ✓ Mastered' : ''}`}
/>
)
})}
</div>
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Step {currentStep.stepNumber + 1} of {SINGLE_CARRY_PATH.length}
</span>
</div>
{/* Next Milestone */}
{nextStep && (
<div
data-element="next-milestone"
className={css({
marginBottom: '1.5rem',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'blue.50',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'blue.200',
})}
>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: 600,
color: isDark ? 'blue.300' : 'blue.700',
marginBottom: '0.5rem',
})}
>
Next milestone:
</h4>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
margin: 0,
})}
>
{nextStep.description}
</p>
</div>
)}
{/* Advanced Controls Toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
data-action="toggle-advanced-controls"
className={css({
width: '100%',
padding: '0.75rem',
fontSize: '0.875rem',
fontWeight: 500,
color: isDark ? 'blue.400' : 'blue.600',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
transition: 'all 0.2s',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.50',
},
})}
>
{showAdvanced ? 'Hide' : 'Show'} Advanced Controls
<span className={css({ fontSize: '0.75rem' })}>{showAdvanced ? '▲' : '▼'}</span>
</button>
{/* Advanced Controls (Collapsible) */}
{showAdvanced && (
<div
data-element="advanced-controls"
className={css({
marginTop: '1.5rem',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
{/* Digit Count */}
<div className={css({ marginBottom: '1.5rem' })}>
<label
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 600,
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.75rem',
})}
>
Digit Count:
</label>
<div
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{[1, 2, 3, 4, 5].map((d) => (
<button
key={d}
type="button"
onClick={() => handleDigitChange(d)}
data-setting="digit-count"
data-value={d}
data-selected={formState.digitRange?.min === d}
className={css({
flex: 1,
padding: '0.5rem',
fontSize: '0.875rem',
fontWeight: 500,
color:
formState.digitRange?.min === d ? 'white' : isDark ? 'gray.300' : 'gray.700',
backgroundColor:
formState.digitRange?.min === d
? isDark
? 'blue.600'
: 'blue.500'
: isDark
? 'gray.700'
: 'gray.100',
border: '1px solid',
borderColor:
formState.digitRange?.min === d
? isDark
? 'blue.500'
: 'blue.400'
: isDark
? 'gray.600'
: 'gray.300',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
backgroundColor:
formState.digitRange?.min === d
? isDark
? 'blue.500'
: 'blue.400'
: isDark
? 'gray.600'
: 'gray.200',
},
})}
>
{d}
</button>
))}
</div>
</div>
{/* Scaffolding Level */}
<div className={css({ marginBottom: '1rem' })}>
<label
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 600,
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.75rem',
})}
>
Scaffolding Level:
</label>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
})}
>
<input
type="radio"
name="scaffolding"
checked={formState.displayRules?.tenFrames === 'whenRegrouping'}
onChange={() => handleScaffoldingChange('whenRegrouping')}
data-setting="scaffolding"
data-value="full"
className={css({ cursor: 'pointer' })}
/>
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'gray.800',
})}
>
Full (ten-frames, carry boxes, colors)
</span>
</label>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
})}
>
<input
type="radio"
name="scaffolding"
checked={formState.displayRules?.tenFrames === 'never'}
onChange={() => handleScaffoldingChange('never')}
data-setting="scaffolding"
data-value="minimal"
className={css({ cursor: 'pointer' })}
/>
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'gray.800',
})}
>
Minimal (no ten-frames)
</span>
</label>
</div>
</div>
{/* Warning */}
<div
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'yellow.900' : 'yellow.50',
border: '1px solid',
borderColor: isDark ? 'yellow.700' : 'yellow.300',
borderRadius: '4px',
})}
>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'yellow.200' : 'yellow.800',
margin: 0,
})}
>
Manual changes will move you to the nearest step on the progression path
</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,89 @@
import * as Switch from '@radix-ui/react-switch'
import { css } from '@styled/css'
export interface ProgressiveDifficultyToggleProps {
interpolate: boolean | undefined
onChange: (interpolate: boolean) => void
isDark?: boolean
}
export function ProgressiveDifficultyToggle({
interpolate,
onChange,
isDark = false,
}: ProgressiveDifficultyToggleProps) {
return (
<div
data-section="progressive-difficulty"
className={css({
bg: isDark ? 'gray.700' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
rounded: 'xl',
p: '3',
})}
>
<div
className={css({
display: 'flex',
gap: '3',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<label
htmlFor="progressive-toggle"
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.200' : 'gray.700',
cursor: 'pointer',
})}
>
Progressive difficulty
</label>
<Switch.Root
id="progressive-toggle"
checked={interpolate ?? true}
onCheckedChange={(checked) => onChange(checked)}
className={css({
width: '11',
height: '6',
bg: isDark ? 'gray.600' : 'gray.300',
rounded: 'full',
position: 'relative',
cursor: 'pointer',
'&[data-state="checked"]': {
bg: 'brand.500',
},
})}
>
<Switch.Thumb
className={css({
display: 'block',
width: '5',
height: '5',
bg: 'white',
rounded: 'full',
transition: 'transform 0.1s',
transform: 'translateX(1px)',
willChange: 'transform',
'&[data-state="checked"]': {
transform: 'translateX(23px)',
},
})}
/>
</Switch.Root>
</div>
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
mt: '1',
})}
>
Start easier and gradually build up throughout the worksheet
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
'use client'
import * as Slider from '@radix-ui/react-slider'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import { useWorksheetConfig } from '../WorksheetConfigContext'
import { useTheme } from '@/contexts/ThemeContext'
export function RegroupingFrequencyPanel() {
const { formState, onChange } = useWorksheetConfig()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
return (
<div
data-section="regrouping"
className={css({
bg: isDark ? 'gray.800' : 'gray.50',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
rounded: 'xl',
p: '3',
})}
>
<div className={stack({ gap: '2.5' })}>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Regrouping Frequency
</div>
{/* Current values display */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
<div>
Both:{' '}
<span
className={css({
color: 'brand.600',
fontWeight: 'semibold',
})}
>
{Math.round((formState.pAllStart || 0) * 100)}%
</span>
</div>
<div>
Any:{' '}
<span
className={css({
color: 'brand.600',
fontWeight: 'semibold',
})}
>
{Math.round((formState.pAnyStart || 0.25) * 100)}%
</span>
</div>
</div>
{/* Double-thumbed range slider */}
<Slider.Root
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
width: 'full',
height: '6',
})}
value={[(formState.pAllStart || 0) * 100, (formState.pAnyStart || 0.25) * 100]}
onValueChange={(values) => {
onChange({
pAllStart: values[0] / 100,
pAnyStart: values[1] / 100,
})
}}
min={0}
max={100}
step={5}
minStepsBetweenThumbs={0}
>
<Slider.Track
className={css({
position: 'relative',
flexGrow: 1,
bg: isDark ? 'gray.600' : 'gray.200',
rounded: 'full',
height: '1.5',
})}
>
<Slider.Range
className={css({
position: 'absolute',
bg: 'brand.500',
rounded: 'full',
height: 'full',
})}
/>
</Slider.Track>
<Slider.Thumb
className={css({
display: 'block',
width: '3.5',
height: '3.5',
bg: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
rounded: 'full',
border: '2px solid',
borderColor: 'brand.500',
cursor: 'pointer',
_hover: { transform: 'scale(1.1)' },
_focus: {
outline: 'none',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.3)',
},
})}
/>
<Slider.Thumb
className={css({
display: 'block',
width: '3.5',
height: '3.5',
bg: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
rounded: 'full',
border: '2px solid',
borderColor: 'brand.600',
cursor: 'pointer',
_hover: { transform: 'scale(1.1)' },
_focus: {
outline: 'none',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.3)',
},
})}
/>
</Slider.Root>
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.3',
})}
>
Regrouping difficulty at worksheet start (Both = all columns regroup, Any = at least one
column regroups)
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import * as Select from '@radix-ui/react-select'
import { css } from '@styled/css'
import type { RuleMode } from '../../displayRules'
export interface RuleDropdownProps {
label: string
description: string
value: RuleMode
onChange: (value: RuleMode) => void
isDark?: boolean
}
const RULE_OPTIONS: Array<{
value: RuleMode
label: string
description: string
}> = [
{ value: 'always', label: 'Always', description: 'Show for all problems' },
{ value: 'never', label: 'Never', description: 'Hide for all problems' },
{
value: 'whenRegrouping',
label: 'When Regrouping',
description: 'Show only when problem requires regrouping',
},
{
value: 'whenMultipleRegroups',
label: 'Multiple Regroups',
description: 'Show when 2+ place values regroup',
},
{
value: 'when3PlusDigits',
label: '3+ Digits',
description: 'Show when problem has 3+ digits',
},
]
export function RuleDropdown({
label,
description,
value,
onChange,
isDark = false,
}: RuleDropdownProps) {
const selectedOption = RULE_OPTIONS.find((opt) => opt.value === value)
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1.5',
})}
>
<div>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
mb: '0.5',
})}
>
{label}
</div>
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.3',
})}
>
{description}
</div>
</div>
<Select.Root value={value} onValueChange={onChange}>
<Select.Trigger
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
w: 'full',
px: '2.5',
py: '1.5',
bg: isDark ? 'gray.700' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
rounded: 'md',
fontSize: 'xs',
color: isDark ? 'gray.200' : 'gray.700',
cursor: 'pointer',
_hover: {
borderColor: isDark ? 'gray.500' : 'brand.400',
},
_focus: {
outline: 'none',
borderColor: 'brand.500',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)',
},
})}
>
<Select.Value>{selectedOption?.label || 'Select...'}</Select.Value>
<Select.Icon
className={css({
ml: '2',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
className={css({
bg: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
rounded: 'md',
shadow: 'lg',
overflow: 'hidden',
zIndex: 1000,
})}
position="popper"
sideOffset={5}
>
<Select.Viewport>
{RULE_OPTIONS.map((option) => (
<Select.Item
key={option.value}
value={option.value}
className={css({
px: '3',
py: '2',
fontSize: 'xs',
color: isDark ? 'gray.200' : 'gray.700',
cursor: 'pointer',
outline: 'none',
_hover: {
bg: isDark ? 'gray.700' : 'brand.50',
},
'&[data-state="checked"]': {
bg: isDark ? 'gray.700' : 'brand.50',
color: isDark ? 'brand.300' : 'brand.700',
fontWeight: 'semibold',
},
})}
>
<div>
<Select.ItemText>{option.label}</Select.ItemText>
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.400' : 'gray.500',
mt: '0.5',
})}
>
{option.description}
</div>
</div>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { css } from '@styled/css'
import type { RuleMode } from '../../displayRules'
export interface RuleThermometerProps {
label: string
description: string
value: RuleMode
onChange: (value: RuleMode) => void
isDark?: boolean
}
const RULE_OPTIONS: Array<{ value: RuleMode; label: string; short: string }> = [
{ value: 'always', label: 'Always', short: 'Always' },
{ value: 'whenRegrouping', label: 'When Regrouping', short: 'Regroup' },
{ value: 'whenMultipleRegroups', label: 'Multiple Regroups', short: '2+' },
{ value: 'when3PlusDigits', label: '3+ Digits', short: '3+ dig' },
{ value: 'never', label: 'Never', short: 'Never' },
]
export function RuleThermometer({
label,
description,
value,
onChange,
isDark = false,
}: RuleThermometerProps) {
const selectedIndex = RULE_OPTIONS.findIndex((opt) => opt.value === value)
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1.5',
})}
>
{/* Label */}
<div>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
mb: '0.5',
})}
>
{label}
</div>
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.3',
})}
>
{description}
</div>
</div>
{/* Horizontal thermometer */}
<div
className={css({
display: 'flex',
gap: '0',
bg: isDark ? 'gray.700' : 'gray.100',
rounded: 'md',
p: '1',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
{RULE_OPTIONS.map((option, index) => {
const isSelected = value === option.value
const isLeftmost = index === 0
const isRightmost = index === RULE_OPTIONS.length - 1
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
title={option.label}
className={css({
flex: 1,
px: '2',
py: '1.5',
fontSize: '2xs',
fontWeight: isSelected ? 'bold' : 'medium',
color: isSelected ? (isDark ? 'white' : 'white') : isDark ? 'gray.400' : 'gray.600',
bg: isSelected ? 'brand.500' : 'transparent',
border: 'none',
borderTopLeftRadius: isLeftmost ? 'md' : '0',
borderBottomLeftRadius: isLeftmost ? 'md' : '0',
borderTopRightRadius: isRightmost ? 'md' : '0',
borderBottomRightRadius: isRightmost ? 'md' : '0',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
bg: isSelected ? 'brand.600' : isDark ? 'gray.600' : 'gray.200',
color: isSelected ? 'white' : isDark ? 'gray.200' : 'gray.800',
},
})}
>
{option.short}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
# SmartModeControls.tsx Refactoring Plan
## Current State
- **File size**: 1505 lines
- **Complexity**: Multiple large, self-contained UI sections embedded in one component
- **Issues**: Hard to maintain, test, and reuse
## Proposed Refactoring
### 1. Extract DifficultyPresetDropdown Component (~270 lines)
**Lines**: 275-546
**Purpose**: Dropdown for selecting difficulty presets
**Props**:
```typescript
interface DifficultyPresetDropdownProps {
currentProfile: DifficultyLevel | null
isCustom: boolean
nearestEasier: DifficultyLevel | null
nearestHarder: DifficultyLevel | null
customDescription: React.ReactNode
hoverPreview: { pAnyStart: number; pAllStart: number; displayRules: DisplayRules; matchedProfile: string | 'custom' } | null
operator: 'addition' | 'subtraction' | 'mixed'
onChange: (updates: { difficultyProfile: DifficultyLevel; pAnyStart: number; pAllStart: number; displayRules: DisplayRules }) => void
isDark?: boolean
}
```
### 2. Extract MakeEasierHarderButtons Component (~240 lines)
**Lines**: 548-850
**Purpose**: Four-button layout for adjusting difficulty
**Props**:
```typescript
interface MakeEasierHarderButtonsProps {
canMakeEasier: { recommended: boolean; alternative: boolean }
canMakeHarder: { recommended: boolean; alternative: boolean }
onEasier: (mode: DifficultyMode) => void
onHarder: (mode: DifficultyMode) => void
isDark?: boolean
}
```
### 3. Extract OverallDifficultySlider Component (~200 lines)
**Lines**: 859-1110
**Purpose**: Slider with preset markers for difficulty adjustment
**Props**:
```typescript
interface OverallDifficultySliderProps {
overallDifficulty: number
currentProfile: DifficultyLevel | null
isCustom: boolean
onChange: (difficulty: number) => void
isDark?: boolean
}
```
### 4. Extract DifficultySpaceMap Component (~390 lines)
**Lines**: 1113-1505
**Purpose**: 2D visualization of difficulty space with interactive hover
**Props**:
```typescript
interface DifficultySpaceMapProps {
currentState: { pAnyStart: number; pAllStart: number; displayRules: DisplayRules }
hoverPoint: { x: number; y: number } | null
setHoverPoint: (point: { x: number; y: number } | null) => void
setHoverPreview: (preview: { pAnyStart: number; pAllStart: number; displayRules: DisplayRules; matchedProfile: string | 'custom' } | null) => void
operator: 'addition' | 'subtraction' | 'mixed'
isDark?: boolean
}
```
### 5. Create Shared Style Utilities
**File**: `src/app/create/worksheets/addition/components/config-panel/buttonStyles.ts`
**Purpose**: Reusable button style generators to reduce duplication
```typescript
export function getDifficultyButtonStyles(
isEnabled: boolean,
isDark: boolean,
variant: 'primary' | 'secondary'
): CSSProperties {
// Common button styling logic
}
```
## Benefits
1. **Maintainability**: Each component focuses on single responsibility
2. **Testability**: Smaller components are easier to test in isolation
3. **Reusability**: Components can be reused in other contexts (e.g., MasteryModePanel)
4. **Readability**: Main SmartModeControls becomes a clean composition
5. **Performance**: React can optimize smaller component trees better
6. **Dark mode**: Easier to audit and maintain consistent theming
## Refactored SmartModeControls Structure
```tsx
export function SmartModeControls({ formState, onChange, isDark }: SmartModeControlsProps) {
// State and logic
const [showDebugPlot, setShowDebugPlot] = useState(false)
const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number } | null>(null)
const [hoverPreview, setHoverPreview] = useState<...>(null)
// Computed values
const currentProfile = getProfileFromConfig(...)
const isCustom = currentProfile === null
return (
<div data-section="smart-mode">
<DigitRangeSection {...} />
<div data-section="difficulty">
<DifficultyPresetDropdown {...} />
<MakeEasierHarderButtons {...} />
<OverallDifficultySlider {...} />
<DifficultySpaceMap {...} />
</div>
<RegroupingFrequencyPanel {...} />
</div>
)
}
```
## Risks & Mitigation
- **Risk**: Breaking existing functionality
- **Mitigation**: Extract one component at a time, test thoroughly
- **Risk**: Props become too complex
- **Mitigation**: Create intermediate types, use composition patterns
- **Risk**: Performance regression from more components
- **Mitigation**: Use React.memo where appropriate
## Implementation Steps
1. ✅ Create this plan document
2. Extract DifficultyPresetDropdown
3. Extract MakeEasierHarderButtons
4. Extract OverallDifficultySlider
5. Extract DifficultySpaceMap
6. Create shared button utilities
7. Test all components
8. Commit and push
## Questions for User
1. Should we proceed with this refactoring plan?
2. Any components you'd prefer to keep inline?
3. Any additional concerns or requirements?

View File

@@ -0,0 +1,703 @@
'use client'
import { useState } from 'react'
import type React from 'react'
import * as Slider from '@radix-ui/react-slider'
import * as Tooltip from '@radix-ui/react-tooltip'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import type { WorksheetFormState } from '../../types'
import { useTheme } from '@/contexts/ThemeContext'
import {
DIFFICULTY_PROFILES,
DIFFICULTY_PROGRESSION,
makeHarder,
makeEasier,
calculateOverallDifficulty,
calculateRegroupingIntensity,
calculateScaffoldingLevel,
REGROUPING_PROGRESSION,
SCAFFOLDING_PROGRESSION,
findNearestValidState,
getProfileFromConfig,
type DifficultyLevel,
type DifficultyMode,
} from '../../difficultyProfiles'
import type { DisplayRules } from '../../displayRules'
import { getScaffoldingSummary } from './utils'
import { RegroupingFrequencyPanel } from './RegroupingFrequencyPanel'
import { DigitRangeSection } from './DigitRangeSection'
import { DifficultyPresetDropdown } from './DifficultyPresetDropdown'
import { MakeEasierHarderButtons } from './MakeEasierHarderButtons'
import { OverallDifficultySlider } from './OverallDifficultySlider'
export interface SmartModeControlsProps {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
}
export function SmartModeControls({ formState, onChange }: SmartModeControlsProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [showDebugPlot, setShowDebugPlot] = useState(false)
const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number } | null>(null)
const [hoverPreview, setHoverPreview] = useState<{
pAnyStart: number
pAllStart: number
displayRules: DisplayRules
matchedProfile: string | 'custom'
} | null>(null)
// Helper function to handle difficulty adjustments
const handleDifficultyChange = (mode: DifficultyMode, direction: 'harder' | 'easier') => {
const currentState = {
pAnyStart: formState.pAnyStart ?? 0.25,
pAllStart: formState.pAllStart ?? 0,
displayRules: {
...(formState.displayRules ?? DIFFICULTY_PROFILES.earlyLearner.displayRules),
// Ensure new fields have defaults if missing (for backward compatibility)
borrowNotation:
(formState.displayRules as any)?.borrowNotation ??
DIFFICULTY_PROFILES.earlyLearner.displayRules.borrowNotation,
borrowingHints:
(formState.displayRules as any)?.borrowingHints ??
DIFFICULTY_PROFILES.earlyLearner.displayRules.borrowingHints,
},
}
const result =
direction === 'harder'
? makeHarder(currentState, mode, formState.operator)
: makeEasier(currentState, mode, formState.operator)
onChange({
pAnyStart: result.pAnyStart,
pAllStart: result.pAllStart,
displayRules: result.displayRules,
difficultyProfile:
result.difficultyProfile !== 'custom' ? result.difficultyProfile : undefined,
})
}
return (
<div data-section="smart-mode" className={stack({ gap: '3' })}>
{/* Digit Range */}
<DigitRangeSection
digitRange={formState.digitRange}
onChange={(digitRange) => onChange({ digitRange })}
/>
{/* Difficulty Level */}
<div data-section="difficulty" className={stack({ gap: '2.5' })}>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Difficulty Level
</div>
{/* Get current profile and state */}
{(() => {
const currentProfile = formState.difficultyProfile as DifficultyLevel | undefined
const profile = currentProfile
? DIFFICULTY_PROFILES[currentProfile]
: DIFFICULTY_PROFILES.earlyLearner
// Use defaults from profile if form state values are undefined
const pAnyStart = formState.pAnyStart ?? profile.regrouping.pAnyStart
const pAllStart = formState.pAllStart ?? profile.regrouping.pAllStart
const displayRules: DisplayRules = {
...(formState.displayRules ?? profile.displayRules),
// Ensure new fields have defaults (backward compatibility with old configs)
borrowNotation:
(formState.displayRules as any)?.borrowNotation ??
profile.displayRules.borrowNotation,
borrowingHints:
(formState.displayRules as any)?.borrowingHints ??
profile.displayRules.borrowingHints,
}
// Check if current state matches the selected profile
const matchesProfile =
pAnyStart === profile.regrouping.pAnyStart &&
pAllStart === profile.regrouping.pAllStart &&
JSON.stringify(displayRules) === JSON.stringify(profile.displayRules)
const isCustom = !matchesProfile
// Find nearest presets for custom configurations
let nearestEasier: DifficultyLevel | null = null
let nearestHarder: DifficultyLevel | null = null
let customDescription: React.ReactNode = ''
if (isCustom) {
const currentRegrouping = calculateRegroupingIntensity(pAnyStart, pAllStart)
const currentScaffolding = calculateScaffoldingLevel(displayRules, currentRegrouping)
// Calculate distances to all presets
const distances = DIFFICULTY_PROGRESSION.map((presetName) => {
const preset = DIFFICULTY_PROFILES[presetName]
const presetRegrouping = calculateRegroupingIntensity(
preset.regrouping.pAnyStart,
preset.regrouping.pAllStart
)
const presetScaffolding = calculateScaffoldingLevel(
preset.displayRules,
presetRegrouping
)
const distance = Math.sqrt(
(currentRegrouping - presetRegrouping) ** 2 +
(currentScaffolding - presetScaffolding) ** 2
)
return {
presetName,
distance,
difficulty: calculateOverallDifficulty(
preset.regrouping.pAnyStart,
preset.regrouping.pAllStart,
preset.displayRules
),
}
}).sort((a, b) => a.distance - b.distance)
const currentDifficultyValue = calculateOverallDifficulty(
pAnyStart,
pAllStart,
displayRules
)
// Find closest easier and harder presets
const easierPresets = distances.filter((d) => d.difficulty < currentDifficultyValue)
const harderPresets = distances.filter((d) => d.difficulty > currentDifficultyValue)
nearestEasier =
easierPresets.length > 0 ? easierPresets[0].presetName : distances[0].presetName
nearestHarder =
harderPresets.length > 0
? harderPresets[0].presetName
: distances[distances.length - 1].presetName
// Generate custom description
const regroupingPercent = Math.round(pAnyStart * 100)
const scaffoldingSummary = getScaffoldingSummary(displayRules, formState.operator)
customDescription = (
<>
<div>{regroupingPercent}% regrouping</div>
{scaffoldingSummary}
</>
)
}
// Calculate current difficulty position
const currentDifficulty = calculateOverallDifficulty(pAnyStart, pAllStart, displayRules)
// Calculate make easier/harder results for preview (all modes)
const easierResultBoth = makeEasier(
{
pAnyStart,
pAllStart,
displayRules,
},
'both',
formState.operator
)
const easierResultChallenge = makeEasier(
{
pAnyStart,
pAllStart,
displayRules,
},
'challenge',
formState.operator
)
const easierResultSupport = makeEasier(
{
pAnyStart,
pAllStart,
displayRules,
},
'support',
formState.operator
)
const harderResultBoth = makeHarder(
{
pAnyStart,
pAllStart,
displayRules,
},
'both',
formState.operator
)
const harderResultChallenge = makeHarder(
{
pAnyStart,
pAllStart,
displayRules,
},
'challenge',
formState.operator
)
const harderResultSupport = makeHarder(
{
pAnyStart,
pAllStart,
displayRules,
},
'support',
formState.operator
)
const canMakeEasierBoth =
easierResultBoth.changeDescription !== 'Already at minimum difficulty'
const canMakeEasierChallenge =
easierResultChallenge.changeDescription !== 'Already at minimum difficulty'
const canMakeEasierSupport =
easierResultSupport.changeDescription !== 'Already at minimum difficulty'
const canMakeHarderBoth =
harderResultBoth.changeDescription !== 'Already at maximum difficulty'
const canMakeHarderChallenge =
harderResultChallenge.changeDescription !== 'Already at maximum difficulty'
const canMakeHarderSupport =
harderResultSupport.changeDescription !== 'Already at maximum difficulty'
// Keep legacy names for compatibility
const canMakeEasier = canMakeEasierBoth
const canMakeHarder = canMakeHarderBoth
return (
<>
{/* Preset Selector Dropdown */}
<DifficultyPresetDropdown
currentProfile={currentProfile}
isCustom={isCustom}
nearestEasier={nearestEasier}
nearestHarder={nearestHarder}
customDescription={customDescription}
hoverPreview={hoverPreview}
onChange={onChange}
/>
{/* Make Easier/Harder buttons with preview */}
<MakeEasierHarderButtons
easierResultBoth={easierResultBoth}
easierResultChallenge={easierResultChallenge}
easierResultSupport={easierResultSupport}
harderResultBoth={harderResultBoth}
harderResultChallenge={harderResultChallenge}
harderResultSupport={harderResultSupport}
canMakeEasierBoth={canMakeEasierBoth}
canMakeEasierChallenge={canMakeEasierChallenge}
canMakeEasierSupport={canMakeEasierSupport}
canMakeHarderBoth={canMakeHarderBoth}
canMakeHarderChallenge={canMakeHarderChallenge}
canMakeHarderSupport={canMakeHarderSupport}
onEasier={(mode) => handleDifficultyChange(mode, 'easier')}
onHarder={(mode) => handleDifficultyChange(mode, 'harder')}
/>
{/* Overall Difficulty Slider */}
<OverallDifficultySlider currentDifficulty={currentDifficulty} onChange={onChange} />
{/* 2D Difficulty Space Visualizer */}
<div
className={css({
bg: isDark ? 'blue.950' : 'blue.50',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.200',
rounded: 'xl',
overflow: 'hidden',
boxShadow: 'sm',
})}
>
<button
type="button"
onClick={() => setShowDebugPlot(!showDebugPlot)}
className={css({
w: 'full',
p: '4',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
bg: 'transparent',
border: 'none',
_hover: {
bg: isDark ? 'blue.900' : 'blue.100',
},
transition: 'background 0.2s',
})}
>
<div
className={css({
fontWeight: 'semibold',
color: isDark ? 'blue.200' : 'blue.900',
fontSize: 'sm',
})}
>
Difficulty Space Map
</div>
<div
className={css({
fontSize: 'sm',
color: isDark ? 'blue.300' : 'blue.700',
transform: showDebugPlot ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
})}
>
</div>
</button>
{showDebugPlot && (
<div className={css({ px: '4', pb: '4', pt: '2' })}>
{/* Responsive SVG container */}
<div
className={css({
w: 'full',
display: 'flex',
justifyContent: 'center',
bg: isDark ? 'gray.900' : 'white',
rounded: 'lg',
p: '4',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{(() => {
// Make responsive - use container width with max size
const maxSize = 500
const width = maxSize
const height = maxSize
const padding = 40
const graphWidth = width - padding * 2
const graphHeight = height - padding * 2
const currentReg = calculateRegroupingIntensity(pAnyStart, pAllStart)
const currentScaf = calculateScaffoldingLevel(displayRules, currentReg)
// Convert 0-10 scale to SVG coordinates
const toX = (val: number) => padding + (val / 10) * graphWidth
const toY = (val: number) => height - padding - (val / 10) * graphHeight
// Convert SVG coordinates to 0-10 scale
const fromX = (x: number) =>
Math.max(0, Math.min(10, ((x - padding) / graphWidth) * 10))
const fromY = (y: number) =>
Math.max(0, Math.min(10, ((height - padding - y) / graphHeight) * 10))
// Helper to calculate valid target from mouse position
const calculateValidTarget = (
clientX: number,
clientY: number,
svg: SVGSVGElement
) => {
const rect = svg.getBoundingClientRect()
const x = clientX - rect.left
const y = clientY - rect.top
// Convert to difficulty space (0-10)
const regroupingIntensity = fromX(x)
const scaffoldingLevel = fromY(y)
// Check if we're near a preset (within snap threshold)
const snapThreshold = 1.0 // 1.0 units in 0-10 scale
let nearestPreset: {
distance: number
profile: (typeof DIFFICULTY_PROFILES)[keyof typeof DIFFICULTY_PROFILES]
} | null = null
for (const profileName of DIFFICULTY_PROGRESSION) {
const p = DIFFICULTY_PROFILES[profileName]
const presetReg = calculateRegroupingIntensity(
p.regrouping.pAnyStart,
p.regrouping.pAllStart
)
const presetScaf = calculateScaffoldingLevel(p.displayRules, presetReg)
// Calculate Euclidean distance
const distance = Math.sqrt(
(regroupingIntensity - presetReg) ** 2 +
(scaffoldingLevel - presetScaf) ** 2
)
if (distance <= snapThreshold) {
if (!nearestPreset || distance < nearestPreset.distance) {
nearestPreset = { distance, profile: p }
}
}
}
// If we found a nearby preset, snap to it
if (nearestPreset) {
return {
newRegrouping: nearestPreset.profile.regrouping,
newDisplayRules: nearestPreset.profile.displayRules,
matchedProfile: nearestPreset.profile.name,
reg: calculateRegroupingIntensity(
nearestPreset.profile.regrouping.pAnyStart,
nearestPreset.profile.regrouping.pAllStart
),
scaf: calculateScaffoldingLevel(
nearestPreset.profile.displayRules,
calculateRegroupingIntensity(
nearestPreset.profile.regrouping.pAnyStart,
nearestPreset.profile.regrouping.pAllStart
)
),
}
}
// No preset nearby, use normal progression indices
const regroupingIdx = Math.round(
(regroupingIntensity / 10) * (REGROUPING_PROGRESSION.length - 1)
)
const scaffoldingIdx = Math.round(
((10 - scaffoldingLevel) / 10) * (SCAFFOLDING_PROGRESSION.length - 1)
)
// Find nearest valid state (applies pedagogical constraints)
const validState = findNearestValidState(regroupingIdx, scaffoldingIdx)
// Get actual values from progressions
const newRegrouping = REGROUPING_PROGRESSION[validState.regroupingIdx]
const newDisplayRules = SCAFFOLDING_PROGRESSION[validState.scaffoldingIdx]
// Calculate display coordinates
const reg = calculateRegroupingIntensity(
newRegrouping.pAnyStart,
newRegrouping.pAllStart
)
const scaf = calculateScaffoldingLevel(newDisplayRules, reg)
// Check if this matches a preset
const matchedProfile = getProfileFromConfig(
newRegrouping.pAllStart,
newRegrouping.pAnyStart,
newDisplayRules
)
return {
newRegrouping,
newDisplayRules,
matchedProfile,
reg,
scaf,
}
}
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
const svg = e.currentTarget
const target = calculateValidTarget(e.clientX, e.clientY, svg)
setHoverPoint({ x: target.reg, y: target.scaf })
setHoverPreview({
pAnyStart: target.newRegrouping.pAnyStart,
pAllStart: target.newRegrouping.pAllStart,
displayRules: target.newDisplayRules,
matchedProfile: target.matchedProfile,
})
}
const handleMouseLeave = () => {
setHoverPoint(null)
setHoverPreview(null)
}
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
const svg = e.currentTarget
const target = calculateValidTarget(e.clientX, e.clientY, svg)
// Update via onChange
onChange({
pAnyStart: target.newRegrouping.pAnyStart,
pAllStart: target.newRegrouping.pAllStart,
displayRules: target.newDisplayRules,
difficultyProfile:
target.matchedProfile !== 'custom'
? target.matchedProfile
: undefined,
})
}
return (
<svg
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className={css({
maxWidth: `${maxSize}px`,
cursor: 'crosshair',
userSelect: 'none',
})}
>
{/* Grid lines */}
{[0, 2, 4, 6, 8, 10].map((val) => (
<g key={`grid-${val}`}>
<line
x1={toX(val)}
y1={padding}
x2={toX(val)}
y2={height - padding}
stroke="#e5e7eb"
strokeWidth="1"
strokeDasharray="3,3"
/>
<line
x1={padding}
y1={toY(val)}
x2={width - padding}
y2={toY(val)}
stroke="#e5e7eb"
strokeWidth="1"
strokeDasharray="3,3"
/>
</g>
))}
{/* Axes */}
<line
x1={padding}
y1={height - padding}
x2={width - padding}
y2={height - padding}
stroke="#374151"
strokeWidth="2"
/>
<line
x1={padding}
y1={padding}
x2={padding}
y2={height - padding}
stroke="#374151"
strokeWidth="2"
/>
{/* Axis labels */}
<text
x={width / 2}
y={height - 10}
textAnchor="middle"
fontSize="13"
fontWeight="500"
fill="#4b5563"
>
Regrouping Intensity
</text>
<text
x={15}
y={height / 2}
textAnchor="middle"
fontSize="13"
fontWeight="500"
fill="#4b5563"
transform={`rotate(-90, 15, ${height / 2})`}
>
Scaffolding (more help)
</text>
{/* Preset points */}
{DIFFICULTY_PROGRESSION.map((profileName) => {
const p = DIFFICULTY_PROFILES[profileName]
const reg = calculateRegroupingIntensity(
p.regrouping.pAnyStart,
p.regrouping.pAllStart
)
const scaf = calculateScaffoldingLevel(p.displayRules, reg)
return (
<g key={profileName}>
<circle
cx={toX(reg)}
cy={toY(scaf)}
r="5"
fill="#6366f1"
stroke="#4f46e5"
strokeWidth="2"
opacity="0.7"
/>
<text
x={toX(reg)}
y={toY(scaf) - 10}
textAnchor="middle"
fontSize="11"
fill="#4338ca"
fontWeight="600"
>
{p.label}
</text>
</g>
)
})}
{/* Hover preview - show where click will land */}
{hoverPoint && (
<>
{/* Dashed line from hover to target */}
<line
x1={toX(hoverPoint.x)}
y1={toY(hoverPoint.y)}
x2={toX(currentReg)}
y2={toY(currentScaf)}
stroke="#f59e0b"
strokeWidth="2"
strokeDasharray="5,5"
opacity="0.5"
/>
{/* Hover target marker */}
<circle
cx={toX(hoverPoint.x)}
cy={toY(hoverPoint.y)}
r="10"
fill="#f59e0b"
stroke="#d97706"
strokeWidth="3"
opacity="0.8"
/>
<circle
cx={toX(hoverPoint.x)}
cy={toY(hoverPoint.y)}
r="4"
fill="white"
/>
</>
)}
{/* Current position */}
<circle
cx={toX(currentReg)}
cy={toY(currentScaf)}
r="8"
fill="#10b981"
stroke="#059669"
strokeWidth="3"
/>
<circle cx={toX(currentReg)} cy={toY(currentScaf)} r="3" fill="white" />
</svg>
)
})()}
</div>
</div>
)}
</div>
</>
)
})()}
</div>
{/* Regrouping Frequency */}
<RegroupingFrequencyPanel />
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { css } from '@styled/css'
export interface StudentNameInputProps {
value: string | undefined
onChange: (value: string) => void
isDark?: boolean
}
export function StudentNameInput({ value, onChange, isDark = false }: StudentNameInputProps) {
return (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="Student Name"
className={css({
w: 'full',
px: '3',
py: '2',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
bg: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.900',
rounded: 'lg',
fontSize: 'sm',
_focus: {
outline: 'none',
borderColor: 'brand.500',
ring: '2px',
ringColor: 'brand.200',
},
_placeholder: { color: isDark ? 'gray.500' : 'gray.400' },
})}
/>
)
}

View File

@@ -0,0 +1,77 @@
import { css } from '@styled/css'
export interface SubOptionProps {
checked: boolean
onChange: (checked: boolean) => void
label: string
parentEnabled: boolean
}
/**
* Reusable sub-option component for nested toggles
* Used for options like "Show for all problems" under "Ten-Frames"
*/
export function SubOption({ checked, onChange, label, parentEnabled }: SubOptionProps) {
return (
<div
className={css({
display: 'flex',
gap: '3',
alignItems: 'center',
justifyContent: 'space-between',
pt: '1.5',
pb: '2.5',
px: '3',
mt: '2',
borderTop: '1px solid',
borderColor: 'brand.300',
opacity: parentEnabled ? 1 : 0,
visibility: parentEnabled ? 'visible' : 'hidden',
pointerEvents: parentEnabled ? 'auto' : 'none',
transition: 'opacity 0.15s',
cursor: 'pointer',
})}
onClick={(e) => {
e.stopPropagation()
onChange(!checked)
}}
>
<label
className={css({
fontSize: '2xs',
fontWeight: 'medium',
color: 'brand.700',
cursor: 'pointer',
flex: 1,
})}
>
{label}
</label>
<div
className={css({
w: '7',
h: '4',
bg: checked ? 'brand.500' : 'gray.300',
rounded: 'full',
position: 'relative',
transition: 'background-color 0.15s',
flexShrink: 0,
})}
>
<div
style={{
position: 'absolute',
top: '0.125rem',
left: checked ? '0.875rem' : '0.125rem',
width: '0.75rem',
height: '0.75rem',
background: 'white',
borderRadius: '9999px',
transition: 'left 0.15s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import type React from 'react'
import * as Checkbox from '@radix-ui/react-checkbox'
import { css } from '@styled/css'
export interface ToggleOptionProps {
checked: boolean
onChange: (checked: boolean) => void
label: string
description: string
children?: React.ReactNode
isDark?: boolean
}
export function ToggleOption({
checked,
onChange,
label,
description,
children,
isDark = false,
}: ToggleOptionProps) {
return (
<div
data-element="toggle-option-container"
className={css({
display: 'flex',
flexDirection: 'column',
h: children ? 'auto' : '20',
bg: checked ? 'brand.50' : isDark ? 'gray.700' : 'white',
border: '2px solid',
borderColor: checked ? 'brand.500' : isDark ? 'gray.600' : 'gray.200',
rounded: 'lg',
transition: 'all 0.15s',
_hover: {
borderColor: checked ? 'brand.600' : isDark ? 'gray.500' : 'gray.300',
bg: checked ? 'brand.100' : isDark ? 'gray.600' : 'gray.50',
},
})}
>
<Checkbox.Root
checked={checked}
onCheckedChange={onChange}
data-element="toggle-option"
className={css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
gap: '1.5',
p: '2.5',
bg: 'transparent',
border: 'none',
rounded: 'lg',
cursor: 'pointer',
textAlign: 'left',
w: 'full',
_focus: {
outline: 'none',
ring: '2px',
ringColor: 'brand.300',
},
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '2',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: checked ? 'brand.700' : isDark ? 'gray.200' : 'gray.700',
})}
>
{label}
</div>
<div
className={css({
w: '9',
h: '5',
bg: checked ? 'brand.500' : 'gray.300',
rounded: 'full',
position: 'relative',
transition: 'background-color 0.15s',
flexShrink: 0,
})}
>
<div
style={{
position: 'absolute',
top: '0.125rem',
left: checked ? '1.125rem' : '0.125rem',
width: '1rem',
height: '1rem',
background: 'white',
borderRadius: '9999px',
transition: 'left 0.15s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}}
/>
</div>
</div>
<div
className={css({
fontSize: '2xs',
color: checked ? 'brand.600' : isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.3',
})}
>
{description}
</div>
</Checkbox.Root>
{children}
</div>
)
}

View File

@@ -0,0 +1,78 @@
import type React from 'react'
import { css } from '@styled/css'
/**
* Generate a human-readable summary of enabled scaffolding aids
* Returns JSX with each frequency group on its own line
* @param displayRules - Display rules to summarize
* @param operator - Current worksheet operator (filters out irrelevant scaffolds)
*/
export function getScaffoldingSummary(
displayRules: any,
operator?: 'addition' | 'subtraction' | 'mixed'
): React.ReactNode {
console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator)
const alwaysItems: string[] = []
const conditionalItems: string[] = []
// Addition-specific scaffolds (skip for subtraction-only)
if (operator !== 'subtraction') {
if (displayRules.carryBoxes === 'always') {
alwaysItems.push('carry boxes')
} else if (displayRules.carryBoxes !== 'never') {
conditionalItems.push('carry boxes')
}
if (displayRules.tenFrames === 'always') {
alwaysItems.push('ten-frames')
} else if (displayRules.tenFrames !== 'never') {
conditionalItems.push('ten-frames')
}
}
// Universal scaffolds (always show)
if (displayRules.answerBoxes === 'always') {
alwaysItems.push('answer boxes')
} else if (displayRules.answerBoxes !== 'never') {
conditionalItems.push('answer boxes')
}
if (displayRules.placeValueColors === 'always') {
alwaysItems.push('place value colors')
} else if (displayRules.placeValueColors !== 'never') {
conditionalItems.push('place value colors')
}
// Subtraction-specific scaffolds (skip for addition-only)
if (operator !== 'addition') {
if (displayRules.borrowNotation === 'always') {
alwaysItems.push('borrow notation')
} else if (displayRules.borrowNotation !== 'never') {
conditionalItems.push('borrow notation')
}
if (displayRules.borrowingHints === 'always') {
alwaysItems.push('borrowing hints')
} else if (displayRules.borrowingHints !== 'never') {
conditionalItems.push('borrowing hints')
}
}
if (alwaysItems.length === 0 && conditionalItems.length === 0) {
console.log('[getScaffoldingSummary] Final summary: no scaffolding')
return <span className={css({ color: 'gray.500', fontStyle: 'italic' })}>no scaffolding</span>
}
console.log('[getScaffoldingSummary] Final summary:', {
alwaysItems,
conditionalItems,
})
return (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.5' })}>
{alwaysItems.length > 0 && <div>Always: {alwaysItems.join(', ')}</div>}
{conditionalItems.length > 0 && <div>When needed: {conditionalItems.join(', ')}</div>}
</div>
)
}

View File

@@ -0,0 +1,35 @@
'use client'
import { useTheme } from '@/contexts/ThemeContext'
import { OperatorSection } from '../config-panel/OperatorSection'
import { useWorksheetConfig } from '../WorksheetConfigContext'
export function ContentTab() {
const { formState, onChange } = useWorksheetConfig()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
return (
<div data-component="operator-tab">
{/* Operator Selector - First class, no container */}
<OperatorSection
operator={formState.operator}
onChange={(operator) => {
// If switching to 'mixed' while in mastery mode without both skill IDs,
// automatically switch to smart mode to prevent errors
const mode = formState.mode ?? 'smart'
if (
operator === 'mixed' &&
mode === 'mastery' &&
(!formState.currentAdditionSkillId || !formState.currentSubtractionSkillId)
) {
onChange({ operator, mode: 'smart' })
} else {
onChange({ operator })
}
}}
isDark={isDark}
/>
</div>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { stack } from '@styled/patterns'
import { defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
import { useTheme } from '@/contexts/ThemeContext'
import type { WorksheetFormState } from '../../types'
import { MasteryModePanel } from '../config-panel/MasteryModePanel'
import { ProgressiveDifficultyToggle } from '../config-panel/ProgressiveDifficultyToggle'
import { SmartModeControls } from '../config-panel/SmartModeControls'
import { DifficultyMethodSelector } from '../DifficultyMethodSelector'
import { useWorksheetConfig } from '../WorksheetConfigContext'
export function DifficultyTab() {
const { formState, onChange } = useWorksheetConfig()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'smart'
// Handler for difficulty method switching (smart vs mastery)
const handleMethodChange = (newMethod: 'smart' | 'mastery') => {
if (currentMethod === newMethod) {
return
}
const displayRules = formState.displayRules ?? defaultAdditionConfig.displayRules
if (newMethod === 'smart') {
onChange({
mode: 'smart',
displayRules,
difficultyProfile: 'earlyLearner',
} as unknown as Partial<WorksheetFormState>)
} else {
onChange({
mode: 'mastery',
displayRules,
} as unknown as Partial<WorksheetFormState>)
}
}
return (
<div data-component="difficulty-tab" className={stack({ gap: '3' })}>
{/* Progressive Difficulty Toggle - applies to all modes */}
<ProgressiveDifficultyToggle
interpolate={formState.interpolate}
onChange={(interpolate) => onChange({ interpolate })}
isDark={isDark}
/>
{/* Difficulty Method Selector */}
<DifficultyMethodSelector
currentMethod={currentMethod}
onChange={handleMethodChange}
isDark={isDark}
/>
{/* Method-specific controls */}
{currentMethod === 'smart' && <SmartModeControls formState={formState} onChange={onChange} />}
{currentMethod === 'mastery' && (
<MasteryModePanel formState={formState} onChange={onChange} isDark={isDark} />
)}
</div>
)
}

View File

@@ -0,0 +1,100 @@
'use client'
import { defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
import { useTheme } from '@/contexts/ThemeContext'
import type { DisplayRules } from '../../displayRules'
import { calculateDerivedState } from '../../utils/layoutCalculations'
import { OrientationPanel } from '../OrientationPanel'
import { useWorksheetConfig } from '../WorksheetConfigContext'
export function LayoutTab() {
const { formState, onChange } = useWorksheetConfig()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Orientation change handler with automatic problemsPerPage/cols updates
const handleOrientationChange = (
orientation: 'portrait' | 'landscape',
problemsPerPage: number,
cols: number
) => {
const pages = formState.pages || 1
const { rows, total } = calculateDerivedState(problemsPerPage, pages, cols)
onChange({
orientation,
problemsPerPage,
cols,
pages,
rows,
total,
})
}
// Problems per page change handler with automatic cols update
const handleProblemsPerPageChange = (problemsPerPage: number, cols: number) => {
const pages = formState.pages || 1
const { rows, total } = calculateDerivedState(problemsPerPage, pages, cols)
onChange({
problemsPerPage,
cols,
pages,
rows,
total,
})
}
// Pages change handler with derived state calculation
const handlePagesChange = (pages: number) => {
const problemsPerPage = formState.problemsPerPage || 15
const cols = formState.cols || 3
const { rows, total } = calculateDerivedState(problemsPerPage, pages, cols)
onChange({
pages,
rows,
total,
})
}
return (
<OrientationPanel
orientation={formState.orientation || 'portrait'}
problemsPerPage={formState.problemsPerPage || 15}
pages={formState.pages || 1}
cols={formState.cols || 3}
onOrientationChange={handleOrientationChange}
onProblemsPerPageChange={handleProblemsPerPageChange}
onPagesChange={handlePagesChange}
isDark={isDark}
problemNumbers={
((formState.displayRules ?? defaultAdditionConfig.displayRules).problemNumbers as
| 'always'
| 'never') || 'always'
}
cellBorders={
((formState.displayRules ?? defaultAdditionConfig.displayRules).cellBorders as
| 'always'
| 'never') || 'always'
}
onProblemNumbersChange={(value) => {
const displayRules: DisplayRules =
formState.displayRules ?? defaultAdditionConfig.displayRules
onChange({
displayRules: {
...displayRules,
problemNumbers: value,
},
})
}}
onCellBordersChange={(value) => {
const displayRules: DisplayRules =
formState.displayRules ?? defaultAdditionConfig.displayRules
onChange({
displayRules: {
...displayRules,
cellBorders: value,
},
})
}}
/>
)
}

View File

@@ -0,0 +1,176 @@
'use client'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import { useWorksheetConfig } from '../WorksheetConfigContext'
import { useTheme } from '@/contexts/ThemeContext'
import { RuleThermometer } from '../config-panel/RuleThermometer'
import type { DisplayRules } from '../../displayRules'
import { defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
export function ScaffoldingTab() {
const { formState, onChange } = useWorksheetConfig()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const displayRules: DisplayRules = formState.displayRules ?? defaultAdditionConfig.displayRules
const updateRule = (key: keyof DisplayRules, value: DisplayRules[keyof DisplayRules]) => {
onChange({
displayRules: {
...displayRules,
[key]: value,
},
})
}
return (
<div data-component="scaffolding-tab" className={stack({ gap: '3' })}>
{/* Quick presets */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
})}
>
Quick Presets
</div>
<div className={css({ display: 'flex', gap: '1.5' })}>
<button
onClick={() =>
onChange({
displayRules: {
...displayRules,
carryBoxes: 'always',
answerBoxes: 'always',
placeValueColors: 'always',
tenFrames: 'always',
borrowNotation: 'always',
borrowingHints: 'always',
},
})
}
className={css({
px: '2',
py: '0.5',
fontSize: '2xs',
color: isDark ? 'brand.300' : 'brand.600',
border: '1px solid',
borderColor: isDark ? 'brand.500' : 'brand.300',
bg: isDark ? 'gray.700' : 'white',
rounded: 'md',
cursor: 'pointer',
_hover: { bg: isDark ? 'gray.600' : 'brand.50' },
})}
>
All Always
</button>
<button
onClick={() =>
onChange({
displayRules: {
...displayRules,
carryBoxes: 'never',
answerBoxes: 'never',
placeValueColors: 'never',
tenFrames: 'never',
borrowNotation: 'never',
borrowingHints: 'never',
},
})
}
className={css({
px: '2',
py: '0.5',
fontSize: '2xs',
color: isDark ? 'gray.300' : 'gray.600',
border: '1px solid',
borderColor: isDark ? 'gray.500' : 'gray.300',
bg: isDark ? 'gray.700' : 'white',
rounded: 'md',
cursor: 'pointer',
_hover: { bg: isDark ? 'gray.600' : 'gray.50' },
})}
>
Minimal
</button>
</div>
</div>
{/* Pedagogical scaffolding thermometers */}
<RuleThermometer
label="Answer Boxes"
description="Guide students to write organized, aligned answers"
value={displayRules.answerBoxes}
onChange={(value) => updateRule('answerBoxes', value)}
isDark={isDark}
/>
<RuleThermometer
label="Place Value Colors"
description="Reinforce place value understanding visually"
value={displayRules.placeValueColors}
onChange={(value) => updateRule('placeValueColors', value)}
isDark={isDark}
/>
<RuleThermometer
label={
formState.operator === 'subtraction'
? 'Borrow Boxes'
: formState.operator === 'mixed'
? 'Carry/Borrow Boxes'
: 'Carry Boxes'
}
description={
formState.operator === 'subtraction'
? 'Help students track borrowing during subtraction'
: formState.operator === 'mixed'
? 'Help students track regrouping (carrying in addition, borrowing in subtraction)'
: 'Help students track regrouping during addition'
}
value={displayRules.carryBoxes}
onChange={(value) => updateRule('carryBoxes', value)}
isDark={isDark}
/>
{(formState.operator === 'subtraction' || formState.operator === 'mixed') && (
<RuleThermometer
label="Borrowed 10s Box"
description="Box for adding 10 to borrowing digit"
value={displayRules.borrowNotation}
onChange={(value) => updateRule('borrowNotation', value)}
isDark={isDark}
/>
)}
{(formState.operator === 'subtraction' || formState.operator === 'mixed') && (
<RuleThermometer
label="Borrowing Hints"
description="Show arrows and calculations guiding the borrowing process"
value={displayRules.borrowingHints}
onChange={(value) => updateRule('borrowingHints', value)}
isDark={isDark}
/>
)}
<RuleThermometer
label="Ten-Frames"
description="Visualize regrouping with concrete counting tools"
value={displayRules.tenFrames}
onChange={(value) => updateRule('tenFrames', value)}
isDark={isDark}
/>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
// Shared logic for generating worksheet previews (used by both API route and SSR)
import { execSync } from 'child_process'
import { validateWorksheetConfig } from './validation'
import {
generateProblems,
generateSubtractionProblems,
generateMixedProblems,
generateMasteryMixedProblems,
} from './problemGenerator'
import { generateTypstSource } from './typstGenerator'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { getSkillById } from './skills'
export interface PreviewResult {
success: boolean
pages?: string[]
error?: string
details?: string
}
/**
* Generate worksheet preview SVG pages
* Can be called from API routes or Server Components
*/
export function generateWorksheetPreview(config: WorksheetFormState): PreviewResult {
try {
// Validate configuration
const validation = validateWorksheetConfig(config)
if (!validation.isValid || !validation.config) {
return {
success: false,
error: 'Invalid configuration',
details: validation.errors?.join(', '),
}
}
const validatedConfig = validation.config
// Generate all problems for full preview based on operator
const operator = validatedConfig.operator ?? 'addition'
const mode = config.mode ?? 'smart'
let problems
// Special handling for mastery + mixed mode
if (mode === 'mastery' && operator === 'mixed') {
// Query both skill configs
const addSkillId = config.currentAdditionSkillId
const subSkillId = config.currentSubtractionSkillId
if (!addSkillId || !subSkillId) {
return {
success: false,
error: 'Mixed mastery mode requires both addition and subtraction skill IDs',
}
}
const addSkill = getSkillById(addSkillId as any)
const subSkill = getSkillById(subSkillId as any)
if (!addSkill || !subSkill) {
return {
success: false,
error: 'Invalid skill IDs',
}
}
// Use skill-specific configs
problems = generateMasteryMixedProblems(
validatedConfig.total,
{
digitRange: addSkill.digitRange,
pAnyStart: addSkill.regroupingConfig.pAnyStart,
pAllStart: addSkill.regroupingConfig.pAllStart,
},
{
digitRange: subSkill.digitRange,
pAnyStart: subSkill.regroupingConfig.pAnyStart,
pAllStart: subSkill.regroupingConfig.pAllStart,
},
validatedConfig.seed
)
} else {
// Standard problem generation
problems =
operator === 'addition'
? generateProblems(
validatedConfig.total,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed,
validatedConfig.digitRange
)
: operator === 'subtraction'
? generateSubtractionProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
: generateMixedProblems(
validatedConfig.total,
validatedConfig.digitRange,
validatedConfig.pAnyStart,
validatedConfig.pAllStart,
validatedConfig.interpolate,
validatedConfig.seed
)
}
// Generate Typst sources (one per page)
const typstSources = generateTypstSource(validatedConfig, problems)
// Compile each page source to SVG (using stdout for single-page output)
const pages: string[] = []
for (let i = 0; i < typstSources.length; i++) {
const typstSource = typstSources[i]
// Compile to SVG via stdin/stdout
try {
const svgOutput = execSync('typst compile --format svg - -', {
input: typstSource,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024, // 10MB limit
})
pages.push(svgOutput)
} catch (error) {
console.error(`Typst compilation error (page ${i + 1}):`, error)
// Extract the actual Typst error message
const stderr =
error instanceof Error && 'stderr' in error
? String((error as any).stderr)
: 'Unknown compilation error'
return {
success: false,
error: `Failed to compile preview (page ${i + 1})`,
details: stderr,
}
}
}
return {
success: true,
pages,
}
} catch (error) {
console.error('Error generating preview:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
return {
success: false,
error: 'Failed to generate preview',
details: errorMessage,
}
}
}

View File

@@ -0,0 +1,136 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
interface UseWorksheetAutoSaveReturn {
isSaving: boolean
lastSaved: Date | null
}
/**
* Auto-save worksheet settings to server
*
* Features:
* - Debounced auto-save (1000ms delay)
* - Only persists settings, not transient state (date, seed, rows, total)
* - Persists V4 fields: mode, digitRange, displayRules, difficultyProfile, manualPreset
* - Silent error handling (auto-save is not critical)
* - StrictMode-safe (handles double renders)
*/
export function useWorksheetAutoSave(
formState: WorksheetFormState,
worksheetType: 'addition'
): UseWorksheetAutoSaveReturn {
const [isSaving, setIsSaving] = useState(false)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
// Store the previous formState for auto-save to detect real changes
const prevAutoSaveFormStateRef = useRef(formState)
// Auto-save settings when they change (debounced) - skip on initial mount
useEffect(() => {
// Skip auto-save if formState hasn't actually changed (handles StrictMode double-render)
if (formState === prevAutoSaveFormStateRef.current) {
console.log('[useWorksheetAutoSave] Skipping auto-save - formState reference unchanged')
return
}
prevAutoSaveFormStateRef.current = formState
console.log('[useWorksheetAutoSave] Settings changed, will save in 1s...')
const timer = setTimeout(async () => {
console.log('[useWorksheetAutoSave] Attempting to save settings...')
setIsSaving(true)
try {
// Extract only the fields we want to persist (exclude date, seed, derived state)
const {
problemsPerPage,
cols,
pages,
orientation,
name,
digitRange,
operator,
pAnyStart,
pAllStart,
interpolate,
showCarryBoxes,
showAnswerBoxes,
showPlaceValueColors,
showProblemNumbers,
showCellBorder,
showTenFrames,
showTenFramesForAll,
showBorrowNotation,
showBorrowingHints,
fontSize,
mode,
difficultyProfile,
displayRules,
manualPreset,
} = formState
const response = await fetch('/api/worksheets/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: worksheetType,
config: {
problemsPerPage,
cols,
pages,
orientation,
name,
digitRange,
operator,
pAnyStart,
pAllStart,
interpolate,
showCarryBoxes,
showAnswerBoxes,
showPlaceValueColors,
showProblemNumbers,
showCellBorder,
showTenFrames,
showTenFramesForAll,
showBorrowNotation,
showBorrowingHints,
fontSize,
mode,
difficultyProfile,
displayRules,
manualPreset,
},
}),
})
if (response.ok) {
const data = await response.json()
console.log('[useWorksheetAutoSave] Save response:', data)
if (data.success) {
console.log('[useWorksheetAutoSave] ✓ Settings saved successfully')
setLastSaved(new Date())
} else {
console.log('[useWorksheetAutoSave] Save skipped')
}
} else {
console.error('[useWorksheetAutoSave] Save failed with status:', response.status)
}
} catch (error) {
// Silently fail - settings persistence is not critical
console.error('[useWorksheetAutoSave] Settings save error:', error)
} finally {
setIsSaving(false)
}
}, 1000) // 1 second debounce for auto-save
return () => clearTimeout(timer)
}, [formState, worksheetType])
return {
isSaving,
lastSaved,
}
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useState } from 'react'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { validateWorksheetConfig } from '../validation'
type GenerationStatus = 'idle' | 'generating' | 'error'
interface UseWorksheetGenerationReturn {
status: GenerationStatus
error: string | null
generate: (config: WorksheetFormState) => Promise<void>
reset: () => void
}
/**
* Handle PDF generation workflow
*
* Features:
* - Status tracking ('idle', 'generating', 'error')
* - Validation before generation
* - API call to generate PDF
* - Automatic download of generated PDF
* - Error handling with detailed messages
*/
export function useWorksheetGeneration(): UseWorksheetGenerationReturn {
const [status, setStatus] = useState<GenerationStatus>('idle')
const [error, setError] = useState<string | null>(null)
const generate = async (config: WorksheetFormState) => {
setStatus('generating')
setError(null)
try {
// Validate configuration
const validation = validateWorksheetConfig(config)
if (!validation.isValid || !validation.config) {
throw new Error(validation.errors?.join(', ') || 'Invalid configuration')
}
const response = await fetch('/api/create/worksheets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
if (!response.ok) {
const errorResult = await response.json()
const errorMsg = errorResult.details
? `${errorResult.error}\n\n${errorResult.details}`
: errorResult.error || 'Generation failed'
throw new Error(errorMsg)
}
// Success - response is binary PDF data, trigger download
const blob = await response.blob()
const filename = `addition-worksheet-${config.name || 'student'}-${Date.now()}.pdf`
// Create download link and trigger download
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
setStatus('idle')
} catch (err) {
console.error('Generation error:', err)
setError(err instanceof Error ? err.message : 'Unknown error occurred')
setStatus('error')
}
}
const reset = () => {
setStatus('idle')
setError(null)
}
return {
status,
error,
generate,
reset,
}
}

View File

@@ -0,0 +1,124 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
interface UseWorksheetStateReturn {
formState: WorksheetFormState
debouncedFormState: WorksheetFormState
updateFormState: (updates: Partial<WorksheetFormState>) => void
}
/**
* Manage worksheet state with debouncing and seed regeneration
*
* Features:
* - Immediate form state updates for controls
* - Debounced state updates for preview (500ms)
* - Automatic seed regeneration when problem settings change
* - StrictMode-safe (handles double renders)
*/
export function useWorksheetState(
initialSettings: Omit<WorksheetFormState, 'date' | 'rows' | 'total'>
): UseWorksheetStateReturn {
// Calculate derived state from initial settings
const problemsPerPage = initialSettings.problemsPerPage ?? 20
const pages = initialSettings.pages ?? 1
const cols = initialSettings.cols ?? 5
const rows = Math.ceil((problemsPerPage * pages) / cols)
const total = problemsPerPage * pages
// Immediate form state (for controls - updates instantly)
const [formState, setFormState] = useState<WorksheetFormState>(() => {
const initial = {
...initialSettings,
rows,
total,
date: '', // Will be set at generation time
// Ensure displayRules is always defined (critical for difficulty adjustment)
displayRules: initialSettings.displayRules ?? defaultAdditionConfig.displayRules,
pAnyStart: initialSettings.pAnyStart ?? defaultAdditionConfig.pAnyStart,
pAllStart: initialSettings.pAllStart ?? defaultAdditionConfig.pAllStart,
}
console.log('[useWorksheetState] Initial formState:', {
seed: initial.seed,
displayRules: initial.displayRules,
})
return initial
})
// Debounced form state (for preview - updates after delay)
const [debouncedFormState, setDebouncedFormState] = useState<WorksheetFormState>(() => {
console.log('[useWorksheetState] Initial debouncedFormState (same as formState)')
return formState
})
// Store the previous formState to detect real changes
const prevFormStateRef = useRef(formState)
// Log whenever debouncedFormState changes (this triggers preview re-fetch)
useEffect(() => {
console.log('[useWorksheetState] debouncedFormState changed - preview will re-fetch:', {
seed: debouncedFormState.seed,
problemsPerPage: debouncedFormState.problemsPerPage,
})
}, [debouncedFormState])
// Debounce preview updates (500ms delay) - only when formState actually changes
useEffect(() => {
console.log('[useWorksheetState Debounce] Triggered')
console.log('[useWorksheetState Debounce] Current formState seed:', formState.seed)
console.log(
'[useWorksheetState Debounce] Previous formState seed:',
prevFormStateRef.current.seed
)
// Skip if formState hasn't actually changed (handles StrictMode double-render)
if (formState === prevFormStateRef.current) {
console.log('[useWorksheetState Debounce] Skipping - formState reference unchanged')
return
}
prevFormStateRef.current = formState
console.log('[useWorksheetState Debounce] Setting timer to update debouncedFormState in 500ms')
const timer = setTimeout(() => {
console.log('[useWorksheetState Debounce] Timer fired - updating debouncedFormState')
setDebouncedFormState(formState)
}, 500)
return () => {
console.log('[useWorksheetState Debounce] Cleanup - clearing timer')
clearTimeout(timer)
}
}, [formState])
const updateFormState = (updates: Partial<WorksheetFormState>) => {
setFormState((prev) => {
const newState = { ...prev, ...updates }
// Generate new seed when problem settings change
const affectsProblems =
updates.problemsPerPage !== undefined ||
updates.cols !== undefined ||
updates.pages !== undefined ||
updates.orientation !== undefined ||
updates.pAnyStart !== undefined ||
updates.pAllStart !== undefined ||
updates.interpolate !== undefined
if (affectsProblems) {
newState.seed = Date.now() % 2147483647
}
return newState
})
}
return {
formState,
debouncedFormState,
updateFormState,
}
}

View File

@@ -0,0 +1,107 @@
// Type definitions for addition worksheet creator (supports 1-5 digit problems)
import type {
AdditionConfigV4,
AdditionConfigV4Smart,
AdditionConfigV4Manual,
AdditionConfigV4Mastery,
} from '@/app/create/worksheets/config-schemas'
/**
* Complete, validated configuration for worksheet generation
* Extends V4 config with additional derived fields needed for rendering
*
* V4 uses discriminated union on 'mode':
* - Smart mode: Uses displayRules for conditional per-problem scaffolding
* - Manual mode: Uses boolean flags for uniform display across all problems
*
* V4 adds digitRange field to support 1-5 digit problems
*/
export type WorksheetConfig = AdditionConfigV4 & {
// Problem set - DERIVED state
total: number // total = problemsPerPage * pages
rows: number // rows = (problemsPerPage / cols) * pages
// Personalization
date: string
seed: number
// Layout
page: {
wIn: number
hIn: number
}
margins: {
left: number
right: number
top: number
bottom: number
}
}
/**
* Partial form state - user may be editing, fields optional
* Based on V4 config with additional derived state
*
* V4 supports three modes via discriminated union:
* - Smart mode: Has displayRules and optional difficultyProfile
* - Mastery mode: Has displayRules and optional currentStepId
* - Manual mode: Has boolean display flags and optional manualPreset
*
* During editing, mode field may be present to indicate which mode is active.
* If mode is absent, defaults to 'smart' mode.
*
* This type is intentionally permissive during form editing to allow fields from
* all modes to exist temporarily. Validation will enforce mode consistency.
*/
export type WorksheetFormState = Partial<Omit<AdditionConfigV4Smart, 'version'>> &
Partial<Omit<AdditionConfigV4Manual, 'version'>> &
Partial<Omit<AdditionConfigV4Mastery, 'version'>> & {
// DERIVED state (calculated from primary state)
rows?: number
total?: number
date?: string
seed?: number
}
/**
* Worksheet operator type
*/
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed'
/**
* A single addition problem
*/
export interface AdditionProblem {
a: number
b: number
operator: 'add'
}
/**
* A single subtraction problem
*/
export interface SubtractionProblem {
minuend: number
subtrahend: number
operator: 'sub'
}
/**
* Unified problem type (addition or subtraction)
*/
export type WorksheetProblem = AdditionProblem | SubtractionProblem
/**
* Validation result
*/
export interface ValidationResult {
isValid: boolean
config?: WorksheetConfig
errors?: string[]
}
/**
* Problem category for difficulty control
*/
export type ProblemCategory = 'non' | 'onesOnly' | 'both'

View File

@@ -0,0 +1,16 @@
/**
* Date formatting utilities for worksheet generation
*/
/**
* Get current date formatted as "Month Day, Year"
* @example "November 7, 2025"
*/
export function getDefaultDate(): string {
const now = new Date()
return now.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}

View File

@@ -0,0 +1,48 @@
/**
* Layout calculation utilities for worksheet grid sizing
*/
/**
* Get default number of columns based on problems per page and orientation
* @param problemsPerPage - Number of problems per page
* @param orientation - Page orientation
* @returns Optimal number of columns for the layout
*/
export function getDefaultColsForProblemsPerPage(
problemsPerPage: number,
orientation: 'portrait' | 'landscape'
): number {
if (orientation === 'portrait') {
if (problemsPerPage === 6) return 2
if (problemsPerPage === 8) return 2
if (problemsPerPage === 10) return 2
if (problemsPerPage === 12) return 3
if (problemsPerPage === 15) return 3
return 2
} else {
if (problemsPerPage === 8) return 4
if (problemsPerPage === 10) return 5
if (problemsPerPage === 12) return 4
if (problemsPerPage === 15) return 5
if (problemsPerPage === 16) return 4
if (problemsPerPage === 20) return 5
return 4
}
}
/**
* Calculate derived state from worksheet layout parameters
* @param problemsPerPage - Number of problems per page
* @param pages - Number of pages
* @param cols - Number of columns
* @returns Calculated rows and total problems
*/
export function calculateDerivedState(
problemsPerPage: number,
pages: number,
cols: number
): { rows: number; total: number } {
const total = problemsPerPage * pages
const rows = Math.ceil(total / cols)
return { rows, total }
}

View File

@@ -0,0 +1,293 @@
// Validation logic for worksheet configuration
import type {
WorksheetFormState,
WorksheetConfig,
ValidationResult,
} from '@/app/create/worksheets/types'
import type { DisplayRules } from './displayRules'
import { getSkillById } from './skills'
/**
* Get current date formatted as "Month Day, Year"
*/
function getDefaultDate(): string {
const now = new Date()
return now.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
/**
* Validate and create complete config from partial form state
*/
export function validateWorksheetConfig(formState: WorksheetFormState): ValidationResult {
const errors: string[] = []
// Validate total (must be positive, reasonable limit)
const total = formState.total ?? 20
if (total < 1 || total > 100) {
errors.push('Total problems must be between 1 and 100')
}
// Validate cols and auto-calculate rows
const cols = formState.cols ?? 4
if (cols < 1 || cols > 10) {
errors.push('Columns must be between 1 and 10')
}
// Auto-calculate rows to fit all problems
const rows = Math.ceil(total / cols)
// Validate probabilities (0-1 range)
// CRITICAL: Must check for undefined/null explicitly, not use ?? operator
// because 0 is a valid value (e.g., "no regrouping" skills set pAnyStart=0)
const pAnyStart =
formState.pAnyStart !== undefined && formState.pAnyStart !== null ? formState.pAnyStart : 0.75
const pAllStart =
formState.pAllStart !== undefined && formState.pAllStart !== null ? formState.pAllStart : 0.25
if (pAnyStart < 0 || pAnyStart > 1) {
errors.push('pAnyStart must be between 0 and 1')
}
if (pAllStart < 0 || pAllStart > 1) {
errors.push('pAllStart must be between 0 and 1')
}
if (pAllStart > pAnyStart) {
errors.push('pAllStart cannot be greater than pAnyStart')
}
// Validate fontSize
const fontSize = formState.fontSize ?? 16
if (fontSize < 8 || fontSize > 32) {
errors.push('Font size must be between 8 and 32')
}
// V4: Validate digitRange (min and max must be 1-5, min <= max)
// Note: Same range applies to both addition and subtraction
const digitRange = formState.digitRange ?? { min: 2, max: 2 }
if (!digitRange.min || digitRange.min < 1 || digitRange.min > 5) {
errors.push('Digit range min must be between 1 and 5')
}
if (!digitRange.max || digitRange.max < 1 || digitRange.max > 5) {
errors.push('Digit range max must be between 1 and 5')
}
if (digitRange.min > digitRange.max) {
errors.push('Digit range min cannot be greater than max')
}
// V4: Validate operator (addition, subtraction, or mixed)
const operator = formState.operator ?? 'addition'
if (!['addition', 'subtraction', 'mixed'].includes(operator)) {
errors.push('Operator must be "addition", "subtraction", or "mixed"')
}
// Validate seed (must be positive integer)
const seed = formState.seed ?? Date.now() % 2147483647
if (!Number.isInteger(seed) || seed < 0) {
errors.push('Seed must be a non-negative integer')
}
if (errors.length > 0) {
return { isValid: false, errors }
}
// Determine orientation based on columns (portrait = 2-3 cols, landscape = 4-5 cols)
const orientation = formState.orientation || (cols <= 3 ? 'portrait' : 'landscape')
// Get primary state values
const problemsPerPage = formState.problemsPerPage ?? total
const pages = formState.pages ?? 1
// Determine mode (default to 'smart' if not specified)
const mode = formState.mode ?? 'smart'
// Shared fields for both modes
const sharedFields = {
// Primary state
problemsPerPage,
cols,
pages,
orientation,
// Derived state
total,
rows,
// Other fields
name: formState.name?.trim() || 'Student',
date: formState.date?.trim() || getDefaultDate(),
pAnyStart,
pAllStart,
// Default interpolate based on mode: true for smart/manual, false for mastery
interpolate:
formState.interpolate !== undefined
? formState.interpolate
: mode === 'mastery'
? false
: true,
// V4: Digit range for problem generation
digitRange,
// V4: Operator selection (addition, subtraction, or mixed)
operator: formState.operator ?? 'addition',
// Layout
page: {
wIn: orientation === 'portrait' ? 8.5 : 11,
hIn: orientation === 'portrait' ? 11 : 8.5,
},
margins: {
left: 0.6,
right: 0.6,
top: 1.1,
bottom: 0.7,
},
fontSize,
seed,
}
// Build mode-specific config
let config: WorksheetConfig
if (mode === 'smart' || mode === 'mastery') {
// Smart & Mastery modes: Use displayRules for conditional scaffolding
// Default display rules
let baseDisplayRules: DisplayRules = {
carryBoxes: 'whenRegrouping',
answerBoxes: 'always',
placeValueColors: 'always',
tenFrames: 'whenRegrouping',
problemNumbers: 'always',
cellBorders: 'always',
borrowNotation: 'whenRegrouping', // Subtraction: show when borrowing
borrowingHints: 'never', // Subtraction: no hints by default
}
// Mastery mode: Apply recommendedScaffolding from current skill(s)
if (mode === 'mastery') {
const operator = formState.operator ?? 'addition'
if (operator === 'mixed') {
// Mixed mode: Store SEPARATE display rules for each operator
// The typstGenerator will choose which rules to apply per-problem
const addSkillId = formState.currentAdditionSkillId
const subSkillId = formState.currentSubtractionSkillId
if (addSkillId && subSkillId) {
const addSkill = getSkillById(addSkillId as any)
const subSkill = getSkillById(subSkillId as any)
if (addSkill?.recommendedScaffolding && subSkill?.recommendedScaffolding) {
// Store both separately - will be used per-problem in typstGenerator
// Note: This will be added to the config below as additionDisplayRules/subtractionDisplayRules
}
}
} else {
// Single operator: Use its recommendedScaffolding
const skillId =
operator === 'addition'
? formState.currentAdditionSkillId
: formState.currentSubtractionSkillId
if (skillId) {
const skill = getSkillById(skillId as any)
if (skill?.recommendedScaffolding) {
baseDisplayRules = { ...skill.recommendedScaffolding }
}
}
}
}
const displayRules: DisplayRules = {
...baseDisplayRules,
...((formState.displayRules as any) ?? {}), // Override with provided rules if any
}
// Build config with operator-specific display rules for mixed mode
const operator = formState.operator ?? 'addition'
const baseConfig = {
version: 4,
mode: mode as 'smart' | 'mastery', // Preserve the actual mode
displayRules,
difficultyProfile: formState.difficultyProfile,
currentStepId: formState.currentStepId, // Mastery progression tracking
...sharedFields,
}
// Add operator-specific display rules for mastery+mixed mode
if (mode === 'mastery' && operator === 'mixed') {
const addSkillId = formState.currentAdditionSkillId
const subSkillId = formState.currentSubtractionSkillId
if (addSkillId && subSkillId) {
const addSkill = getSkillById(addSkillId as any)
const subSkill = getSkillById(subSkillId as any)
if (addSkill?.recommendedScaffolding && subSkill?.recommendedScaffolding) {
// Merge user's displayRules with skill's recommended scaffolding
// User's displayRules take precedence for problemNumbers and cellBorders (layout options)
const userDisplayRules = formState.displayRules || {}
config = {
...baseConfig,
additionDisplayRules: {
...addSkill.recommendedScaffolding,
// Override layout options with user's choices
problemNumbers:
userDisplayRules.problemNumbers ?? addSkill.recommendedScaffolding.problemNumbers,
cellBorders:
userDisplayRules.cellBorders ?? addSkill.recommendedScaffolding.cellBorders,
},
subtractionDisplayRules: {
...subSkill.recommendedScaffolding,
// Override layout options with user's choices
problemNumbers:
userDisplayRules.problemNumbers ?? subSkill.recommendedScaffolding.problemNumbers,
cellBorders:
userDisplayRules.cellBorders ?? subSkill.recommendedScaffolding.cellBorders,
},
} as any
} else {
console.log('[MIXED MODE SCAFFOLDING] Missing recommendedScaffolding', {
addSkill: addSkill?.name,
hasAddScaffolding: !!addSkill?.recommendedScaffolding,
subSkill: subSkill?.name,
hasSubScaffolding: !!subSkill?.recommendedScaffolding,
})
config = baseConfig as any
}
} else {
config = baseConfig as any
}
} else {
config = baseConfig as any
}
} else {
// Manual mode: Use displayRules (same as Smart/Mastery)
const displayRules: DisplayRules = formState.displayRules ?? {
carryBoxes: 'always',
answerBoxes: 'always',
placeValueColors: 'always',
tenFrames: 'never',
problemNumbers: 'always',
cellBorders: 'always',
borrowNotation: 'always',
borrowingHints: 'never',
}
config = {
version: 4,
mode: 'manual',
displayRules,
manualPreset: formState.manualPreset,
...sharedFields,
}
}
return { isValid: true, config }
}