feat: add 'auto' option for scaffolding to defer to mastery progression

Add new 'auto' display rule value that allows users to defer scaffolding decisions to the mastery progression's skill recommendations while still being able to override individual settings.

**Changes:**

1. **Type System (config-schemas.ts)**
   - Add 'auto' to displayRuleValues constant
   - Update all V4 schemas (Smart, Manual, Mastery) to include 'auto'
   - Document 'auto' as "defer to mastery progression's skill recommendation"

2. **UI (RuleThermometer.tsx, ScaffoldingTab.tsx)**
   - Add 'Auto' option to scaffolding thermometer controls
   - Add 'Auto' preset button (only shown in mastery mode)
   - Style Auto button with green theme to indicate recommended default

3. **Validation Logic (validation.ts)**
   - Add mergeDisplayRulesWithAuto() helper function
   - Resolve 'auto' values to skill's recommendedScaffolding
   - Support both single-operator and mixed-operator mastery modes
   - Add debug logging to trace auto resolution

**How it works:**

- When value is 'auto' → use skill's recommendation
- When value is anything else → use user's manual override
- When value is undefined → use skill's recommendation (fallback)

**Example:**
```typescript
// User sets answerBoxes to 'auto', carryBoxes to 'never'
userRules = {
  answerBoxes: 'auto',  // → Resolves to skill.recommendedScaffolding.answerBoxes
  carryBoxes: 'never'   // → Manual override, uses 'never'
}
```

This resolves the conflict between mastery progression recommendations and manual scaffolding controls, allowing users to defer to skill expertise while retaining the ability to override specific settings.

🤖 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 06:53:29 -06:00
parent 77ea70bff5
commit a945a620c4
4 changed files with 170 additions and 298 deletions

View File

@ -12,6 +12,7 @@ export interface RuleThermometerProps {
}
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+' },

View File

