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:
parent
9944abf85f
commit
b62db5a323
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue