feat: add responsive page button layout with dynamic dropdown

Implement fully responsive page selection that adapts to container width:

**Responsive button display:**
- ≤280px: 4 buttons (1, 2, 3, 4), dropdown has remaining options
- ≤320px: 6 buttons (1, 2, 3, 4, 10, 25), dropdown has 50, 100
- >320px: All 8 options as buttons (1, 2, 3, 4, 10, 25, 50, 100)

**Smart dropdown handling:**
- Hidden when empty (all options fit as buttons)
- Single remaining option rendered as button instead of dropdown
- Dropdown label shows first option + "+" or selected value

**Layout improvements:**
- Uses ResizeObserver to track container width dynamically
- Buttons spread across full width with space-between
- Smart orientation switching minimizes total problem count changes
- Total badge moved to layout tab button
- Orientation buttons show calculated layouts for unselected orientation

**Container queries:**
- Replaced viewport media queries with CSS container queries
- Panel responds to its own width (280px minimum), not viewport

🤖 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-18 11:08:29 -06:00
parent 8df62d6a45
commit 3f33cd1924
28 changed files with 542 additions and 336 deletions

View File

@@ -42,11 +42,17 @@
"mcp__sqlite__describe_table",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)"
"Bash(git stash:*)",
"Bash(npx @biomejs/biome:*)",
"Bash(git rev-parse:*)",
"Bash(gh run list:*)",
"Bash(npx biome:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["sqlite"]
"enabledMcpjsonServers": [
"sqlite"
]
}

View File