@ -69,6 +69,48 @@ export function ScaffoldingTab() {
Quick Presets
</div>
<div className={css({ display: 'flex', gap: '1.5' })}>
{/* Auto preset - defer to mastery progression (only show in mastery mode) */}
{formState.mode === 'mastery' && (
<button
onClick={() => {
const newDisplayRules = {
...displayRules,
carryBoxes: 'auto' as const,
answerBoxes: 'auto' as const,
placeValueColors: 'auto' as const,
tenFrames: 'auto' as const,
borrowNotation: 'auto' as const,
borrowingHints: 'auto' as const,
}
// In mastery+mixed mode, update operator-specific rules too
if (isMasteryMixed) {
onChange({
displayRules: newDisplayRules,
additionDisplayRules: newDisplayRules,
subtractionDisplayRules: newDisplayRules,
})
} else {
onChange({
displayRules: newDisplayRules,
})
}
}}
className={css({
px: '2',
py: '0.5',
fontSize: '2xs',
color: isDark ? 'green.300' : 'green.600',
bg: isDark ? 'green.900/30' : 'green.50',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.300',
rounded: 'md',
cursor: 'pointer',
_hover: { bg: isDark ? 'green.800/40' : 'green.100' },
})}
>
Auto
</button>
)}
<button
onClick={() => {
const newDisplayRules = {

View File

@ -17,6 +17,29 @@ import { WORKSHEET_LIMITS } from './constants/validation'
* 3. Export parseXXXConfig() helper
*/
// =============================================================================
// SHARED DISPLAY RULE TYPES
// =============================================================================
/**
* Display rule values for conditional scaffolding
*
* - "auto": Defer to mastery progression's skill recommendation (mastery mode only)
* - "always": Always show this scaffolding element
* - "never": Never show this scaffolding element
* - "whenRegrouping": Show only when problem requires regrouping
* - "whenMultipleRegroups": Show only when problem has multiple regroups
* - "when3PlusDigits": Show only when problem has 3+ digits
*/
const displayRuleValues = [
'auto',
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
] as const
// =============================================================================
// ADDITION WORKSHEETS
// =============================================================================
@ -336,64 +359,16 @@ const additionConfigV4BaseSchema = z.object({
const additionConfigV4SmartSchema = additionConfigV4BaseSchema.extend({
mode: z.literal('smart'),
// Conditional display rules
// Conditional display rules (with 'auto' for deferring to smart difficulty)
displayRules: z.object({
carryBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
answerBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
placeValueColors: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
tenFrames: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
problemNumbers: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
cellBorders: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowNotation: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowingHints: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
carryBoxes: z.enum(displayRuleValues),
answerBoxes: z.enum(displayRuleValues),
placeValueColors: z.enum(displayRuleValues),
tenFrames: z.enum(displayRuleValues),
problemNumbers: z.enum(displayRuleValues),
cellBorders: z.enum(displayRuleValues),
borrowNotation: z.enum(displayRuleValues),
borrowingHints: z.enum(displayRuleValues),
}),
// Optional: Which smart difficulty profile is selected
@ -406,63 +381,16 @@ const additionConfigV4ManualSchema = additionConfigV4BaseSchema.extend({
mode: z.literal('manual'),
// Manual mode now uses conditional display rules (same as Smart/Mastery)
// 'auto' is available but typically not used in manual mode
displayRules: z.object({
carryBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
answerBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
placeValueColors: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
tenFrames: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
problemNumbers: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
cellBorders: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowNotation: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowingHints: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
carryBoxes: z.enum(displayRuleValues),
answerBoxes: z.enum(displayRuleValues),
placeValueColors: z.enum(displayRuleValues),
tenFrames: z.enum(displayRuleValues),
problemNumbers: z.enum(displayRuleValues),
cellBorders: z.enum(displayRuleValues),
borrowNotation: z.enum(displayRuleValues),
borrowingHints: z.enum(displayRuleValues),
}),
// Optional: Which manual preset is selected
@ -473,187 +401,44 @@ const additionConfigV4ManualSchema = additionConfigV4BaseSchema.extend({
const additionConfigV4MasterySchema = additionConfigV4BaseSchema.extend({
mode: z.literal('mastery'),
// Mastery mode uses displayRules like smart mode (conditional scaffolding)
// Mastery mode uses displayRules with 'auto' for deferring to skill recommendations
displayRules: z.object({
carryBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
answerBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
placeValueColors: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
tenFrames: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
problemNumbers: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
cellBorders: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowNotation: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowingHints: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
carryBoxes: z.enum(displayRuleValues),
answerBoxes: z.enum(displayRuleValues),
placeValueColors: z.enum(displayRuleValues),
tenFrames: z.enum(displayRuleValues),
problemNumbers: z.enum(displayRuleValues),
cellBorders: z.enum(displayRuleValues),
borrowNotation: z.enum(displayRuleValues),
borrowingHints: z.enum(displayRuleValues),
}),
// Optional: Separate display rules for mixed mode (operator-specific scaffolding)
// When operator='mixed', additionDisplayRules applies to addition problems,
// subtractionDisplayRules applies to subtraction problems
// Each can independently use 'auto' to defer to the operator-specific skill recommendation
additionDisplayRules: z
.object({
carryBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
answerBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
placeValueColors: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
tenFrames: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
problemNumbers: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
cellBorders: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowNotation: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowingHints: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
carryBoxes: z.enum(displayRuleValues),
answerBoxes: z.enum(displayRuleValues),
placeValueColors: z.enum(displayRuleValues),
tenFrames: z.enum(displayRuleValues),
problemNumbers: z.enum(displayRuleValues),
cellBorders: z.enum(displayRuleValues),
borrowNotation: z.enum(displayRuleValues),
borrowingHints: z.enum(displayRuleValues),
})
.optional(),
subtractionDisplayRules: z
.object({
carryBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
answerBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
placeValueColors: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
tenFrames: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
problemNumbers: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
cellBorders: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowNotation: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowingHints: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
carryBoxes: z.enum(displayRuleValues),
answerBoxes: z.enum(displayRuleValues),
placeValueColors: z.enum(displayRuleValues),
tenFrames: z.enum(displayRuleValues),
problemNumbers: z.enum(displayRuleValues),
cellBorders: z.enum(displayRuleValues),
borrowNotation: z.enum(displayRuleValues),
borrowingHints: z.enum(displayRuleValues),
})
.optional(),

View File

@ -21,6 +21,34 @@ function getDefaultDate(): string {
})
}
/**
* Merge user display rules with skill recommendations, resolving "auto" values
*
* For each display rule field:
* - If user value is "auto" use skill's recommendation
* - If user value is undefined use skill's recommendation
* - Otherwise use user's explicit value (manual override)
*
* @param skillRules - The skill's recommended scaffolding settings
* @param userRules - The user's custom scaffolding settings (may contain "auto")
* @returns Fully resolved display rules with no "auto" values
*/
function mergeDisplayRulesWithAuto(
skillRules: DisplayRules,
userRules: Partial<DisplayRules>
): DisplayRules {
const result: Record<string, any> = {}
for (const key of Object.keys(skillRules) as Array<keyof DisplayRules>) {
const userValue = userRules[key]
// If user value is "auto" or undefined, use skill's recommendation
// Otherwise, use user's explicit value (manual override)
result[key] = userValue === 'auto' || userValue === undefined ? skillRules[key] : userValue
}
return result as DisplayRules
}
/**
* Validate and create complete config from partial form state
*/
@ -260,10 +288,22 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
}
}
const displayRules: DisplayRules = {
...baseDisplayRules,
...((formState.displayRules as any) ?? {}), // Override with provided rules if any
}
// Merge user's display rules with skill recommendations, resolving "auto" values
const userDisplayRules = (formState.displayRules as any) ?? {}
const displayRules: DisplayRules =
mode === 'mastery'
? mergeDisplayRulesWithAuto(baseDisplayRules, userDisplayRules)
: {
...baseDisplayRules,
...userDisplayRules, // Smart mode: direct override (no "auto" resolution)
}
console.log('[MASTERY MODE] Display rules resolved:', {
mode,
baseDisplayRules,
userDisplayRules,
resolvedDisplayRules: displayRules,
})
// Build config with operator-specific display rules for mixed mode
const operator = formState.operator ?? 'addition'
@ -287,32 +327,36 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
if (addSkill?.recommendedScaffolding && subSkill?.recommendedScaffolding) {
// Merge user's operator-specific displayRules with skill's recommended scaffolding
// User's rules (if set) take precedence over skill's recommendations
// Fall back to general displayRules if operator-specific rules don't exist
// Resolves "auto" values to skill recommendations
// Falls back to general displayRules if operator-specific rules don't exist
const userAdditionRules: Partial<DisplayRules> =
(formState as any).additionDisplayRules || formState.displayRules || {}
const userSubtractionRules: Partial<DisplayRules> =
(formState as any).subtractionDisplayRules || formState.displayRules || {}
console.log('[MIXED MODE SCAFFOLDING] User rules:', {
console.log('[MIXED MODE SCAFFOLDING] User rules (may contain "auto"):', {
additionRules: userAdditionRules,
subtractionRules: userSubtractionRules,
generalRules: formState.displayRules,
})
// Resolve "auto" values to skill recommendations
const resolvedAdditionRules = mergeDisplayRulesWithAuto(
addSkill.recommendedScaffolding,
userAdditionRules
)
const resolvedSubtractionRules = mergeDisplayRulesWithAuto(
subSkill.recommendedScaffolding,
userSubtractionRules
)
config = {
...baseConfig,
additionDisplayRules: {
...addSkill.recommendedScaffolding,
...userAdditionRules, // User's custom rules override skill's recommendations
},
subtractionDisplayRules: {
...subSkill.recommendedScaffolding,
...userSubtractionRules, // User's custom rules override skill's recommendations
},
additionDisplayRules: resolvedAdditionRules,
subtractionDisplayRules: resolvedSubtractionRules,
} as any
console.log('[MIXED MODE SCAFFOLDING] Final config:', {
console.log('[MIXED MODE SCAFFOLDING] Final config (after resolving "auto"):', {
additionDisplayRules: config.additionDisplayRules,
subtractionDisplayRules: config.subtractionDisplayRules,
})