feat: enhance scaffolding tab with live preview and resolved display rules

Add live problem preview to scaffolding tab:
- Show actual worksheet problem as icon instead of emoji
- Preview reflects current digit range and scaffolding settings
- Resolve 'auto' display rules to mastery skill recommendations
- Preview updates in real-time when settings change

Improve preview UX:
- Add theme-aware border and shadow (light: gray border + drop shadow, dark: gray border + glow)
- Clip SVG edges to remove white background showing through
- Keep previous preview visible during updates (no flash)
- Show invisible placeholder on first load

Update scaffolding subtitle:
- Split into "Always" and "Conditional" categories
- Show "None" when all scaffolding is disabled
- Hide empty categories

🤖 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 09:41:55 -06:00
parent b41d47b936
commit 9a5a0d4e3c
3 changed files with 441 additions and 12 deletions

View File

@@ -3,6 +3,8 @@
import { css } from '@styled/css'
import { useEffect, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { DisplayRules } from '../displayRules'
import { getSkillById } from '../skills'
import { StudentNameInput } from './config-panel/StudentNameInput'
import { ContentTab } from './config-sidebar/ContentTab'
import { DifficultyTab } from './config-sidebar/DifficultyTab'
@@ -29,6 +31,21 @@ export function ConfigSidebar({
const { formState, onChange, isReadOnly: contextReadOnly } = useWorksheetConfig()
const effectiveReadOnly = isReadOnly || contextReadOnly
// Get resolved display rules for showing what 'auto' defers to (mastery mode only)
let resolvedDisplayRules: DisplayRules | undefined
if (formState.mode === 'mastery') {
const operator = formState.operator ?? 'addition'
const skillId =
operator === 'addition' || operator === 'mixed'
? formState.currentAdditionSkillId
: formState.currentSubtractionSkillId
if (skillId) {
const skill = getSkillById(skillId as any)
resolvedDisplayRules = skill?.recommendedScaffolding
}
}
// Always initialize with default to avoid hydration mismatch
const [activeTab, setActiveTab] = useState<string>('operator')
const [isInitialized, setIsInitialized] = useState(false)
@@ -119,6 +136,15 @@ export function ConfigSidebar({
activeTab={activeTab}
onChange={setActiveTab}
operator={formState.operator}
mode={formState.mode as 'custom' | 'manual' | 'mastery' | undefined}
difficultyProfile={formState.difficultyProfile}
interpolate={formState.interpolate}
orientation={formState.orientation}
problemsPerPage={formState.problemsPerPage}
cols={formState.cols}
displayRules={formState.displayRules}
resolvedDisplayRules={resolvedDisplayRules}
digitRange={formState.digitRange}
/>
</div>

View File

@@ -0,0 +1,238 @@
'use client'
import { css } from '@styled/css'
import { useEffect, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { DisplayRules } from '../../displayRules'
interface ProblemPreviewProps {
displayRules: DisplayRules
resolvedDisplayRules?: DisplayRules
operator?: 'addition' | 'subtraction' | 'mixed'
digitRange?: { min: number; max: number }
className?: string
}
interface SVGDimensions {
width: number
height: number
}
/**
* Choose demonstration problems based on digit range
* Problems are selected to show conditional regrouping:
* - Ones place regroups/borrows
* - Higher places don't (to demonstrate conditional scaffolding)
*/
function getExampleProblems(digitCount: number, operator: string) {
if (operator === 'subtraction') {
// Subtraction: ones borrow, higher places don't
switch (digitCount) {
case 1:
return { minuend: 12, subtrahend: 7 } // 12 - 7 = 5 (borrow)
case 2:
return { minuend: 52, subtrahend: 17 } // 52 - 17 = 35 (ones borrow, tens don't)
case 3:
return { minuend: 352, subtrahend: 117 } // 352 - 117 = 235 (ones borrow, tens/hundreds don't)
case 4:
return { minuend: 2352, subtrahend: 1117 } // ones borrow only
case 5:
return { minuend: 12352, subtrahend: 11117 } // ones borrow only
case 6:
return { minuend: 112352, subtrahend: 111117 } // ones borrow only
default:
return { minuend: 52, subtrahend: 17 } // Default to 2-digit
}
} else {
// Addition: ones regroup, higher places don't
switch (digitCount) {
case 1:
return { addend1: 7, addend2: 8 } // 7 + 8 = 15 (regroup)
case 2:
return { addend1: 27, addend2: 14 } // 27 + 14 = 41 (ones regroup: 7+4=11, tens don't)
case 3:
return { addend1: 127, addend2: 234 } // 127 + 234 = 361 (ones regroup: 7+4=11, tens/hundreds don't)
case 4:
return { addend1: 1027, addend2: 2034 } // ones regroup only
case 5:
return { addend1: 10027, addend2: 20034 } // ones regroup only
case 6:
return { addend1: 100027, addend2: 200034 } // ones regroup only
default:
return { addend1: 27, addend2: 14 } // Default to 2-digit
}
}
}
/**
* Extract actual dimensions from SVG string
*/
function getSVGDimensions(svgString: string): SVGDimensions {
const parser = new DOMParser()
const doc = parser.parseFromString(svgString, 'image/svg+xml')
const svg = doc.querySelector('svg')
if (!svg) {
return { width: 60, height: 50 } // Fallback
}
// Try to get dimensions from width/height attributes
const widthAttr = svg.getAttribute('width')
const heightAttr = svg.getAttribute('height')
if (widthAttr && heightAttr) {
// Parse values like "123.45pt" or "123.45"
const width = parseFloat(widthAttr)
const height = parseFloat(heightAttr)
if (!Number.isNaN(width) && !Number.isNaN(height)) {
return { width, height }
}
}
// Try to get dimensions from viewBox
const viewBox = svg.getAttribute('viewBox')
if (viewBox) {
const [, , width, height] = viewBox.split(' ').map(parseFloat)
if (!Number.isNaN(width) && !Number.isNaN(height)) {
return { width, height }
}
}
return { width: 60, height: 50 } // Fallback
}
/**
* Compact problem preview showing current scaffolding settings
* Fetches a single-problem SVG from the example API and scales it to fit
*/
export function ProblemPreview({
displayRules,
resolvedDisplayRules,
operator = 'addition',
digitRange,
className,
}: ProblemPreviewProps) {
const [svg, setSvg] = useState<string | null>(null)
const [dimensions, setDimensions] = useState<SVGDimensions>({ width: 60, height: 50 })
const [isLoading, setIsLoading] = useState(false)
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
useEffect(() => {
const fetchPreview = async () => {
setIsLoading(true)
try {
// Use first operator for mixed mode
const effectiveOperator = operator === 'mixed' ? 'addition' : operator
// Use max digit count from range, or default to 2
const digitCount = digitRange?.max ?? 2
// Get appropriate example problems for this digit count
const problems = getExampleProblems(digitCount, effectiveOperator)
// Resolve 'auto' to actual value if we have resolved rules
const getResolvedValue = (key: keyof DisplayRules) => {
const value = displayRules[key]
if (value === 'auto' && resolvedDisplayRules) {
return resolvedDisplayRules[key]
}
return value
}
// Get resolved values for all rules
const carryBoxes = getResolvedValue('carryBoxes')
const answerBoxes = getResolvedValue('answerBoxes')
const placeValueColors = getResolvedValue('placeValueColors')
const tenFrames = getResolvedValue('tenFrames')
const borrowNotation = getResolvedValue('borrowNotation')
const borrowingHints = getResolvedValue('borrowingHints')
const response = await fetch('/api/create/worksheets/addition/example', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
showCarryBoxes: carryBoxes !== 'never',
showAnswerBoxes: answerBoxes !== 'never',
showPlaceValueColors: placeValueColors !== 'never',
showProblemNumbers: false, // Don't show problem numbers in preview
showCellBorder: false, // Keep preview clean
showTenFrames: tenFrames !== 'never',
// Only force show-all for 'always' - others show conditionally based on problem
showTenFramesForAll: tenFrames === 'always',
showBorrowNotation: borrowNotation !== 'never',
showBorrowingHints: borrowingHints !== 'never',
fontSize: 12, // Smaller font for compact preview
operator: effectiveOperator,
// Use problems that demonstrate conditional regrouping
...problems,
}),
})
if (response.ok) {
const data = await response.json()
const svgDimensions = getSVGDimensions(data.svg)
setSvg(data.svg)
setDimensions(svgDimensions)
}
} catch (error) {
console.error('[ProblemPreview] Error fetching preview:', error)
} finally {
setIsLoading(false)
}
}
fetchPreview()
}, [displayRules, operator, digitRange, resolvedDisplayRules])
// Only show loading state on first load (no previous svg), and make it invisible
if (!svg) {
return (
<div
className={css({
opacity: 0,
})}
style={{ width: 60, height: 50 }}
/>
)
}
// Show svg even if isLoading (keeps previous preview visible during updates)
// Calculate scale to fit in preview area (max 60px wide, 50px tall)
const maxWidth = 60
const maxHeight = 50
const scaleX = maxWidth / dimensions.width
const scaleY = maxHeight / dimensions.height
const scale = Math.min(scaleX, scaleY, 1) // Don't scale up, only down
const scaledWidth = dimensions.width * scale
const scaledHeight = dimensions.height * scale
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
rounded: 'md',
boxShadow: isDark ? '0 0 8px rgba(255, 255, 255, 0.1)' : '0 1px 3px rgba(0, 0, 0, 0.1)',
// Make the SVG inside slightly larger to clip off white edges
'& svg': {
width: 'calc(100% + 4px)',
height: 'calc(100% + 4px)',
marginLeft: '-2px',
marginTop: '-2px',
},
})}
style={{
width: scaledWidth,
height: scaledHeight,
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}

View File

@@ -2,11 +2,24 @@
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import type { DisplayRules } from '../../displayRules'
import { ProblemPreview } from './ProblemPreview'
export interface Tab {
id: string
label: string
icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed') => string)
icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed') => string) | 'preview'
subtitle?: (props: {
mode?: 'custom' | 'manual' | 'mastery'
difficultyProfile?: string
interpolate?: boolean
orientation?: 'portrait' | 'landscape'
problemsPerPage?: number
cols?: number
displayRules?: DisplayRules
resolvedDisplayRules?: DisplayRules
operator?: 'addition' | 'subtraction' | 'mixed'
}) => string | null
}
export const TABS: Tab[] = [
@@ -19,18 +32,112 @@ export const TABS: Tab[] = [
return '+'
},
},
{ id: 'layout', label: 'Layout', icon: '📐' },
{ id: 'scaffolding', label: 'Scaffolding', icon: '🎨' },
{ id: 'difficulty', label: 'Difficulty', icon: '📊' },
{
id: 'layout',
label: 'Layout',
icon: '📐',
subtitle: ({ orientation, problemsPerPage, cols }) => {
if (!orientation || !problemsPerPage || !cols) return null
const orientationLabel = orientation === 'portrait' ? 'Portrait' : 'Landscape'
const rows = Math.ceil(problemsPerPage / cols)
return `${orientationLabel}: ${cols}×${rows}`
},
},
{
id: 'scaffolding',
label: 'Scaffolding',
icon: 'preview',
subtitle: ({ displayRules, operator, resolvedDisplayRules }) => {
if (!displayRules) return null
// Map features to abbreviated names (for addition and subtraction)
const features: { key: keyof DisplayRules; name: string }[] =
operator === 'subtraction'
? [
{ key: 'borrowNotation', name: 'Borrow' },
{ key: 'borrowingHints', name: 'Hints' },
{ key: 'tenFrames', name: '10-frames' },
{ key: 'answerBoxes', name: 'Answer' },
{ key: 'placeValueColors', name: 'Colors' },
]
: [
{ key: 'carryBoxes', name: 'Carry' },
{ key: 'tenFrames', name: '10-frames' },
{ key: 'answerBoxes', name: 'Answer' },
{ key: 'placeValueColors', name: 'Colors' },
]
// Resolve 'auto' to actual value if we have resolved rules
const getResolvedValue = (key: keyof DisplayRules) => {
const value = displayRules[key]
if (value === 'auto' && resolvedDisplayRules) {
return resolvedDisplayRules[key]
}
return value
}
// Split features into 'always' and conditional (not 'never' and not 'always')
const always = features.filter((f) => getResolvedValue(f.key) === 'always')
const conditional = features.filter((f) => {
const resolved = getResolvedValue(f.key)
return resolved !== 'never' && resolved !== 'always'
})
// Build subtitle lines, hiding empty categories
const lines: string[] = []
if (always.length > 0) {
lines.push(`Always: ${always.map((f) => f.name).join(', ')}`)
}
if (conditional.length > 0) {
lines.push(`Conditional: ${conditional.map((f) => f.name).join(', ')}`)
}
// Return combined lines, or "None" if both are empty
return lines.length > 0 ? lines.join('\n') : 'None'
},
},
{
id: 'difficulty',
label: 'Difficulty',
icon: '📊',
subtitle: ({ mode, interpolate }) => {
if (!mode) return null
const modeName = mode === 'custom' ? 'Custom' : mode === 'mastery' ? 'Mastery' : 'Manual'
const progression = interpolate ? 'Progressive' : 'Fixed'
return mode === 'manual' ? modeName : `${modeName}: ${progression}`
},
},
]
interface TabNavigationProps {
activeTab: string
onChange: (tabId: string) => void
operator?: 'addition' | 'subtraction' | 'mixed'
mode?: 'custom' | 'manual' | 'mastery'
difficultyProfile?: string
interpolate?: boolean
orientation?: 'portrait' | 'landscape'
problemsPerPage?: number
cols?: number
displayRules?: DisplayRules
resolvedDisplayRules?: DisplayRules
digitRange?: { min: number; max: number }
}
export function TabNavigation({ activeTab, onChange, operator }: TabNavigationProps) {
export function TabNavigation({
activeTab,
onChange,
operator,
mode,
difficultyProfile,
interpolate,
orientation,
problemsPerPage,
cols,
displayRules,
resolvedDisplayRules,
digitRange,
}: TabNavigationProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -41,6 +148,23 @@ export function TabNavigation({ activeTab, onChange, operator }: TabNavigationPr
return tab.icon
}
const getTabSubtitle = (tab: Tab) => {
if (typeof tab.subtitle === 'function') {
return tab.subtitle({
mode,
difficultyProfile,
interpolate,
orientation,
problemsPerPage,
cols,
displayRules,
resolvedDisplayRules,
operator,
})
}
return null
}
return (
<div
data-component="tab-navigation"
@@ -52,6 +176,8 @@ export function TabNavigation({ activeTab, onChange, operator }: TabNavigationPr
>
{TABS.map((tab) => {
const icon = getTabIcon(tab)
const subtitle = getTabSubtitle(tab)
const showPreviewIcon = tab.icon === 'preview' && displayRules
// All operator symbols (+, , ±) are ASCII characters that need larger font size
const isOperatorSymbol = tab.id === 'operator'
@@ -94,20 +220,59 @@ export function TabNavigation({ activeTab, onChange, operator }: TabNavigationPr
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5',
gap: subtitle || showPreviewIcon ? '0.5' : '1.5',
})}
>
<span
<div
className={css({
fontSize: isOperatorSymbol ? 'lg' : undefined,
fontWeight: isOperatorSymbol ? 'bold' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5',
})}
>
{icon}
</span>
<span>{tab.label}</span>
{showPreviewIcon ? (
<ProblemPreview
displayRules={displayRules!}
resolvedDisplayRules={resolvedDisplayRules}
operator={operator}
digitRange={digitRange}
/>
) : (
<span
className={css({
fontSize: isOperatorSymbol ? 'lg' : undefined,
fontWeight: isOperatorSymbol ? 'bold' : undefined,
})}
>
{icon}
</span>
)}
<span>{tab.label}</span>
</div>
{subtitle ? (
<span
className={css({
fontSize: '2xs',
fontWeight: 'normal',
whiteSpace: 'pre-line',
textAlign: 'center',
color:
activeTab === tab.id
? isDark
? 'brand.300'
: 'brand.600'
: isDark
? 'gray.400'
: 'gray.500',
})}
>
{subtitle}
</span>
) : null}
</div>
</button>
)