feat: add auto scaffolding mode with visual feedback and override notices

Add comprehensive auto scaffolding system for mastery mode:

**Auto Scaffolding Mode:**
- Add 'auto' value to displayRules that defers to skill recommendations
- mergeDisplayRulesWithAuto() resolves 'auto' to skill scaffolding at validation time
- User settings persist when changing skills (only resolved values change)

**Visual Feedback:**
- RuleThermometer shows green highlighting for auto-resolved values
- Operator icons (±, +, −) show which operator defers to each value in mixed mode
- Uses outline instead of border to prevent height jumping
- OperatorIcon component provides consistent operator symbols across UI

**Override Notices:**
- ScaffoldingTab shows "Auto uses [Skill] recommendations" notice
- MasteryModePanel shows "Custom scaffolding: [overrides]" with reset button
- Both notices are subtle, non-obtrusive (small italic gray text)

**Components:**
- OperatorIcon: Reusable component for operator symbols (+, −, ±)
- RuleThermometer: Enhanced with operator-specific resolved values
- ScaffoldingTab: Auto notice and operator-specific resolution
- MasteryModePanel: Override detection and reset-to-auto button

🤖 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 07:20:18 -06:00
parent 9944abf85f
commit b62db5a323
5 changed files with 286 additions and 35 deletions

View File

@ -65,6 +65,40 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
? subtractionSkills
: additionSkills
// Get override notice - which scaffolding settings are overriding mastery progression
const getOverrideNotice = () => {
if (!currentSkill?.recommendedScaffolding || !formState.displayRules) return null
const overrides: string[] = []
const recommended = currentSkill.recommendedScaffolding
const userRules = formState.displayRules
const ruleLabels: Record<string, string> = {
carryBoxes: 'Carry/Borrow',
answerBoxes: 'Answer Boxes',
placeValueColors: 'Place Value',
tenFrames: 'Ten-Frames',
borrowNotation: 'Borrow Notation',
borrowingHints: 'Borrow Hints',
}
for (const [key, label] of Object.entries(ruleLabels)) {
const userValue = (userRules as any)[key]
const recommendedValue = (recommended as any)[key]
// Check if user has manually overridden (not 'auto' and different from recommended)
if (userValue !== 'auto' && userValue !== undefined && userValue !== recommendedValue) {
overrides.push(label)
}
}
if (overrides.length === 0) return null
return `Custom scaffolding: ${overrides.join(', ')}`
}
const overrideNotice = getOverrideNotice()
// Load mastery states from API
useEffect(() => {
async function loadMasteryStates() {
@ -886,6 +920,72 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
>
{currentSkill.description}
</p>
{overrideNotice && (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
marginTop: '0.5rem',
})}
>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
fontStyle: 'italic',
})}
>
{overrideNotice}
</p>
<button
type="button"
onClick={() => {
const autoRules: DisplayRules = {
carryBoxes: 'auto',
answerBoxes: 'auto',
placeValueColors: 'auto',
tenFrames: 'auto',
problemNumbers: 'auto',
cellBorders: 'auto',
borrowNotation: 'auto',
borrowingHints: 'auto',
}
// In mastery+mixed mode, update operator-specific rules too
if (isMixedMode) {
onChange({
displayRules: autoRules,
additionDisplayRules: autoRules,
subtractionDisplayRules: autoRules,
})
} else {
onChange({
displayRules: autoRules,
})
}
}}
className={css({
fontSize: '0.625rem',
px: '1.5',
py: '0.5',
rounded: 'md',
color: isDark ? 'green.300' : 'green.600',
bg: isDark ? 'green.900/30' : 'green.50',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.300',
cursor: 'pointer',
fontWeight: 'medium',
whiteSpace: 'nowrap',
_hover: {
bg: isDark ? 'green.800/40' : 'green.100',
},
})}
>
Reset to Auto
</button>
</div>
)}
</div>
</div>

View File

