feat: enhance pedagogical reasoning tooltips with comprehensive context

 Core improvements:
- Add local context chips showing place, a, d, and s values for immediate situational awareness
- Implement expandable step-by-step breakdown with clickable chevron interface
- Name carry paths explicitly for ten complement operations (cascade vs simple)
- Standardize rule naming: "Five Friend", "Ten Friend", "Ten Friend (cascade)"

🎯 Pedagogical enhancements:
- Show concrete state values (a=4, d=6, s=4) to anchor abstract rules to current context
- Provide carry path descriptions: "tens=9 ⇒ hundreds+1, tens→0" for cascade operations
- Enable expandable bead movement details while maintaining compact default view
- Consistent rule taxonomy across all complement types

 Accessibility improvements:
- Full keyboard navigation: Enter/Space to toggle, Escape to close
- Proper ARIA labels and descriptions for screen readers
- Focus management and visual focus indicators
- Semantic button roles and expanded state announcements

🎨 Polish:
- Clean expandable interface with animated chevron
- Context chips with monospace font for precise values
- Highlighted carry path boxes for ten complements
- Consistent visual hierarchy and spacing

🤖 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 16:17:05 -05:00
parent e60f4384c3
commit bb38c7c87c
2 changed files with 219 additions and 25 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import React from 'react'
import React, { useState } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import type { PedagogicalRule, PedagogicalSegment, TermReason } from './DecompositionWithReasons'
import type { UnifiedStepData } from '../../utils/unifiedStepGenerator'
@@ -16,6 +16,28 @@ interface ReasonTooltipProps {
onOpenChange?: (open: boolean) => void
}
// Utility functions
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,
@@ -26,6 +48,7 @@ export function ReasonTooltip({
open,
onOpenChange
}: ReasonTooltipProps) {
const [showBeadDetails, setShowBeadDetails] = useState(false)
const rule = reason?.rule ?? segment?.plan[0]?.rule
const shortReason = reason?.shortReason
const bullets = reason?.bullets
@@ -34,6 +57,17 @@ 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
const getRuleInfo = (rule: PedagogicalRule) => {
switch (rule) {
case 'Direct':
@@ -77,29 +111,66 @@ export function ReasonTooltip({
const ruleInfo = getRuleInfo(rule)
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>
{children}
<div
role="button"
tabIndex={0}
aria-labelledby={tooltipId}
aria-describedby={`${tooltipId}-description`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenChange?.(!open)
}
if (e.key === 'Escape' && open) {
onOpenChange?.(false)
}
}}
>
{children}
</div>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
id={tooltipId}
className={contentClasses}
sideOffset={8}
side="top"
align="center"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={() => onOpenChange?.(false)}
>
<div className="reason-tooltip__content">
<div className="reason-tooltip__header">
<span className="reason-tooltip__emoji">{ruleInfo.emoji}</span>
<div className="reason-tooltip__title">
<h4 className="reason-tooltip__name">{ruleInfo.name}</h4>
<p className="reason-tooltip__description">{ruleInfo.description}</p>
<h4 className="reason-tooltip__name">{ruleTitle}</h4>
<p id={`${tooltipId}-description`} className="reason-tooltip__description">{ruleInfo.description}</p>
</div>
</div>
{/* Context chips */}
{segment && (
<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}
</span>
)}
</div>
</div>
)}
{shortReason && (
<div className="reason-tooltip__explanation">
<p>{shortReason}</p>
@@ -122,6 +193,23 @@ export function ReasonTooltip({
</div>
)}
{/* Show carry path for ten complements */}
{rule === 'TenComplement' && (
<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)}
</>
)}
</p>
</div>
)}
{bullets && bullets.length > 0 && (
<div className="reason-tooltip__details">
<ul>
@@ -135,23 +223,45 @@ export function ReasonTooltip({
</div>
)}
{/* Show step-by-step breakdown for multi-step segments */}
{/* Show expandable step-by-step breakdown for multi-step segments */}
{segment && steps && segment.stepIndices && segment.stepIndices.length > 1 && (
<div className="reason-tooltip__steps">
<h5 className="reason-tooltip__section-title">Step-by-step breakdown:</h5>
<ol className="reason-tooltip__step-list">
{segment.stepIndices.map((stepIndex, idx) => {
const step = steps[stepIndex]
if (!step) return null
<button
className="reason-tooltip__expand-button"
onClick={() => setShowBeadDetails(!showBeadDetails)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setShowBeadDetails(!showBeadDetails)
}
}}
aria-expanded={showBeadDetails}
aria-controls={`${tooltipId}-steps`}
type="button"
>
<span className="reason-tooltip__section-title">
Step-by-step breakdown
<span className="reason-tooltip__chevron" style={{ transform: showBeadDetails ? 'rotate(180deg)' : 'rotate(0deg)' }}>
</span>
</span>
</button>
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>
)
})}
</ol>
{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>
)
})}
</ol>
)}
</div>
)}

View File

@@ -198,13 +198,13 @@
font-size: 12px;
font-weight: 600;
color: #374151;
margin: 0 0 8px 0;
margin: 0 0 4px 0;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 4px;
padding-bottom: 2px;
}
.reason-tooltip__reasoning {
margin-bottom: 12px;
margin-bottom: 8px;
}
.reason-tooltip__decision {
@@ -219,20 +219,22 @@
}
.reason-tooltip__steps {
margin-bottom: 12px;
margin-bottom: 8px;
}
.reason-tooltip__step-list {
margin: 0;
padding: 0 0 0 16px;
margin-block-start: 0;
margin-block-end: 0;
padding: 0 0 0 12px;
list-style: decimal;
}
.reason-tooltip__step {
margin-bottom: 8px;
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
}
.reason-tooltip__step:last-child {
@@ -265,6 +267,88 @@
font-style: italic;
}
/* Context chips */
.reason-tooltip__context {
margin: 8px 0;
}
.reason-tooltip__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.reason-tooltip__chip {
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
font-weight: 500;
color: #374151;
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace;
}
/* Expandable step section */
.reason-tooltip__expand-button {
background: none;
border: none;
width: 100%;
text-align: left;
cursor: pointer;
padding: 0;
margin: 0;
font: inherit;
}
.reason-tooltip__expand-button:hover .reason-tooltip__section-title {
color: #1f2937;
}
.reason-tooltip__expand-button:focus {
outline: 2px solid #3b82f6;
outline-offset: 1px;
border-radius: 2px;
}
.reason-tooltip__expand-button:focus .reason-tooltip__section-title {
color: #1f2937;
}
.reason-tooltip__section-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 600;
color: #374151;
margin: 0 0 4px 0;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 2px;
}
.reason-tooltip__chevron {
font-size: 10px;
transition: transform 0.2s ease;
color: #9ca3af;
}
/* Carry path section */
.reason-tooltip__carry-path {
margin: 8px 0;
padding: 6px 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 4px;
}
.reason-tooltip__carry-description {
margin: 0;
font-size: 11px;
color: #475569;
line-height: 1.4;
}
.reason-tooltip__code {
background: #f9fafb;
border: 1px solid #e5e7eb;