feat: implement learner-friendly pedagogical tooltips with plain language

🎯 Learner-focused redesign:
- Replace technical variables (a, d, s) with concrete language: "This rod shows: 4", "We're adding: 6"
- Add SegmentReadable interface generating plain-language descriptions for all tooltip content
- Implement context-aware titles: "Make 10 — ones", "Make 5 — tens", "Direct Add — ones"
- Convert abstract concepts to observable abacus states students can see and understand

💬 Plain language improvements:
- "This rod shows" instead of "a = 4"
- "We're adding" instead of "d = 6"
- "So take away here" instead of "s = 10 - d = 4"
- Concrete carry paths: "Tens is 9 → hundreds +1; tens → 0" vs generic formulas
- Step-by-step uses bead verbs: "Add 100 to hundreds", "Remove 90 from tens"

🎓 Progressive disclosure:
- Default view shows essential context (title + why bullets + chips)
- "Step-by-step breakdown" expandable for detailed bead movements
- "Show the math" toggle reveals formulas for advanced users/teachers
- Maintains pedagogical rigor while prioritizing accessibility

🎨 UX improvements:
- Fix inline display issue (div → span) preventing line breaks in decomposition
- Enhanced keyboard accessibility for all interactive elements
- Consistent visual hierarchy with readable context chips
- Advanced math section styled with subtle visual separation

🏗️ Architecture:
- SegmentReadable generation integrated into unified step generator
- Clean separation between internal math (a, d, s) and learner presentation
- Backward compatible: fallbacks for existing segment data
- Type-safe interface ensuring consistent learner experience

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-25 17:21:28 -05:00
parent e1e590c474
commit 01ed22c051
3 changed files with 280 additions and 93 deletions

View File