@ -0,0 +1,49 @@
import { css } from '@styled/css'
export interface OperatorIconProps {
operator: 'addition' | 'subtraction' | 'mixed'
size?: 'sm' | 'md' | 'lg' | 'xl'
isDark?: boolean
color?: 'gray' | 'green'
className?: string
}
const sizeMap = {
sm: '2xs',
md: 'xs',
lg: 'sm',
xl: 'xl',
} as const
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed'): string {
if (operator === 'mixed') return '±'
if (operator === 'subtraction') return ''
return '+'
}
export function OperatorIcon({
operator,
size = 'xl',
isDark = false,
color = 'gray',
className,
}: OperatorIconProps) {
const colorValue =
color === 'green' ? (isDark ? 'green.400' : 'green.600') : isDark ? 'gray.300' : 'gray.700'
return (
<span
className={css(
{
fontSize: sizeMap[size],
fontWeight: 'bold',
color: colorValue,
flexShrink: 0,
},
className
)}
>
{getOperatorSymbol(operator)}
</span>
)
}

View File

@ -1,4 +1,5 @@
import { css } from '@styled/css'
import { OperatorIcon } from './OperatorIcon'
export interface OperatorSectionProps {
operator: 'addition' | 'subtraction' | 'mixed' | undefined
@ -108,16 +109,7 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
minWidth: 0,
})}
>
<span
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.700',
flexShrink: 0,
})}
>
+
</span>
<OperatorIcon operator="addition" isDark={isDark} />
<span
className={css({
fontSize: 'sm',
@ -180,16 +172,7 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
minWidth: 0,
})}
>
<span
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.700',
flexShrink: 0,
})}
>
</span>
<OperatorIcon operator="subtraction" isDark={isDark} />
<span
className={css({
fontSize: 'sm',

View File

@ -2,6 +2,7 @@
import { css } from '@styled/css'
import type { RuleMode } from '../../displayRules'
import { OperatorIcon } from './OperatorIcon'
export interface RuleThermometerProps {
label: string
@ -9,8 +10,12 @@ export interface RuleThermometerProps {
value: RuleMode
onChange: (value: RuleMode) => void
isDark?: boolean
/** When value is 'auto', this shows which value 'auto' resolves to */
/** When value is 'auto', this shows which value 'auto' resolves to (single operator mode) */
resolvedValue?: RuleMode
/** When value is 'auto' in mixed mode, shows which value addition defers to */
resolvedAdditionValue?: RuleMode
/** When value is 'auto' in mixed mode, shows which value subtraction defers to */
resolvedSubtractionValue?: RuleMode
}
const RULE_OPTIONS: Array<{ value: RuleMode; label: string; short: string }> = [
@ -29,9 +34,12 @@ export function RuleThermometer({
onChange,
isDark = false,
resolvedValue,
resolvedAdditionValue,
resolvedSubtractionValue,
}: RuleThermometerProps) {
const selectedIndex = RULE_OPTIONS.findIndex((opt) => opt.value === value)
const isAutoSelected = value === 'auto'
const isMixedMode = resolvedAdditionValue !== undefined && resolvedSubtractionValue !== undefined
return (
<div
@ -78,8 +86,30 @@ export function RuleThermometer({
>
{RULE_OPTIONS.map((option, index) => {
const isSelected = value === option.value
// When 'auto' is selected, show which value it resolves to with secondary color
const isAutoResolved = isAutoSelected && resolvedValue === option.value
// Check which operators defer to this option (for mixed mode)
const additionDefersHere =
isMixedMode && isAutoSelected && resolvedAdditionValue === option.value
const subtractionDefersHere =
isMixedMode && isAutoSelected && resolvedSubtractionValue === option.value
const isAutoResolvedMixed = additionDefersHere || subtractionDefersHere
// For single operator mode
const isAutoResolved = !isMixedMode && isAutoSelected && resolvedValue === option.value
// Determine which operator to show
let operatorToShow: 'addition' | 'subtraction' | 'mixed' | null = null
if (isAutoResolvedMixed) {
if (additionDefersHere && subtractionDefersHere) {
operatorToShow = 'mixed' // Both defer here
} else if (additionDefersHere) {
operatorToShow = 'addition' // Only addition
} else if (subtractionDefersHere) {
operatorToShow = 'subtraction' // Only subtraction
}
}
const showHighlight = isAutoResolved || isAutoResolvedMixed
const isLeftmost = index === 0
const isRightmost = index === RULE_OPTIONS.length - 1
@ -88,16 +118,22 @@ export function RuleThermometer({
key={option.value}
type="button"
onClick={() => onChange(option.value)}
title={isAutoResolved ? `${option.label} (currently selected by Auto)` : option.label}
title={
isAutoResolvedMixed && operatorToShow
? `${option.label} (${operatorToShow} selected by Auto)`
: isAutoResolved
? `${option.label} (currently selected by Auto)`
: option.label
}
className={css({
flex: 1,
px: '2',
py: '1.5',
fontSize: '2xs',
fontWeight: isSelected ? 'bold' : isAutoResolved ? 'semibold' : 'medium',
fontWeight: isSelected ? 'bold' : showHighlight ? 'semibold' : 'medium',
color: isSelected
? 'white'
: isAutoResolved
: showHighlight
? isDark
? 'green.300'
: 'green.700'
@ -106,23 +142,24 @@ export function RuleThermometer({
: 'gray.600',
bg: isSelected
? 'brand.500'
: isAutoResolved
: showHighlight
? isDark
? 'green.900/30'
: 'green.100'
: 'transparent',
border: isAutoResolved ? '1px solid' : 'none',
borderColor: isAutoResolved ? (isDark ? 'green.700' : 'green.300') : 'transparent',
outline: showHighlight ? '1px solid' : 'none',
outlineColor: showHighlight ? (isDark ? 'green.700' : 'green.300') : 'transparent',
borderTopLeftRadius: isLeftmost ? 'md' : '0',
borderBottomLeftRadius: isLeftmost ? 'md' : '0',
borderTopRightRadius: isRightmost ? 'md' : '0',
borderBottomRightRadius: isRightmost ? 'md' : '0',
cursor: 'pointer',
transition: 'all 0.15s',
position: 'relative', // For absolute positioning of operator symbol
_hover: {
bg: isSelected
? 'brand.600'
: isAutoResolved
: showHighlight
? isDark
? 'green.800/40'
: 'green.200'
@ -133,6 +170,19 @@ export function RuleThermometer({
},
})}
>
{/* Operator icon for mixed mode - positioned absolutely on left */}
{operatorToShow && (
<div
className={css({
position: 'absolute',
left: '0.25rem',
top: '50%',
transform: 'translateY(-50%)',
})}
>
<OperatorIcon operator={operatorToShow} size="lg" isDark={isDark} color="green" />
</div>
)}
{option.short}
</button>
)

View File

@ -21,16 +21,25 @@ export function ScaffoldingTab() {
// Get resolved display rules for showing what 'auto' defers to
let resolvedDisplayRules: DisplayRules | undefined
let resolvedAdditionRules: DisplayRules | undefined
let resolvedSubtractionRules: DisplayRules | undefined
if (formState.mode === 'mastery') {
const operator = formState.operator ?? 'addition'
if (operator === 'mixed') {
// Mixed mode: Use addition skill's recommendations for now (could show both)
const skillId = formState.currentAdditionSkillId
if (skillId) {
const skill = getSkillById(skillId as any)
resolvedDisplayRules = skill?.recommendedScaffolding
// Mixed mode: Get BOTH addition and subtraction skill recommendations
const addSkillId = formState.currentAdditionSkillId
const subSkillId = formState.currentSubtractionSkillId
if (addSkillId) {
const skill = getSkillById(addSkillId as any)
resolvedAdditionRules = skill?.recommendedScaffolding
}
if (subSkillId) {
const skill = getSkillById(subSkillId as any)
resolvedSubtractionRules = skill?.recommendedScaffolding
}
} else {
// Single operator: Use its skill's recommendations
@ -75,6 +84,40 @@ export function ScaffoldingTab() {
}
}
// Get skill names for auto notice
const getAutoNoticeText = () => {
if (formState.mode !== 'mastery') return null
const operator = formState.operator ?? 'addition'
if (operator === 'mixed') {
const addSkill = formState.currentAdditionSkillId
? getSkillById(formState.currentAdditionSkillId as any)
: null
const subSkill = formState.currentSubtractionSkillId
? getSkillById(formState.currentSubtractionSkillId as any)
: null
if (addSkill && subSkill) {
return `Auto uses ${addSkill.name} (addition) and ${subSkill.name} (subtraction) recommendations`
}
} else {
const skillId =
operator === 'addition'
? formState.currentAdditionSkillId
: formState.currentSubtractionSkillId
const skill = skillId ? getSkillById(skillId as any) : null
if (skill) {
return `Auto uses ${skill.name} recommendations`
}
}
return null
}
const autoNoticeText = getAutoNoticeText()
return (
<div data-component="scaffolding-tab" className={stack({ gap: '3' })}>
{/* Quick presets */}
@ -220,6 +263,20 @@ export function ScaffoldingTab() {
</div>
</div>
{/* Auto notice - shows what skill auto defers to */}
{autoNoticeText && (
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.500' : 'gray.500',
fontStyle: 'italic',
px: '1',
})}
>
{autoNoticeText}
</div>
)}
{/* Pedagogical scaffolding thermometers */}
<RuleThermometer
label="Answer Boxes"
@ -228,6 +285,8 @@ export function ScaffoldingTab() {
onChange={(value) => updateRule('answerBoxes', value)}
isDark={isDark}
resolvedValue={resolvedDisplayRules?.answerBoxes}
resolvedAdditionValue={resolvedAdditionRules?.answerBoxes}
resolvedSubtractionValue={resolvedSubtractionRules?.answerBoxes}
/>
<RuleThermometer
@ -237,6 +296,8 @@ export function ScaffoldingTab() {
onChange={(value) => updateRule('placeValueColors', value)}
isDark={isDark}
resolvedValue={resolvedDisplayRules?.placeValueColors}
resolvedAdditionValue={resolvedAdditionRules?.placeValueColors}
resolvedSubtractionValue={resolvedSubtractionRules?.placeValueColors}
/>
<RuleThermometer
@ -258,6 +319,8 @@ export function ScaffoldingTab() {
onChange={(value) => updateRule('carryBoxes', value)}
isDark={isDark}
resolvedValue={resolvedDisplayRules?.carryBoxes}
resolvedAdditionValue={resolvedAdditionRules?.carryBoxes}
resolvedSubtractionValue={resolvedSubtractionRules?.carryBoxes}
/>
{(formState.operator === 'subtraction' || formState.operator === 'mixed') && (
@ -268,6 +331,8 @@ export function ScaffoldingTab() {
onChange={(value) => updateRule('borrowNotation', value)}
isDark={isDark}
resolvedValue={resolvedDisplayRules?.borrowNotation}
resolvedAdditionValue={resolvedAdditionRules?.borrowNotation}
resolvedSubtractionValue={resolvedSubtractionRules?.borrowNotation}
/>
)}
@ -279,6 +344,8 @@ export function ScaffoldingTab() {
onChange={(value) => updateRule('borrowingHints', value)}
isDark={isDark}
resolvedValue={resolvedDisplayRules?.borrowingHints}
resolvedAdditionValue={resolvedAdditionRules?.borrowingHints}
resolvedSubtractionValue={resolvedSubtractionRules?.borrowingHints}
/>
)}
@ -289,6 +356,8 @@ export function ScaffoldingTab() {
onChange={(value) => updateRule('tenFrames', value)}
isDark={isDark}
resolvedValue={resolvedDisplayRules?.tenFrames}
resolvedAdditionValue={resolvedAdditionRules?.tenFrames}
resolvedSubtractionValue={resolvedSubtractionRules?.tenFrames}
/>
</div>
)