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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
132
apps/web/src/app/create/worksheets/components/ModeSelector.tsx
Normal file
132
apps/web/src/app/create/worksheets/components/ModeSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1180
apps/web/src/app/create/worksheets/difficultyProfiles.ts
Normal file
1180
apps/web/src/app/create/worksheets/difficultyProfiles.ts
Normal file
File diff suppressed because it is too large
Load Diff
163
apps/web/src/app/create/worksheets/generatePreview.ts
Normal file
163
apps/web/src/app/create/worksheets/generatePreview.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
136
apps/web/src/app/create/worksheets/hooks/useWorksheetAutoSave.ts
Normal file
136
apps/web/src/app/create/worksheets/hooks/useWorksheetAutoSave.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
124
apps/web/src/app/create/worksheets/hooks/useWorksheetState.ts
Normal file
124
apps/web/src/app/create/worksheets/hooks/useWorksheetState.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
107
apps/web/src/app/create/worksheets/types.ts
Normal file
107
apps/web/src/app/create/worksheets/types.ts
Normal 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'
|
||||
16
apps/web/src/app/create/worksheets/utils/dateFormatting.ts
Normal file
16
apps/web/src/app/create/worksheets/utils/dateFormatting.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
293
apps/web/src/app/create/worksheets/validation.ts
Normal file
293
apps/web/src/app/create/worksheets/validation.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user