@@ -16,28 +16,12 @@ interface ReasonTooltipProps {
onOpenChange?: (open: boolean) => void
}
// Utility functions
// Fallback utility for legacy support
function getPlaceName(place: number): string {
const names = ['ones', 'tens', 'hundreds', 'thousands', 'ten-thousands']
return names[place] || `10^${place}`
}
function formatRuleName(rule: PedagogicalRule, place: number, hasCascade = false): string {
const placeName = getPlaceName(place)
switch (rule) {
case 'Direct':
return `Direct — ${placeName}`
case 'FiveComplement':
return `Five Friend — ${placeName}`
case 'TenComplement':
return hasCascade ? `Ten Friend (cascade) — ${placeName}` : `Ten Friend — ${placeName}`
case 'Cascade':
return `Chain Reaction — ${placeName}`
default:
return `Strategy — ${placeName}`
}
}
export function ReasonTooltip({
children,
termIndex,
@@ -49,6 +33,7 @@ export function ReasonTooltip({
onOpenChange
}: ReasonTooltipProps) {
const [showBeadDetails, setShowBeadDetails] = useState(false)
const [showMath, setShowMath] = useState(false)
const rule = reason?.rule ?? segment?.plan[0]?.rule
const shortReason = reason?.shortReason
const bullets = reason?.bullets
@@ -57,16 +42,8 @@ export function ReasonTooltip({
return <>{children}</>
}
// Calculate context values
const place = segment?.place ?? 0
const currentDigit = segment?.a ?? 0 // Current digit at this place
const addingDigit = segment?.digit ?? 0 // Digit being added
const sum = currentDigit + addingDigit
const complement = rule === 'FiveComplement' ? (5 - currentDigit) :
rule === 'TenComplement' ? (10 - addingDigit) : 0
// Detect cascade
const hasCascade = segment?.plan?.some(p => p.rule === 'Cascade') ?? false
// Use readable format from segment
const readable = segment?.readable
const getRuleInfo = (rule: PedagogicalRule) => {
switch (rule) {
@@ -112,12 +89,11 @@ export function ReasonTooltip({
const contentClasses = `reason-tooltip reason-tooltip--${ruleInfo.color}`
const tooltipId = `tooltip-${termIndex}`
const ruleTitle = formatRuleName(rule, place, hasCascade)
return (
<Tooltip.Root open={open} onOpenChange={onOpenChange} delayDuration={300}>
<Tooltip.Trigger asChild>
<div
<span
role="button"
tabIndex={0}
aria-labelledby={tooltipId}
@@ -133,7 +109,7 @@ export function ReasonTooltip({
}}
>
{children}
</div>
</span>
</Tooltip.Trigger>
<Tooltip.Portal>
@@ -150,81 +126,78 @@ export function ReasonTooltip({
<div className="reason-tooltip__header">
<span className="reason-tooltip__emoji">{ruleInfo.emoji}</span>
<div className="reason-tooltip__title">
<h4 className="reason-tooltip__name">{ruleTitle}</h4>
<p id={`${tooltipId}-description`} className="reason-tooltip__description">{ruleInfo.description}</p>
<h4 className="reason-tooltip__name">{readable?.title || ruleInfo.name}</h4>
<p id={`${tooltipId}-description`} className="reason-tooltip__description">
{readable?.subtitle || ruleInfo.description}
</p>
</div>
</div>
{/* Context chips */}
{segment && (
{/* Context chips using readable format */}
{readable && readable.chips.length > 0 && (
<div className="reason-tooltip__context">
<div className="reason-tooltip__chips">
<span className="reason-tooltip__chip">Place: {getPlaceName(place)}</span>
<span className="reason-tooltip__chip">a = {currentDigit}</span>
<span className="reason-tooltip__chip">d = {addingDigit}</span>
{complement > 0 && (
<span className="reason-tooltip__chip">
s = {rule === 'FiveComplement' ? 5 : 10} {rule === 'FiveComplement' ? currentDigit : addingDigit} = {complement}
{readable.chips.map((chip, index) => (
<span key={index} className="reason-tooltip__chip">
{chip.label}: {chip.value}
</span>
)}
))}
</div>
</div>
)}
{shortReason && (
<div className="reason-tooltip__explanation">
<p>{shortReason}</p>
</div>
)}
{/* Show expansion reasoning from segment plan */}
{segment?.plan && segment.plan.length > 0 && (
{/* Why this step using readable format */}
{readable && readable.why.length > 0 && (
<div className="reason-tooltip__reasoning">
<h5 className="reason-tooltip__section-title">Why this expansion?</h5>
{segment.plan.map((decision, idx) => (
<div key={idx} className="reason-tooltip__decision">
{decision.explanation.map((explain, explainIdx) => (
<p key={explainIdx} className="reason-tooltip__explanation-text">
{explain}
</p>
))}
</div>
<h5 className="reason-tooltip__section-title">Why this step</h5>
{readable.why.map((why, index) => (
<p key={index} className="reason-tooltip__explanation-text">
{why}
</p>
))}
</div>
)}
{/* Show carry path for ten complements */}
{rule === 'TenComplement' && (
{/* Show carry path using readable format */}
{readable?.carryPath && (
<div className="reason-tooltip__carry-path">
<p className="reason-tooltip__carry-description">
{hasCascade ? (
<>
<strong>Carry path:</strong> {getPlaceName(place + 1)} = 9 find nearest non-9, then clear intermediate 9s
</>
) : (
<>
<strong>Carry path:</strong> +1 to {getPlaceName(place + 1)}, -{addingDigit} from {getPlaceName(place)}
</>
)}
<strong>Carry path:</strong> {readable.carryPath}
</p>
</div>
)}
{bullets && bullets.length > 0 && (
<div className="reason-tooltip__details">
<ul>
{bullets.map((bullet, idx) => (
<li key={idx}>
<span className="reason-tooltip__bullet"></span>
<span>{bullet}</span>
</li>
))}
</ul>
{/* Show the math toggle for advanced users */}
{readable?.showMath && (
<div className="reason-tooltip__advanced">
<button
className="reason-tooltip__math-toggle"
onClick={() => setShowMath(!showMath)}
aria-expanded={showMath}
type="button"
>
<span className="reason-tooltip__math-label">
Show the math
<span className="reason-tooltip__chevron" style={{ transform: showMath ? 'rotate(180deg)' : 'rotate(0deg)' }}>
</span>
</span>
</button>
{showMath && (
<div className="reason-tooltip__math-content">
{readable.showMath.lines.map((line, index) => (
<p key={index} className="reason-tooltip__math-line">
{line}
</p>
))}
</div>
)}
</div>
)}
{/* Show expandable step-by-step breakdown for multi-step segments */}
{segment && steps && segment.stepIndices && segment.stepIndices.length > 1 && (
{/* Show expandable step-by-step breakdown using readable format */}
{readable && readable.stepsFriendly.length > 1 && (
<div className="reason-tooltip__steps">
<button
className="reason-tooltip__expand-button"
@@ -249,22 +222,17 @@ export function ReasonTooltip({
{showBeadDetails && (
<ol id={`${tooltipId}-steps`} className="reason-tooltip__step-list">
{segment.stepIndices.map((stepIndex, idx) => {
const step = steps[stepIndex]
if (!step) return null
return (
<li key={stepIndex} className="reason-tooltip__step">
<code className="reason-tooltip__step-term">{step.mathematicalTerm}</code>
<span className="reason-tooltip__step-instruction">{step.englishInstruction}</span>
</li>
)
})}
{readable.stepsFriendly.map((stepInstruction, idx) => (
<li key={idx} className="reason-tooltip__step">
<span className="reason-tooltip__step-instruction">{stepInstruction}</span>
</li>
))}
</ol>
)}
</div>
)}
{/* Original transformation shown at bottom */}
{originalValue && segment?.expression && (
<div className="reason-tooltip__formula">
<div className="reason-tooltip__expansion">

View File

@@ -349,6 +349,59 @@
line-height: 1.4;
}
/* Advanced math toggle section */
.reason-tooltip__advanced {
margin-top: 8px;
border-top: 1px solid #f1f5f9;
padding-top: 8px;
}
.reason-tooltip__math-toggle {
background: none;
border: none;
width: 100%;
text-align: left;
cursor: pointer;
padding: 0;
margin: 0;
font: inherit;
color: #64748b;
font-size: 11px;
}
.reason-tooltip__math-toggle:hover .reason-tooltip__math-label {
color: #475569;
}
.reason-tooltip__math-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: 500;
color: #64748b;
font-style: italic;
}
.reason-tooltip__math-content {
margin-top: 6px;
padding: 6px 8px;
background: #fafbfc;
border: 1px solid #e9ecef;
border-radius: 4px;
}
.reason-tooltip__math-line {
margin: 0 0 4px 0;
font-size: 10px;
color: #6b7280;
line-height: 1.3;
}
.reason-tooltip__math-line:last-child {
margin-bottom: 0;
}
.reason-tooltip__code {
background: #f9fafb;
border: 1px solid #e5e7eb;

View File

@@ -12,6 +12,16 @@ import {
export type PedagogicalRule = 'Direct' | 'FiveComplement' | 'TenComplement' | 'Cascade'
export interface SegmentReadable {
title: string // "Make 10 — ones" or "Make 10 (carry) — ones"
subtitle?: string // "Using pairs that make 10"
chips: Array<{ label: string; value: string }>
why: string[] // short, plain bullets
carryPath?: string // "Tens is 9 → hundreds +1; tens → 0"
stepsFriendly: string[] // bead verbs for each subterm
showMath?: { lines: string[] } // ["We take away 5 here (that's 10 minus 5)."]
}
export interface SegmentDecision {
/** Short, machine-readable rule fired at this segment */
rule: PedagogicalRule
@@ -44,6 +54,8 @@ export interface PedagogicalSegment {
endValue: number
startState: AbacusState
endState: AbacusState
/** Learner-friendly descriptions without technical variables */
readable: SegmentReadable
}
export interface UnifiedStepData {
@@ -210,6 +222,147 @@ function formatSegmentGoal(digit: number, placeValue: number): string {
return `Add ${digit} to ${placeName}`
}
function generateSegmentReadable(
rule: PedagogicalRule,
place: number,
digit: number,
currentDigit: number,
plan: SegmentDecision[],
steps: UnifiedStepData[],
stepIndices: number[],
startState: AbacusState,
targetState: AbacusState
): SegmentReadable {
const placeName = getPlaceName(place)
const hasCascade = plan.some(p => p.rule === 'Cascade')
// Generate title based on rule
let title: string
let subtitle: string | undefined
switch (rule) {
case 'Direct':
title = `Direct Add — ${placeName}`
subtitle = digit <= 4 ? 'Simple bead movement' : 'Using the heaven bead'
break
case 'FiveComplement':
title = `Make 5 — ${placeName}`
subtitle = 'Using pairs that make 5'
break
case 'TenComplement':
title = hasCascade ? `Make 10 (carry) — ${placeName}` : `Make 10 — ${placeName}`
subtitle = 'Using pairs that make 10'
break
case 'Cascade':
title = `Chain Reaction — ${placeName}`
subtitle = 'Multiple carries needed'
break
default:
title = `Strategy — ${placeName}`
}
// Generate chips with concrete language
const chips: Array<{ label: string; value: string }> = []
chips.push({
label: 'This rod shows',
value: currentDigit.toString()
})
chips.push({
label: "We're adding",
value: digit.toString()
})
// Add context-specific third chip
if (rule === 'TenComplement') {
const takeAway = 10 - digit
chips.push({
label: 'So take away here',
value: takeAway.toString()
})
} else if (rule === 'FiveComplement') {
chips.push({
label: 'Not enough lower beads here',
value: `Need ${digit - currentDigit} more`
})
}
// Generate why bullets
const why: string[] = []
switch (rule) {
case 'Direct':
if (digit <= 4) {
why.push('We can add beads directly to this rod.')
} else {
why.push(`Adding ${digit} fits perfectly using heaven and earth beads.`)
}
break
case 'FiveComplement':
why.push(`Adding ${digit} would need more lower beads than we have.`)
why.push('Use the heaven bead instead: press it and lift some lower beads.')
break
case 'TenComplement':
why.push(`Adding ${digit} would overfill this rod.`)
why.push(`We "make a ten": give 10 to the next rod and take ${10 - digit} away here.`)
break
}
// Generate carry path for ten complements
let carryPath: string | undefined
if (rule === 'TenComplement') {
if (hasCascade) {
// Look at the start state to determine the cascade path
const nextPlace = place + 1
const nextPlaceName = getPlaceName(nextPlace)
const nextValue = (startState[nextPlace]?.heavenActive ? 5 : 0) + (startState[nextPlace]?.earthActive || 0)
if (nextValue === 9) {
const higherPlace = place + 2
const higherPlaceName = getPlaceName(higherPlace)
carryPath = `${nextPlaceName} is 9 → ${higherPlaceName} +1; ${nextPlaceName} → 0`
} else {
carryPath = `${nextPlaceName} +1`
}
} else {
const nextPlaceName = getPlaceName(place + 1)
carryPath = `${nextPlaceName} +1`
}
}
// Generate friendly step descriptions
const stepsFriendly: string[] = []
stepIndices.forEach(stepIndex => {
const step = steps[stepIndex]
if (step) {
stepsFriendly.push(step.englishInstruction)
}
})
// Generate advanced math explanations
const showMath: { lines: string[] } = {
lines: []
}
if (rule === 'TenComplement') {
showMath.lines.push(`We take away ${10 - digit} here because that's what's needed to reach 10.`)
showMath.lines.push(`(That's 10 minus ${digit}.)`)
} else if (rule === 'FiveComplement') {
showMath.lines.push(`We take away ${5 - currentDigit} lower beads after pressing the heaven bead.`)
showMath.lines.push(`(That's 5 minus ${currentDigit}.)`)
}
return {
title,
subtitle,
chips,
why,
carryPath,
stepsFriendly,
showMath: showMath.lines.length > 0 ? showMath : undefined
}
}
function buildSegmentsWithPositions(
segmentsPlan: SegmentDraft[],
fullDecomposition: string,
@@ -235,6 +388,8 @@ function buildSegmentsWithPositions(
start -= 1; end += 1
}
const primaryRule = draft.plan[0]?.rule || 'Direct'
return {
id: draft.id,
place: draft.place,
@@ -251,7 +406,18 @@ function buildSegmentsWithPositions(
startValue: draft.startValue,
endValue: draft.endValue,
startState: draft.startState,
endState: draft.endState
endState: draft.endState,
readable: generateSegmentReadable(
primaryRule,
draft.place,
draft.digit,
draft.a,
draft.plan,
steps,
draft.stepIndices,
draft.startState,
draft.endState
)
}
})
}