@@ -10,16 +10,12 @@
import { execSync } from 'child_process'
import { type NextRequest, NextResponse } from 'next/server'
import {
generateProblems,
generateSubtractionProblems,
} from '@/app/create/worksheets/problemGenerator'
import type { WorksheetOperator } from '@/app/create/worksheets/types'
import {
generatePlaceValueColors,
generateProblemStackFunction,
generateSubtractionProblemStackFunction,
generateTypstHelpers,
generatePlaceValueColors,
} from '@/app/create/worksheets/typstHelpers'
export const dynamic = 'force-dynamic'
@@ -64,7 +60,7 @@ function generateExampleTypst(config: ExampleRequest): string {
const showBorrowingHints = config.showBorrowingHints ?? false
if (operator === 'addition') {
// Use custom addends if provided, otherwise generate a problem
// Use custom addends if provided, otherwise use a 3-digit problem with multiple regroups
let a: number
let b: number
@@ -72,11 +68,10 @@ function generateExampleTypst(config: ExampleRequest): string {
a = config.addend1
b = config.addend2
} else {
// Generate a simple 2-digit + 2-digit problem with carries
const problems = generateProblems(1, 0.8, 0.5, false, 12345)
const problem = problems[0]
a = problem.a
b = problem.b
// Use a 3-digit problem with multiple regroups to demonstrate all scaffolding features
// 456 + 789 = 1245 (3 digits, 3 regroups: ones, tens, hundreds)
a = 456
b = 789
}
return String.raw`
@@ -113,12 +108,10 @@ ${generateProblemStackFunction(cellSize, 3)}
minuend = config.minuend
subtrahend = config.subtrahend
} else {
// Generate a simple 2-digit - 2-digit problem with borrows
const digitRange = { min: 2, max: 2 }
const problems = generateSubtractionProblems(1, digitRange, 0.8, 0.5, false, 12345)
const problem = problems[0]
minuend = problem.minuend
subtrahend = problem.subtrahend
// Use a 3-digit problem with multiple borrows to demonstrate all scaffolding features
// 832 - 456 = 376 (3 digits, 2 borrows: ones and tens)
minuend = 832
subtrahend = 456
}
return String.raw`

View File

@@ -97,7 +97,7 @@ describe('Ten-frames rendering', () => {
it('should pass showTenFrames: true to Typst template for regrouping problems', () => {
const config: WorksheetConfig = {
version: 4,
mode: 'smart',
mode: 'custom',
problemsPerPage: 4,
cols: 2,
pages: 1,
@@ -149,7 +149,7 @@ describe('Ten-frames rendering', () => {
it('should include ten-frames rendering code when showTenFrames: true', () => {
const config: WorksheetConfig = {
version: 4,
mode: 'smart',
mode: 'custom',
problemsPerPage: 2,
cols: 1,
pages: 1,

View File

@@ -267,7 +267,7 @@ export const SmartModeEarlyLearner: Story = {
render: () => (
<FullWorksheetGenerator
initialState={{
mode: 'smart',
mode: 'custom',
difficultyProfile: 'earlyLearner',
pages: 3,
}}
@@ -279,7 +279,7 @@ export const SmartModeAdvanced: Story = {
render: () => (
<FullWorksheetGenerator
initialState={{
mode: 'smart',
mode: 'custom',
difficultyProfile: 'advanced',
pages: 5,
}}

View File

@@ -10,7 +10,7 @@ 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 { CustomModeControls } from './config-panel/CustomModeControls'
import { MasteryModePanel } from './config-panel/MasteryModePanel'
import { DisplayControlsPanel } from './DisplayControlsPanel'
import { validateProblemSpace } from '@/app/create/worksheets/utils/validateProblemSpace'
@@ -56,8 +56,8 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
])
// Handler for difficulty method switching (smart vs mastery)
const handleMethodChange = (newMethod: 'smart' | 'mastery') => {
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'smart'
const handleMethodChange = (newMethod: 'custom' | 'mastery') => {
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'custom'
if (currentMethod === newMethod) {
return // No change needed
}
@@ -65,9 +65,9 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
// Preserve displayRules when switching
const displayRules = formState.displayRules ?? defaultAdditionConfig.displayRules
if (newMethod === 'smart') {
if (newMethod === 'custom') {
onChange({
mode: 'smart',
mode: 'custom',
displayRules,
difficultyProfile: 'earlyLearner',
} as unknown as Partial<WorksheetFormState>)
@@ -80,7 +80,7 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
}
// Determine current method for selector
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'smart'
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'custom'
return (
<WorksheetConfigProvider formState={formState} updateFormState={onChange}>
@@ -158,8 +158,8 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
/>
{/* Method-specific preset controls */}
{currentMethod === 'smart' && (
<SmartModeControls formState={formState} onChange={onChange} />
{currentMethod === 'custom' && (
<CustomModeControls formState={formState} onChange={onChange} />
)}
{currentMethod === 'mastery' && (

View File

@@ -103,7 +103,7 @@ export const SmartMode: Story = {
render: () => (
<SidebarWrapper
initialState={{
mode: 'smart',
mode: 'custom',
difficultyProfile: 'earlyLearner',
}}
/>

View File

@@ -3,8 +3,8 @@
import { css } from '@styled/css'
interface DifficultyMethodSelectorProps {
currentMethod: 'smart' | 'mastery'
onChange: (method: 'smart' | 'mastery') => void
currentMethod: 'custom' | 'mastery'
onChange: (method: 'custom' | 'mastery') => void
isDark?: boolean
}
@@ -24,11 +24,11 @@ export function DifficultyMethodSelector({
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Smart Difficulty Tab */}
{/* Custom Difficulty Tab */}
<button
type="button"
data-action="select-smart"
onClick={() => onChange('smart')}
onClick={() => onChange('custom')}
className={css({
display: 'flex',
alignItems: 'center',
@@ -37,16 +37,16 @@ export function DifficultyMethodSelector({
px: '5',
py: '3',
flex: '1',
bg: currentMethod === 'smart' ? (isDark ? 'gray.800' : 'white') : 'transparent',
bg: currentMethod === 'custom' ? (isDark ? 'gray.800' : 'white') : 'transparent',
color:
currentMethod === 'smart'
currentMethod === 'custom'
? isDark
? 'brand.300'
: 'brand.600'
: isDark
? 'gray.500'
: 'gray.500',
fontWeight: currentMethod === 'smart' ? 'bold' : 'medium',
fontWeight: currentMethod === 'custom' ? 'bold' : 'medium',
fontSize: 'sm',
borderTopLeftRadius: 'lg',
borderTopRightRadius: 'lg',
@@ -54,11 +54,11 @@ export function DifficultyMethodSelector({
transition: 'all 0.2s',
borderBottom: '3px solid',
borderColor:
currentMethod === 'smart' ? (isDark ? 'brand.500' : 'brand.500') : 'transparent',
currentMethod === 'custom' ? (isDark ? 'brand.500' : 'brand.500') : 'transparent',
mb: '-2px',
_hover: {
color:
currentMethod === 'smart'
currentMethod === 'custom'
? isDark
? 'brand.200'
: 'brand.700'
@@ -66,7 +66,7 @@ export function DifficultyMethodSelector({
? 'gray.400'
: 'gray.600',
bg:
currentMethod === 'smart'
currentMethod === 'custom'
? isDark
? 'gray.800'
: 'white'
@@ -77,7 +77,7 @@ export function DifficultyMethodSelector({
})}
>
<span>🎯</span>
<span>Smart Difficulty</span>
<span>Custom Difficulty</span>
</button>
{/* Mastery Progression Tab */}
@@ -147,7 +147,7 @@ export function DifficultyMethodSelector({
textAlign: 'center',
})}
>
{currentMethod === 'smart'
{currentMethod === 'custom'
? 'Choose a difficulty preset, then customize display options below'
: 'Follow a structured skill progression with recommended scaffolding'}
</div>

View File

@@ -3,21 +3,21 @@
import { css } from '@styled/css'
interface ModeSelectorProps {
currentMode: 'smart' | 'manual' | 'mastery'
onChange: (mode: 'smart' | 'manual' | 'mastery') => void
currentMode: 'custom' | 'manual' | 'mastery'
onChange: (mode: 'custom' | '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
* Large, prominent tabs that switch between Custom Difficulty, Manual Control, and Mastery Progression modes
*/
export function ModeSelector({ currentMode, onChange, isDark = false }: ModeSelectorProps) {
const modes = [
{
id: 'smart' as const,
id: 'custom' as const,
emoji: '🎯',
label: 'Smart Difficulty',
label: 'Custom Difficulty',
description: 'Research-backed progressive difficulty with adaptive scaffolding per problem',
},
{

View File

@@ -3,11 +3,11 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as Tooltip from '@radix-ui/react-tooltip'
import { css } from '@styled/css'
import { useMemo, useState } from 'react'
import { LayoutPreview } from './config-sidebar/LayoutPreview'
import { validateProblemSpace } from '../utils/validateProblemSpace'
import type { ProblemSpaceValidation } from '../utils/validateProblemSpace'
import { useEffect, useRef, useState } from 'react'
import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations'
import type { ProblemSpaceValidation } from '../utils/validateProblemSpace'
import { validateProblemSpace } from '../utils/validateProblemSpace'
import { LayoutPreview } from './config-sidebar/LayoutPreview'
interface OrientationPanelProps {
orientation: 'portrait' | 'landscape'
@@ -56,8 +56,27 @@ export function OrientationPanel({
onProblemNumbersChange,
onCellBordersChange,
}: OrientationPanelProps) {
// Calculate best problems per page for an orientation to minimize total change
const getBestProblemsPerPage = (targetOrientation: 'portrait' | 'landscape') => {
const currentTotal = problemsPerPage * pages
const options = targetOrientation === 'portrait' ? [6, 8, 10, 12, 15] : [8, 10, 12, 15, 16, 20]
let bestOption = options[options.length - 1] // default to largest
let smallestDiff = Math.abs(bestOption * pages - currentTotal)
for (const option of options) {
const diff = Math.abs(option * pages - currentTotal)
if (diff < smallestDiff) {
smallestDiff = diff
bestOption = option
}
}
return bestOption
}
const handleOrientationChange = (newOrientation: 'portrait' | 'landscape') => {
const newProblemsPerPage = newOrientation === 'portrait' ? 15 : 20
const newProblemsPerPage = getBestProblemsPerPage(newOrientation)
const newCols = getDefaultColsForProblemsPerPage(newProblemsPerPage, newOrientation)
onOrientationChange(newOrientation, newProblemsPerPage, newCols)
}
@@ -68,8 +87,39 @@ export function OrientationPanel({
}
const [dropdownOpen, setDropdownOpen] = useState(false)
const [containerWidth, setContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
// Track container width to determine which buttons are visible
useEffect(() => {
const container = containerRef.current
if (!container) return
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
setContainerWidth(entry.contentRect.width)
}
})
resizeObserver.observe(container)
return () => resizeObserver.disconnect()
}, [])
// All possible page options
const allPageOptions = [1, 2, 3, 4, 10, 25, 50, 100]
// Determine which buttons are visible based on container width
const getVisibleButtonCount = (): number => {
if (containerWidth <= 280) return 4
if (containerWidth <= 320) return 6
return 8 // Show all options as buttons
}
const visibleButtonCount = getVisibleButtonCount()
const visibleButtons = allPageOptions.slice(0, visibleButtonCount)
const dropdownOptions = allPageOptions.slice(visibleButtonCount)
const total = problemsPerPage * pages
const problemsForOrientation =
orientation === 'portrait' ? [6, 8, 10, 12, 15] : [8, 10, 12, 15, 16, 20]
@@ -110,15 +160,14 @@ export function OrientationPanel({
}
/**
* Get the mildest (most severe) warning among dropdown items (4, 10, 25, 50, 100)
* Get the mildest (most severe) warning among dropdown items
* Returns 'none' if no warnings, 'caution' if any caution, 'danger' if any danger
*/
const getDropdownMildestWarning = (): 'none' | 'caution' | 'danger' => {
const dropdownPageCounts = [4, 10, 25, 50, 100]
let hasCaution = false
let hasDanger = false
for (const pageCount of dropdownPageCounts) {
for (const pageCount of dropdownOptions) {
const risk = getRiskLevel(getValidationForPageCount(pageCount))
if (risk === 'danger') hasDanger = true
if (risk === 'caution') hasCaution = true
@@ -139,35 +188,35 @@ export function OrientationPanel({
p: '4',
minWidth: 0,
overflow: 'hidden',
'@media (max-width: 400px)': {
containerType: 'inline-size',
'@container (max-width: 400px)': {
p: '3',
},
'@media (max-width: 300px)': {
'@container (max-width: 300px)': {
p: '2',
},
})}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '3' })}>
{/* Row 1: Orientation + Pages */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '3',
'@container (min-width: 400px)': {
gap: '4',
},
})}
>
{/* Orientation + Pages - Always stacked for better visual hierarchy */}
<div
className={css({
display: 'flex',
flexDirection: 'row',
flexDirection: 'column',
gap: '3',
alignItems: 'end',
'@media (max-width: 444px)': {
flexDirection: 'column',
gap: '2',
},
})}
>
{/* Orientation */}
<div
className={css({
flex: '1',
minWidth: 0,
})}
>
<div>
<div
className={css({
fontSize: '2xs',
@@ -176,6 +225,9 @@ export function OrientationPanel({
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1.5',
'@container (min-width: 400px)': {
mb: '2',
},
})}
>
Orientation
@@ -183,54 +235,76 @@ export function OrientationPanel({
<div
className={css({
display: 'flex',
gap: '1.5',
'@media (max-width: 400px)': {
gap: '1',
gap: '2',
width: '100%',
'@container (min-width: 500px)': {
gap: '3',
},
})}
>
<LayoutPreview
orientation="portrait"
cols={
orientation === 'portrait'
? cols
: getDefaultColsForProblemsPerPage(15, 'portrait')
}
rows={
orientation === 'portrait'
? Math.ceil(problemsPerPage / cols)
: Math.ceil(15 / getDefaultColsForProblemsPerPage(15, 'portrait'))
}
onClick={() => handleOrientationChange('portrait')}
isSelected={orientation === 'portrait'}
/>
<LayoutPreview
orientation="landscape"
cols={
orientation === 'landscape'
? cols
: getDefaultColsForProblemsPerPage(20, 'landscape')
}
rows={
orientation === 'landscape'
? Math.ceil(problemsPerPage / cols)
: Math.ceil(20 / getDefaultColsForProblemsPerPage(20, 'landscape'))
}
onClick={() => handleOrientationChange('landscape')}
isSelected={orientation === 'landscape'}
/>
<div
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
{(() => {
const portraitProblemsPerPage =
orientation === 'portrait'
? problemsPerPage
: getBestProblemsPerPage('portrait')
const portraitCols = getDefaultColsForProblemsPerPage(
portraitProblemsPerPage,
'portrait'
)
return (
<LayoutPreview
orientation="portrait"
cols={portraitCols}
rows={Math.ceil(portraitProblemsPerPage / portraitCols)}
onClick={() => handleOrientationChange('portrait')}
isSelected={orientation === 'portrait'}
maxSize={80}
/>
)
})()}
</div>
<div
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
{(() => {
const landscapeProblemsPerPage =
orientation === 'landscape'
? problemsPerPage
: getBestProblemsPerPage('landscape')
const landscapeCols = getDefaultColsForProblemsPerPage(
landscapeProblemsPerPage,
'landscape'
)
return (
<LayoutPreview
orientation="landscape"
cols={landscapeCols}
rows={Math.ceil(landscapeProblemsPerPage / landscapeCols)}
onClick={() => handleOrientationChange('landscape')}
isSelected={orientation === 'landscape'}
maxSize={80}
/>
)
})()}
</div>
</div>
</div>
{/* Pages */}
<div
className={css({
flexShrink: 0,
'@media (max-width: 444px)': {
width: '100%',
},
})}
>
<div>
<div
className={css({
fontSize: '2xs',
@@ -239,23 +313,26 @@ export function OrientationPanel({
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '1.5',
'@container (min-width: 400px)': {
mb: '2',
},
})}
>
Pages
</div>
<div
ref={containerRef}
className={css({
display: 'flex',
gap: '1',
alignItems: 'center',
'@media (max-width: 444px)': {
width: '100%',
gap: '0.5',
},
width: '100%',
flexWrap: 'wrap',
justifyContent: 'space-between',
})}
>
{/* Quick select buttons for 1-3 pages */}
{[1, 2, 3].map((pageCount) => {
{/* Quick select buttons - dynamically shown based on container width */}
{visibleButtons.map((pageCount) => {
const isSelected = pages === pageCount
const validation = getValidationForPageCount(pageCount)
const risk = getRiskLevel(validation)
@@ -336,17 +413,11 @@ export function OrientationPanel({
? 'yellow.400'
: 'brand.400',
},
'@media (max-width: 444px)': {
w: '6',
h: '6',
'@container (max-width: 280px)': {
w: '7',
h: '7',
fontSize: '2xs',
},
'@media (max-width: 300px)': {
w: '5',
h: '5',
fontSize: '2xs',
borderWidth: '1px',
},
})}
>
{pageCount}
@@ -408,15 +479,165 @@ export function OrientationPanel({
return button
})}
{/* Dropdown for 4+ pages */}
<DropdownMenu.Root open={dropdownOpen} onOpenChange={setDropdownOpen}>
{/* Dropdown for remaining options (only if more than one option) */}
{dropdownOptions.length === 1 ? (
// Render single dropdown option as a button
(() => {
const pageCount = dropdownOptions[0]
const isSelected = pages === pageCount
const validation = getValidationForPageCount(pageCount)
const risk = getRiskLevel(validation)
const tooltipMessage = getTooltipMessage(validation)
const button = (
<button
key={pageCount}
type="button"
data-action={`select-pages-${pageCount}`}
onClick={() => onPagesChange(pageCount)}
className={css({
w: '8',
h: '8',
border: '2px solid',
borderColor: isSelected
? risk === 'danger'
? 'red.500'
: risk === 'caution'
? 'yellow.500'
: 'brand.500'
: risk === 'danger'
? isDark
? 'red.700'
: 'red.300'
: risk === 'caution'
? isDark
? 'yellow.700'
: 'yellow.300'
: isDark
? 'gray.600'
: 'gray.300',
bg: isSelected
? isDark
? risk === 'danger'
? 'red.900'
: risk === 'caution'
? 'yellow.900'
: 'brand.900'
: risk === 'danger'
? 'red.50'
: risk === 'caution'
? 'yellow.50'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
rounded: 'lg',
cursor: 'pointer',
fontSize: 'xs',
fontWeight: 'bold',
color: isSelected
? isDark
? risk === 'danger'
? 'red.200'
: risk === 'caution'
? 'yellow.200'
: 'brand.200'
: risk === 'danger'
? 'red.700'
: risk === 'caution'
? 'yellow.700'
: 'brand.700'
: isDark
? 'gray.300'
: 'gray.600',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
position: 'relative',
_hover: {
borderColor:
risk === 'danger'
? 'red.400'
: risk === 'caution'
? 'yellow.400'
: 'brand.400',
},
'@container (max-width: 280px)': {
w: '7',
h: '7',
fontSize: '2xs',
},
})}
>
{pageCount}
{/* Warning indicator dot */}
{risk !== 'none' && (
<span
className={css({
position: 'absolute',
top: '-1',
right: '-1',
w: '2',
h: '2',
bg: risk === 'danger' ? 'red.500' : 'yellow.500',
rounded: 'full',
})}
/>
)}
</button>
)
// Wrap in tooltip if there's a warning message
if (tooltipMessage) {
return (
<Tooltip.Provider key={pageCount}>
<Tooltip.Root delayDuration={0} disableHoverableContent={true}>
<Tooltip.Trigger asChild>{button}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className={css({
bg: isDark ? 'gray.800' : 'white',
color: isDark ? 'gray.100' : 'gray.900',
px: '3',
py: '2',
rounded: 'lg',
shadow: 'lg',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
maxW: '64',
fontSize: 'xs',
lineHeight: '1.5',
whiteSpace: 'pre-wrap',
zIndex: 10000,
})}
sideOffset={5}
>
{tooltipMessage}
<Tooltip.Arrow
className={css({
fill: isDark ? 'gray.800' : 'white',
})}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
}
return button
})()
) : dropdownOptions.length > 1 ? (
<DropdownMenu.Root open={dropdownOpen} onOpenChange={setDropdownOpen}>
<Tooltip.Provider>
<Tooltip.Root
delayDuration={0}
disableHoverableContent={true}
open={
!dropdownOpen &&
pages > 3 &&
dropdownOptions.includes(pages) &&
getTooltipMessage(getValidationForPageCount(pages)) !== null
? undefined
: false
@@ -432,27 +653,29 @@ export function OrientationPanel({
h: '8',
px: '2',
border: '2px solid',
borderColor: pages > 3 ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
bg:
pages > 3
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
borderColor: dropdownOptions.includes(pages)
? 'brand.500'
: isDark
? 'gray.600'
: 'gray.300',
bg: dropdownOptions.includes(pages)
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
rounded: 'lg',
cursor: 'pointer',
fontSize: 'xs',
fontWeight: 'bold',
color:
pages > 3
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.300'
: 'gray.600',
color: dropdownOptions.includes(pages)
? isDark
? 'brand.200'
: 'brand.700'
: isDark
? 'gray.300'
: 'gray.600',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
@@ -463,20 +686,18 @@ export function OrientationPanel({
_hover: {
borderColor: 'brand.400',
},
'@media (max-width: 444px)': {
minW: '6',
h: '6',
'@container (max-width: 280px)': {
minW: '7',
h: '7',
fontSize: '2xs',
},
'@media (max-width: 300px)': {
minW: '5',
h: '5',
fontSize: '2xs',
borderWidth: '1px',
},
})}
>
{pages > 3 ? pages : '4+'}
{dropdownOptions.includes(pages)
? pages
: dropdownOptions.length > 0
? `${dropdownOptions[0]}+`
: '•••'}
<span className={css({ fontSize: '2xs', opacity: 0.7 })}></span>
{/* Warning indicator dot - shows mildest warning from all dropdown items */}
{getDropdownMildestWarning() !== 'none' && (
@@ -498,7 +719,8 @@ export function OrientationPanel({
</button>
</DropdownMenu.Trigger>
</Tooltip.Trigger>
{pages > 3 && getTooltipMessage(getValidationForPageCount(pages)) && (
{dropdownOptions.includes(pages) &&
getTooltipMessage(getValidationForPageCount(pages)) && (
<Tooltip.Portal>
<Tooltip.Content
className={css({
@@ -544,7 +766,7 @@ export function OrientationPanel({
})}
sideOffset={5}
>
{[4, 10, 25, 50, 100].map((pageCount) => {
{dropdownOptions.map((pageCount) => {
const isSelected = pages === pageCount
const validation = getValidationForPageCount(pageCount)
const risk = getRiskLevel(validation)
@@ -668,42 +890,30 @@ export function OrientationPanel({
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
) : null}
</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',
},
})}
>
{/* Problems per page dropdown */}
<div>
<div
className={css({
flex: '1',
minWidth: 0,
fontSize: '2xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: 'wider',
display: 'block',
mb: '1.5',
'@container (min-width: 400px)': {
mb: '2',
},
})}
>
<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>
Problems per Page
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
@@ -730,10 +940,6 @@ export function OrientationPanel({
_hover: {
borderColor: 'brand.400',
},
'@media (max-width: 200px)': {
px: '1',
fontSize: '2xs',
},
})}
>
<span
@@ -744,26 +950,8 @@ export function OrientationPanel({
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>
{problemsPerPage} problems ({cols} cols × {Math.ceil(problemsPerPage / cols)}{' '}
rows)
</span>
<span
className={css({
@@ -915,56 +1103,17 @@ export function OrientationPanel({
</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 */}
{/* Layout Options */}
<div
className={css({
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
pt: '3',
mt: '1',
'@container (min-width: 400px)': {
pt: '4',
},
})}
>
<div
@@ -975,12 +1124,24 @@ export function OrientationPanel({
textTransform: 'uppercase',
letterSpacing: 'wider',
mb: '2',
'@container (min-width: 400px)': {
mb: '3',
},
})}
>
Layout Options
</div>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2' })}>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2',
'@container (min-width: 400px)': {
gap: '3',
},
})}
>
{/* Problem Numbers Toggle */}
<label
className={css({

View File

@@ -143,8 +143,8 @@ function PreviewContent({
formState.operator,
// V4: Mode and conditional display settings
formState.mode,
formState.displayRules, // Smart mode: conditional scaffolding
formState.difficultyProfile, // Smart mode: difficulty preset
formState.displayRules, // Custom mode: conditional scaffolding
formState.difficultyProfile, // Custom mode: difficulty preset
formState.manualPreset, // Manual mode: manual preset
// Mastery mode: skill IDs (CRITICAL for mastery+mixed mode)
formState.currentAdditionSkillId,

View File

@@ -32,12 +32,12 @@ import { DifficultyPresetDropdown } from './DifficultyPresetDropdown'
import { MakeEasierHarderButtons } from './MakeEasierHarderButtons'
import { OverallDifficultySlider } from './OverallDifficultySlider'
export interface SmartModeControlsProps {
export interface CustomModeControlsProps {
formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void
}
export function SmartModeControls({ formState, onChange }: SmartModeControlsProps) {
export function CustomModeControls({ formState, onChange }: CustomModeControlsProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [showDebugPlot, setShowDebugPlot] = useState(false)

View File

@@ -2,20 +2,20 @@
import * as Slider from '@radix-ui/react-slider'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import {
DIFFICULTY_PROFILES,
DIFFICULTY_PROGRESSION,
calculateOverallDifficulty,
calculateRegroupingIntensity,
calculateScaffoldingLevel,
REGROUPING_PROGRESSION,
SCAFFOLDING_PROGRESSION,
DIFFICULTY_PROFILES,
DIFFICULTY_PROGRESSION,
type DifficultyLevel,
findNearestValidState,
getProfileFromConfig,
type DifficultyLevel,
REGROUPING_PROGRESSION,
SCAFFOLDING_PROGRESSION,
} from '../../difficultyProfiles'
import type { DisplayRules } from '../../displayRules'
import { useTheme } from '@/contexts/ThemeContext'
export interface OverallDifficultySliderProps {
currentDifficulty: number
@@ -162,13 +162,14 @@ export function OverallDifficultySlider({
mb: '1.5',
})}
>
Overall Difficulty: {currentDifficulty.toFixed(1)} / 10
Overall Difficulty: {Number.isNaN(currentDifficulty) ? '0.0' : currentDifficulty.toFixed(1)}{' '}
/ 10
</div>
{/* Difficulty Slider */}
<div className={css({ position: 'relative', px: '2' })}>
<Slider.Root
value={[currentDifficulty * 10]}
value={[Number.isNaN(currentDifficulty) ? 0 : currentDifficulty * 10]}
max={100}
step={1}
onValueChange={handleValueChange}

View File

@@ -22,7 +22,7 @@ const RULE_OPTIONS: Array<{ value: RuleMode; label: string; short: string }> = [
{ value: 'auto', label: 'Auto (Use Mastery Progression)', short: 'Auto' },
{ value: 'always', label: 'Always', short: 'Always' },
{ value: 'whenRegrouping', label: 'When Regrouping', short: 'Regroup' },
{ value: 'whenMultipleRegroups', label: 'Multiple Regroups', short: '2+' },
{ value: 'whenMultipleRegroups', label: 'Multiple Regroups', short: '2+ reg' },
{ value: 'when3PlusDigits', label: '3+ Digits', short: '3+ dig' },
{ value: 'never', label: 'Never', short: 'Never' },
]

View File

@@ -17,13 +17,13 @@ export function ContentTab() {
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'
const mode = formState.mode ?? 'custom'
if (
operator === 'mixed' &&
mode === 'mastery' &&
(!formState.currentAdditionSkillId || !formState.currentSubtractionSkillId)
) {
onChange({ operator, mode: 'smart' })
onChange({ operator, mode: 'custom' })
} else {
onChange({ operator })
}

View File

@@ -6,7 +6,7 @@ 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 { CustomModeControls } from '../config-panel/CustomModeControls'
import { DifficultyMethodSelector } from '../DifficultyMethodSelector'
import { useWorksheetConfig } from '../WorksheetConfigContext'
@@ -15,19 +15,19 @@ export function DifficultyTab() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'smart'
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'custom'
// Handler for difficulty method switching (smart vs mastery)
const handleMethodChange = (newMethod: 'smart' | 'mastery') => {
const handleMethodChange = (newMethod: 'custom' | 'mastery') => {
if (currentMethod === newMethod) {
return
}
const displayRules = formState.displayRules ?? defaultAdditionConfig.displayRules
if (newMethod === 'smart') {
if (newMethod === 'custom') {
onChange({
mode: 'smart',
mode: 'custom',
displayRules,
difficultyProfile: 'earlyLearner',
} as unknown as Partial<WorksheetFormState>)
@@ -56,7 +56,9 @@ export function DifficultyTab() {
/>
{/* Method-specific controls */}
{currentMethod === 'smart' && <SmartModeControls formState={formState} onChange={onChange} />}
{currentMethod === 'custom' && (
<CustomModeControls formState={formState} onChange={onChange} />
)}
{currentMethod === 'mastery' && (
<MasteryModePanel formState={formState} onChange={onChange} isDark={isDark} />

View File

@@ -10,6 +10,7 @@ interface LayoutPreviewProps {
className?: string
onClick?: () => void
isSelected?: boolean
maxSize?: number
}
/**
@@ -23,6 +24,7 @@ export function LayoutPreview({
className,
onClick,
isSelected = false,
maxSize = 48,
}: LayoutPreviewProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -31,7 +33,6 @@ export function LayoutPreview({
const pageAspect = orientation === 'portrait' ? 8.5 / 11 : 11 / 8.5
// Scale to fit in button (max dimensions)
const maxSize = 48
let pageWidth: number
let pageHeight: number
@@ -51,15 +52,16 @@ export function LayoutPreview({
const svgContent = (
<svg
width={pageWidth}
height={pageHeight}
viewBox={`0 0 ${pageWidth} ${pageHeight}`}
className={css({
rounded: 'sm',
width: '100%',
height: '100%',
})}
style={{
backgroundColor: isDark ? '#1f2937' : 'white',
}}
preserveAspectRatio="xMidYMid meet"
>
{/* Problem grid */}
{Array.from({ length: rows }).map((_, rowIdx) =>
@@ -80,6 +82,8 @@ export function LayoutPreview({
// If used as a button, wrap in button element with styling
if (onClick) {
const aspectRatio = orientation === 'portrait' ? 8.5 / 11 : 11 / 8.5
return (
<button
type="button"
@@ -95,10 +99,15 @@ export function LayoutPreview({
cursor: 'pointer',
transition: 'all 0.15s',
p: '2',
width: '100%',
maxWidth: '32',
_hover: {
borderColor: 'brand.400',
},
})}
style={{
aspectRatio: aspectRatio.toString(),
}}
>
{svgContent}
</button>
@@ -106,14 +115,23 @@ export function LayoutPreview({
}
// Otherwise just return the SVG with border
const aspectRatio = orientation === 'portrait' ? 8.5 / 11 : 11 / 8.5
return (
<div
className={css({
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
rounded: 'sm',
display: 'inline-block',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
p: '1',
})}
style={{
width: `${maxSize}px`,
aspectRatio: aspectRatio.toString(),
}}
>
{svgContent}
</div>

View File

@@ -69,7 +69,7 @@ export function LayoutTab() {
digitRange={formState.digitRange || { min: 2, max: 2 }}
pAnyStart={formState.pAnyStart ?? 0}
operator={formState.operator || 'addition'}
mode={formState.mode || 'smart'}
mode={formState.mode || 'custom'}
problemNumbers={
((formState.displayRules ?? defaultAdditionConfig.displayRules).problemNumbers as
| 'always'

View File

@@ -247,6 +247,7 @@ export function TabNavigation({
orientation={orientation!}
cols={cols!}
rows={Math.ceil(problemsPerPage! / cols!)}
maxSize={32}
/>
) : showScaffoldingPreview ? (
<ProblemPreview
@@ -299,6 +300,27 @@ export function TabNavigation({
{subtitle}
</span>
) : null}
{/* Total badge for layout tab */}
{tab.id === 'layout' && problemsPerPage && pages ? (
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '1',
px: '2',
py: '0.5',
bg: activeTab === tab.id ? 'brand.500' : isDark ? 'gray.600' : 'gray.400',
color: 'white',
rounded: 'full',
fontSize: '2xs',
fontWeight: 'semibold',
mt: '0.5',
})}
>
<span>Total:</span>
<span>{problemsPerPage * pages}</span>
</span>
) : null}
</div>
</div>
</button>

View File

@@ -44,7 +44,7 @@ export function WorksheetPreviewProvider({ formState, children }: WorksheetPrevi
const problemsPerPage = formState.problemsPerPage ?? 20
const pages = formState.pages ?? 1
const operator = formState.operator ?? 'addition'
const mode = formState.mode ?? 'smart'
const mode = formState.mode ?? 'custom'
// Reset dismissed state when config changes
setIsDismissed(false)

View File

@@ -83,7 +83,7 @@ export type AdditionConfigV1 = z.infer<typeof additionConfigV1Schema>
/**
* Addition worksheet config - Version 2
* Smart difficulty system with conditional display rules
* Custom difficulty system with conditional display rules
*/
export const additionConfigV2Schema = z.object({
version: z.literal(2),
@@ -176,7 +176,7 @@ export type AdditionConfigV2 = z.infer<typeof additionConfigV2Schema>
/**
* Addition worksheet config - Version 3
* Two-mode system: Smart Difficulty vs Manual Control
* Two-mode system: Custom Difficulty vs Manual Control
*/
// Shared base fields for both modes
@@ -205,9 +205,9 @@ const additionConfigV3BaseSchema = z.object({
prngAlgorithm: z.string().optional(),
})
// Smart Difficulty Mode
const additionConfigV3SmartSchema = additionConfigV3BaseSchema.extend({
mode: z.literal('smart'),
// Custom Difficulty Mode
const additionConfigV3CustomSchema = additionConfigV3BaseSchema.extend({
mode: z.literal('custom'),
// Conditional display rules
displayRules: z.object({
@@ -269,10 +269,10 @@ const additionConfigV3SmartSchema = additionConfigV3BaseSchema.extend({
]),
}),
// Optional: Which smart difficulty profile is selected
// Optional: Which custom difficulty profile is selected
difficultyProfile: z.string().optional(),
// showTenFramesForAll is deprecated in V3 smart mode
// showTenFramesForAll is deprecated in V3 custom mode
// (controlled by displayRules.tenFrames)
})
@@ -295,12 +295,12 @@ const additionConfigV3ManualSchema = additionConfigV3BaseSchema.extend({
// V3 uses discriminated union on 'mode'
export const additionConfigV3Schema = z.discriminatedUnion('mode', [
additionConfigV3SmartSchema,
additionConfigV3CustomSchema,
additionConfigV3ManualSchema,
])
export type AdditionConfigV3 = z.infer<typeof additionConfigV3Schema>
export type AdditionConfigV3Smart = z.infer<typeof additionConfigV3SmartSchema>
export type AdditionConfigV3Custom = z.infer<typeof additionConfigV3CustomSchema>
export type AdditionConfigV3Manual = z.infer<typeof additionConfigV3ManualSchema>
/**
@@ -355,11 +355,11 @@ const additionConfigV4BaseSchema = z.object({
prngAlgorithm: z.string().optional(),
})
// Smart Difficulty Mode for V4
const additionConfigV4SmartSchema = additionConfigV4BaseSchema.extend({
mode: z.literal('smart'),
// Custom Difficulty Mode for V4
const additionConfigV4CustomSchema = additionConfigV4BaseSchema.extend({
mode: z.literal('custom'),
// Conditional display rules (with 'auto' for deferring to smart difficulty)
// Conditional display rules (with 'auto' for deferring to custom difficulty)
displayRules: z.object({
carryBoxes: z.enum(displayRuleValues),
answerBoxes: z.enum(displayRuleValues),
@@ -371,16 +371,16 @@ const additionConfigV4SmartSchema = additionConfigV4BaseSchema.extend({
borrowingHints: z.enum(displayRuleValues),
}),
// Optional: Which smart difficulty profile is selected
// Optional: Which custom difficulty profile is selected
difficultyProfile: z.string().optional(),
})
// Manual Control Mode for V4
// Now uses displayRules like Smart/Mastery modes for 1:1 correspondence
// Now uses displayRules like Custom/Mastery modes for 1:1 correspondence
const additionConfigV4ManualSchema = additionConfigV4BaseSchema.extend({
mode: z.literal('manual'),
// Manual mode now uses conditional display rules (same as Smart/Mastery)
// Manual mode now uses conditional display rules (same as Custom/Mastery)
// 'auto' is available but typically not used in manual mode
displayRules: z.object({
carryBoxes: z.enum(displayRuleValues),
@@ -452,13 +452,13 @@ const additionConfigV4MasterySchema = additionConfigV4BaseSchema.extend({
// V4 uses discriminated union on 'mode'
export const additionConfigV4Schema = z.discriminatedUnion('mode', [
additionConfigV4SmartSchema,
additionConfigV4CustomSchema,
additionConfigV4ManualSchema,
additionConfigV4MasterySchema,
])
export type AdditionConfigV4 = z.infer<typeof additionConfigV4Schema>
export type AdditionConfigV4Smart = z.infer<typeof additionConfigV4SmartSchema>
export type AdditionConfigV4Custom = z.infer<typeof additionConfigV4CustomSchema>
export type AdditionConfigV4Manual = z.infer<typeof additionConfigV4ManualSchema>
export type AdditionConfigV4Mastery = z.infer<typeof additionConfigV4MasterySchema>
@@ -473,11 +473,11 @@ export const additionConfigSchema = z.discriminatedUnion('version', [
export type AdditionConfig = z.infer<typeof additionConfigSchema>
/**
* Default addition config (always latest version - V4 Smart Mode)
* Default addition config (always latest version - V4 Custom Mode)
*/
export const defaultAdditionConfig: AdditionConfigV4Smart = {
export const defaultAdditionConfig: AdditionConfigV4Custom = {
version: 4,
mode: 'smart',
mode: 'custom',
problemsPerPage: 20,
cols: 5,
pages: 1,
@@ -545,11 +545,11 @@ function migrateAdditionV1toV2(v1: AdditionConfigV1): AdditionConfigV2 {
* Determines mode based on whether difficultyProfile is set
*/
function migrateAdditionV2toV3(v2: AdditionConfigV2): AdditionConfigV3 {
// If user has a difficultyProfile set, they're using smart mode
// If user has a difficultyProfile set, they're using custom mode
if (v2.difficultyProfile) {
return {
version: 3,
mode: 'smart',
mode: 'custom',
problemsPerPage: v2.problemsPerPage,
cols: v2.cols,
pages: v2.pages,
@@ -618,10 +618,10 @@ function migrateAdditionV3toV4(v3: AdditionConfigV3): AdditionConfigV4 {
prngAlgorithm: v3.prngAlgorithm,
}
if (v3.mode === 'smart') {
if (v3.mode === 'custom') {
return {
...baseFields,
mode: 'smart',
mode: 'custom',
displayRules: v3.displayRules,
difficultyProfile: v3.difficultyProfile,
}

View File

@@ -550,6 +550,7 @@ export function calculateScaffoldingLevel(
regroupingIntensity?: number
): number {
const ruleScores: Record<string, number> = {
auto: 8, // Default to 'whenRegrouping' level for auto mode
always: 10, // Maximum scaffolding (easiest)
whenRegrouping: 8,
whenMultipleRegroups: 5,
@@ -632,8 +633,9 @@ export function calculateOverallDifficulty(
result: progress * 10,
})
// Scale to 0-10 range
return progress * 10
// Scale to 0-10 range and ensure no NaN
const result = progress * 10
return Number.isNaN(result) ? 0 : result
}
/**

View File

@@ -77,7 +77,7 @@ export function generateWorksheetPreview(
}
// Generate all problems for full preview based on operator
const mode = config.mode ?? 'smart'
const mode = config.mode ?? 'custom'
console.log(
`[PREVIEW] Step 2: Generating ${validatedConfig.total} problems (mode: ${mode}, operator: ${operator})...`
@@ -275,7 +275,7 @@ export function generateSinglePage(
// Generate all problems (need full set to know which problems go on which page)
// This is unavoidable because problems are distributed across pages
const operator = validatedConfig.operator ?? 'addition'
const mode = config.mode ?? 'smart'
const mode = config.mode ?? 'custom'
let problems

View File

@@ -160,7 +160,7 @@ export function calculateMasteryMix(
*
* This is the bridge between mastery mode and smart mode.
* Each skill's configuration (digitRange, regrouping, scaffolding) maps directly
* to a Smart Mode configuration.
* to a Custom Mode configuration.
*
* @param skill - Skill definition
* @param problemCount - Number of problems to generate for this skill
@@ -172,7 +172,7 @@ export function skillToConfig(
): Partial<WorksheetConfig> {
return {
version: 4,
mode: 'smart',
mode: 'custom',
// Digit range from skill
digitRange: skill.digitRange,

View File

@@ -2,7 +2,7 @@
import type {
AdditionConfigV4,
AdditionConfigV4Smart,
AdditionConfigV4Custom,
AdditionConfigV4Manual,
AdditionConfigV4Mastery,
} from '@/app/create/worksheets/config-schemas'
@@ -12,7 +12,7 @@ import type {
* 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
* - Custom 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
@@ -47,12 +47,12 @@ export type WorksheetConfig = AdditionConfigV4 & {
* Based on V4 config with additional derived state
*
* V4 supports three modes via discriminated union:
* - Smart mode: Has displayRules and optional difficultyProfile
* - Custom 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.
* If mode is absent, defaults to 'custom' mode.
*
* This type is intentionally permissive during form editing to allow fields from
* all modes to exist temporarily. Validation will enforce mode consistency.
@@ -72,7 +72,7 @@ export type WorksheetConfig = AdditionConfigV4 & {
*
* See `.claude/WORKSHEET_CONFIG_PERSISTENCE.md` for full architecture.
*/
export type WorksheetFormState = Partial<Omit<AdditionConfigV4Smart, 'version'>> &
export type WorksheetFormState = Partial<Omit<AdditionConfigV4Custom, 'version'>> &
Partial<Omit<AdditionConfigV4Manual, 'version'>> &
Partial<Omit<AdditionConfigV4Mastery, 'version'>> & {
// ========================================

View File

@@ -58,8 +58,8 @@ function generatePageTypst(
// Enrich problems with display options based on mode
const enrichedProblems = pageProblems.map((p, index) => {
if (config.mode === 'smart' || config.mode === 'mastery') {
// Smart & Mastery modes: Per-problem conditional display based on problem complexity
if (config.mode === 'custom' || config.mode === 'mastery') {
// Custom & Mastery modes: Per-problem conditional display based on problem complexity
// Both modes use displayRules for conditional scaffolding
const meta =
p.operator === 'add'
@@ -103,7 +103,7 @@ function generatePageTypst(
...displayOptions, // Now includes showBorrowNotation and showBorrowingHints from resolved rules
}
} else {
// Manual mode: Per-problem conditional display using displayRules (same as Smart/Mastery)
// Manual mode: Per-problem conditional display using displayRules (same as Custom/Mastery)
const meta =
p.operator === 'add'
? analyzeProblem(p.a, p.b)

View File

@@ -71,11 +71,12 @@ export function generateBorrowBoxesRow(cellDimensions: CellDimensions): string {
]
)
// Draw curved line using Typst bezier with control point
// Note: path() is deprecated but curve() has different API - needs investigation
#place(
top + left,
dx: ${arrowStartDx}in,
dy: ${arrowStartDy}in,
curve(
path(
stroke: (paint: gray.darken(30%), thickness: ${TYPST_CONSTANTS.ARROW_STROKE_WIDTH}pt),
// Start vertex (near the "1" in borrow box)
(0pt, 0pt),

View File

@@ -85,13 +85,13 @@ export function generateSettingsSummary(config: Partial<WorksheetFormState>): {
if (config.mode) {
const diffIcon = SETTING_ICONS.difficulty[config.mode as keyof typeof SETTING_ICONS.difficulty]
const modeName =
config.mode === 'smart'
? 'Smart difficulty'
config.mode === 'custom'
? 'Custom difficulty'
: config.mode === 'mastery'
? 'Mastery mode'
: 'Manual mode'
const pStart =
config.mode === 'smart' && config.pAnyStart != null
config.mode === 'custom' && config.pAnyStart != null
? `${Math.round(config.pAnyStart * 100)}% starts`
: ''
lines.push(`${diffIcon} ${modeName}${pStart}`)

View File

@@ -184,8 +184,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
// Determine orientation based on columns (portrait = 2-3 cols, landscape = 4-5 cols)
const orientation = formState.orientation || (cols <= 3 ? 'portrait' : 'landscape')
// Determine mode (default to 'smart' if not specified)
const mode: 'smart' | 'manual' | 'mastery' = formState.mode ?? 'smart'
// Determine mode (default to 'custom' if not specified)
const mode: 'custom' | 'manual' | 'mastery' = formState.mode ?? 'custom'
// Shared fields for both modes
const sharedFields = {
@@ -204,7 +204,7 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
date: formState.date?.trim() || getDefaultDate(),
pAnyStart,
pAllStart,
// Default interpolate based on mode: true for smart/manual, false for mastery
// Default interpolate based on mode: true for custom/manual, false for mastery
interpolate:
formState.interpolate !== undefined
? formState.interpolate
@@ -238,8 +238,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
// Build mode-specific config
let config: WorksheetConfig
if (mode === 'smart' || mode === 'mastery') {
// Smart & Mastery modes: Use displayRules for conditional scaffolding
if (mode === 'custom' || mode === 'mastery') {
// Custom & Mastery modes: Use displayRules for conditional scaffolding
// Default display rules
let baseDisplayRules: DisplayRules = {
@@ -295,7 +295,7 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
? mergeDisplayRulesWithAuto(baseDisplayRules, userDisplayRules)
: {
...baseDisplayRules,
...userDisplayRules, // Smart mode: direct override (no "auto" resolution)
...userDisplayRules, // Custom mode: direct override (no "auto" resolution)
}
console.log('[MASTERY MODE] Display rules resolved:', {
@@ -309,7 +309,7 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
const operator = formState.operator ?? 'addition'
const baseConfig = {
version: 4,
mode: mode as 'smart' | 'mastery', // Preserve the actual mode
mode: mode as 'custom' | 'mastery', // Preserve the actual mode
displayRules,
difficultyProfile: formState.difficultyProfile,
currentStepId: formState.currentStepId, // Mastery progression tracking
@@ -376,7 +376,7 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
config = baseConfig as any
}
} else {
// Manual mode: Use displayRules (same as Smart/Mastery)
// Manual mode: Use displayRules (same as Custom/Mastery)
const displayRules: DisplayRules = formState.displayRules ?? {
carryBoxes: 'always',
answerBoxes: 'always',