refactor: extract DifficultyPresetDropdown and MakeEasierHarderButtons

Part 1 of SmartModeControls.tsx refactoring (1505 lines → smaller components)

Extracted components:
1. DifficultyPresetDropdown (272 lines)
   - Dropdown selector for difficulty presets
   - Shows current preset or custom configuration
   - Displays hover preview of preset descriptions
   - Handles preset selection with regrouping and scaffolding

2. MakeEasierHarderButtons (343 lines)
   - Four-button layout for difficulty adjustment
   - Alternative modes (challenge/support) with tooltips
   - Conditional rendering based on availability
   - Full dark mode support

Benefits:
- Each component has single responsibility
- Easier to test in isolation
- Improved code organization
- Consistent dark mode theming

Next steps:
- Extract OverallDifficultySlider (~200 lines)
- Extract DifficultySpaceMap (~390 lines)
- Update SmartModeControls to use new components
- Reduces main file from 1505 → ~400 lines

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-11 05:49:16 -06:00
parent 6efde42ee1
commit 4d1c2c1e79
3 changed files with 807 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,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?