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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user