feat(practice): redesign paused modal with kid-friendly statistics UX

Redesign SessionPausedModal to be approachable for children while
maintaining high-fidelity statistical information:

- New visual components:
  - SpeedMeter: shows average response time vs variation range
  - SampleDots: visualizes progress toward learning user's rhythm (5 samples)

- Educational framing:
  - "We Know Your Rhythm!" when we have enough samples
  - "Learning Your Rhythm..." when collecting data
  - "Taking a Thinking Break!" instead of clinical "paused" language

- Friendly UI improvements:
  - Contextual emoji thought bubbles (🤔 for auto-pause,  for manual)
  - Encouraging messages ("Smart thinking to take a break!")
  - "Keep Going!" button instead of "Resume"
  - Progress bar with gradient styling

- Statistical transparency:
  - Shows "Usually you take about X seconds" for mean
  - Visual representation of standard deviation as "wiggle room"
  - Explains why the pause happened in child-friendly terms

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-11 16:46:06 -06:00
parent 826c8490ba
commit 11ecb385ad
1 changed files with 474 additions and 188 deletions

View File

@ -56,29 +56,195 @@ function getPartTypeEmoji(type: SessionPart['type']): string {
}
/**
* Format milliseconds as a human-readable duration
* Format milliseconds as a human-readable duration for kids
*/
function formatDuration(ms: number): string {
function formatDurationFriendly(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
if (remainingMinutes === 0) {
return hours === 1 ? '1 hour' : `${hours} hours`
}
return `${hours}h ${remainingMinutes}m`
}
if (minutes > 0) {
const remainingSeconds = seconds % 60
if (remainingSeconds === 0) {
return minutes === 1 ? '1 minute' : `${minutes} minutes`
}
return `${minutes}m ${remainingSeconds}s`
}
return `${seconds}s`
return seconds === 1 ? '1 second' : `${seconds} seconds`
}
/**
* Format milliseconds as seconds with one decimal place
* Format seconds in a kid-friendly way
*/
function formatSeconds(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`
function formatSecondsFriendly(ms: number): string {
const seconds = ms / 1000
if (seconds < 1) return 'less than a second'
if (seconds < 2) return 'about 1 second'
if (seconds < 10) return `about ${Math.round(seconds)} seconds`
return `about ${Math.round(seconds)} seconds`
}
/**
* Sample dots visualization - shows the problems used to learn the rhythm
*/
function SampleDots({ count, needed, isDark }: { count: number; needed: number; isDark: boolean }) {
const total = Math.max(count, needed)
const dots = []
for (let i = 0; i < total; i++) {
const isFilled = i < count
dots.push(
<div
key={i}
className={css({
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: isFilled
? isDark
? 'green.400'
: 'green.500'
: isDark
? 'gray.600'
: 'gray.300',
transition: 'all 0.3s ease',
})}
title={isFilled ? `Problem ${i + 1}` : 'Not yet solved'}
/>
)
}
return (
<div
data-element="sample-dots"
className={css({
display: 'flex',
gap: '6px',
justifyContent: 'center',
flexWrap: 'wrap',
})}
>
{dots}
</div>
)
}
/**
* Speed visualization - shows average speed vs variation
*/
function SpeedMeter({
meanMs,
stdDevMs,
thresholdMs,
isDark,
}: {
meanMs: number
stdDevMs: number
thresholdMs: number
isDark: boolean
}) {
// Normalize values for display (0-100 scale based on threshold)
const meanPercent = Math.min(100, (meanMs / thresholdMs) * 100)
const variationPercent = Math.min(50, (stdDevMs / thresholdMs) * 100)
return (
<div
data-element="speed-meter"
className={css({
width: '100%',
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
})}
>
{/* Speed bar container */}
<div
className={css({
position: 'relative',
height: '24px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '12px',
overflow: 'visible',
})}
>
{/* Variation range (the "wiggle room") */}
<div
className={css({
position: 'absolute',
height: '100%',
backgroundColor: isDark ? 'blue.800' : 'blue.100',
borderRadius: '12px',
transition: 'all 0.5s ease',
})}
style={{
left: `${Math.max(0, meanPercent - variationPercent)}%`,
width: `${variationPercent * 2}%`,
}}
/>
{/* Average marker */}
<div
className={css({
position: 'absolute',
top: '-4px',
width: '8px',
height: '32px',
backgroundColor: isDark ? 'blue.400' : 'blue.500',
borderRadius: '4px',
transition: 'all 0.5s ease',
zIndex: 1,
})}
style={{
left: `calc(${meanPercent}% - 4px)`,
}}
/>
{/* Threshold marker */}
<div
className={css({
position: 'absolute',
top: '0',
width: '3px',
height: '100%',
backgroundColor: isDark ? 'yellow.500' : 'yellow.600',
borderRadius: '2px',
})}
style={{
left: 'calc(100% - 2px)',
}}
/>
</div>
{/* Labels */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginTop: '0.5rem',
fontSize: '0.6875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<span>Fast</span>
<span
className={css({
color: isDark ? 'blue.300' : 'blue.600',
fontWeight: 'bold',
})}
>
Your usual speed
</span>
<span>Pause</span>
</div>
</div>
)
}
export interface SessionPausedModalProps {
@ -103,11 +269,9 @@ export interface SessionPausedModalProps {
/**
* Session Paused Modal
*
* A unified modal shown when:
* 1. User pauses an active session (via the HUD controls)
* 2. User navigates back to a paused session
*
* Shows progress info and options to resume or end the session.
* A kid-friendly modal shown when a session is paused.
* Features educational explanations of statistics concepts
* like averages and variation in an approachable way.
*/
export function SessionPausedModal({
isOpen,
@ -150,10 +314,9 @@ export function SessionPausedModal({
const currentPart = session.parts[session.currentPartIndex]
// Format pause time
const pauseTimeStr = pauseInfo?.pausedAt
? pauseInfo.pausedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
: null
// Determine greeting based on pause reason
const isAutoTimeout = pauseInfo?.reason === 'auto-timeout'
const stats = pauseInfo?.autoPauseStats
return (
<div
@ -165,17 +328,14 @@ export function SessionPausedModal({
left: 0,
right: 0,
bottom: 0,
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.8)' : 'rgba(0, 0, 0, 0.6)',
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.85)' : 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
})}
onClick={(e) => {
// Don't close on backdrop click - must use buttons
e.stopPropagation()
}}
onClick={(e) => e.stopPropagation()}
>
<div
data-element="modal-content"
@ -183,212 +343,339 @@ export function SessionPausedModal({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1.5rem',
padding: '2rem',
maxWidth: '420px',
gap: '1.25rem',
padding: '1.5rem',
maxWidth: '380px',
width: '100%',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
borderRadius: '20px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
})}
onClick={(e) => e.stopPropagation()}
>
{/* Paused indicator */}
<div
className={css({
fontSize: '3rem',
})}
>
</div>
{/* Avatar and greeting */}
{/* Hero section with avatar */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
})}
>
{/* Thinking character */}
<div
className={css({
position: 'relative',
})}
>
<div
data-element="student-avatar"
className={css({
width: '64px',
height: '64px',
width: '72px',
height: '72px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
fontSize: '2.5rem',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</div>
<h2
{/* Thought bubble */}
<div
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
textAlign: 'center',
position: 'absolute',
top: '-8px',
right: '-12px',
fontSize: '1.5rem',
})}
>
Session Paused
{isAutoTimeout ? '🤔' : '☕'}
</div>
</div>
{/* Greeting - contextual based on reason */}
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: '1.375rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
marginBottom: '0.25rem',
})}
>
{isAutoTimeout ? 'Taking a Thinking Break!' : 'Break Time!'}
</h2>
<p
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.400' : 'gray.600',
textAlign: 'center',
})}
>
Take a break, {student.name}! Tap Resume when ready.
{isAutoTimeout
? `This one's a thinker, ${student.name}!`
: `Nice pause, ${student.name}!`}
</p>
</div>
</div>
{/* Pause details */}
{/* Break timer - make it feel positive */}
{pauseInfo && (
<div
data-element="pause-details"
data-element="break-timer"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 1rem',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '20px',
})}
>
<span className={css({ fontSize: '1rem' })}></span>
<span
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.300' : 'gray.600',
})}
>
Resting for{' '}
<strong
className={css({
color: isDark ? 'blue.300' : 'blue.600',
fontFamily: 'monospace',
})}
>
{formatDurationFriendly(pauseDuration)}
</strong>
</span>
</div>
)}
{/* Auto-pause explanation - educational and friendly */}
{isAutoTimeout && stats && (
<div
data-element="rhythm-explanation"
className={css({
width: '100%',
padding: '1rem',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
backgroundColor: isDark ? 'blue.900/50' : 'blue.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
})}
>
{stats.usedStatistics ? (
<>
{/* We know their rhythm */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.75rem',
})}
>
<span className={css({ fontSize: '1.25rem' })}>🎵</span>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'blue.200' : 'blue.700',
})}
>
We Know Your Rhythm!
</span>
</div>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.75rem',
lineHeight: '1.5',
})}
>
We watched you solve{' '}
<strong className={css({ color: isDark ? 'green.300' : 'green.600' })}>
{stats.sampleCount} problems
</strong>{' '}
and learned your speed! Usually you take{' '}
<strong className={css({ color: isDark ? 'blue.300' : 'blue.600' })}>
{formatSecondsFriendly(stats.meanMs)}
</strong>
.
</p>
{/* Speed visualization */}
<SpeedMeter
meanMs={stats.meanMs}
stdDevMs={stats.stdDevMs}
thresholdMs={stats.thresholdMs}
isDark={isDark}
/>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
marginTop: '0.75rem',
fontStyle: 'italic',
})}
>
The blue bar shows your usual range. This problem took longer, so we paused to
check in!
</p>
</>
) : (
<>
{/* Still learning their rhythm */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.75rem',
})}
>
<span className={css({ fontSize: '1.25rem' })}>📊</span>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'blue.200' : 'blue.700',
})}
>
Learning Your Rhythm...
</span>
</div>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.75rem',
lineHeight: '1.5',
})}
>
We need to watch you solve a few problems to learn how fast you usually go!
</p>
{/* Sample dots showing progress */}
<div className={css({ marginBottom: '0.75rem' })}>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '0.5rem',
textAlign: 'center',
})}
>
Problems solved: {stats.sampleCount} of 5 needed
</p>
<SampleDots count={stats.sampleCount} needed={5} isDark={isDark} />
</div>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'yellow.300' : 'yellow.700',
textAlign: 'center',
fontWeight: '500',
})}
>
{5 - stats.sampleCount} more problem{5 - stats.sampleCount !== 1 ? 's' : ''} until
we know your rhythm!
</p>
</>
)}
</div>
)}
{/* Manual pause - simple and encouraging */}
{pauseInfo?.reason === 'manual' && (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
backgroundColor: isDark ? 'green.900/50' : 'green.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.200',
})}
>
<span className={css({ fontSize: '1.25rem' })}></span>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'green.200' : 'green.700',
})}
>
Smart thinking to take a break when you need one!
</p>
</div>
)}
{/* Progress summary - celebratory */}
<div
className={css({
width: '100%',
padding: '0.75rem',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
{/* Pause timing */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<span
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Paused at {pauseTimeStr}
</span>
<span
className={css({
fontSize: '0.9375rem',
fontWeight: 'bold',
fontFamily: 'monospace',
color: isDark ? 'blue.300' : 'blue.600',
})}
>
{formatDuration(pauseDuration)}
</span>
</div>
{/* Auto-pause reason */}
{pauseInfo.reason === 'auto-timeout' && (
<div
data-element="auto-pause-reason"
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'yellow.900' : 'yellow.50',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'yellow.700' : 'yellow.200',
})}
>
<p
className={css({
fontSize: '0.8125rem',
fontWeight: 'bold',
color: isDark ? 'yellow.300' : 'yellow.700',
marginBottom: '0.5rem',
})}
>
Auto-paused: Taking longer than usual
</p>
{pauseInfo.autoPauseStats && (
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.300' : 'gray.600',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
>
{pauseInfo.autoPauseStats.usedStatistics ? (
<>
<p>
Based on {pauseInfo.autoPauseStats.sampleCount} problems: avg{' '}
{formatSeconds(pauseInfo.autoPauseStats.meanMs)} ±{' '}
{formatSeconds(pauseInfo.autoPauseStats.stdDevMs)}
</p>
<p>
Timeout threshold: {formatSeconds(pauseInfo.autoPauseStats.thresholdMs)}{' '}
(avg + 2×std dev)
</p>
</>
) : (
<p>
Using default {formatSeconds(pauseInfo.autoPauseStats.thresholdMs)} timeout
(need {5 - pauseInfo.autoPauseStats.sampleCount} more problems for
personalized timing)
</p>
)}
</div>
)}
</div>
)}
{/* Manual pause */}
{pauseInfo.reason === 'manual' && (
<p
<span
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
fontStyle: 'italic',
})}
>
Session paused manually
</p>
)}
</div>
)}
{/* Progress summary */}
<div className={css({ width: '100%', textAlign: 'center' })}>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '0.75rem',
})}
>
Problem <strong>{completedProblems + 1}</strong> of <strong>{totalProblems}</strong>
</p>
Progress
</span>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'green.300' : 'green.600',
})}
>
{completedProblems} of {totalProblems} done!
</span>
</div>
{/* Progress bar */}
{/* Progress bar with sparkle */}
<div
data-element="progress-bar"
className={css({
position: 'relative',
width: '100%',
height: '10px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
borderRadius: '5px',
height: '12px',
backgroundColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '6px',
overflow: 'hidden',
marginBottom: '0.75rem',
})}
>
<div
className={css({
height: '100%',
backgroundColor: isDark ? 'green.400' : 'green.500',
borderRadius: '5px',
background: isDark
? 'linear-gradient(90deg, #22c55e, #4ade80)'
: 'linear-gradient(90deg, #16a34a, #22c55e)',
borderRadius: '6px',
transition: 'width 0.3s ease',
})}
style={{ width: `${progressPercent}%` }}
@ -399,12 +686,13 @@ export function SessionPausedModal({
{currentPart && (
<p
className={css({
fontSize: '0.8125rem',
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
marginTop: '0.5rem',
textAlign: 'center',
})}
>
{getPartTypeEmoji(currentPart.type)} Part {session.currentPartIndex + 1}:{' '}
{getPartTypeLabel(currentPart.type)}
{getPartTypeEmoji(currentPart.type)} {getPartTypeLabel(currentPart.type)}
</p>
)}
</div>
@ -414,9 +702,8 @@ export function SessionPausedModal({
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
gap: '0.5rem',
width: '100%',
marginTop: '0.5rem',
})}
>
<button
@ -428,8 +715,8 @@ export function SessionPausedModal({
fontSize: '1.125rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: 'green.500',
borderRadius: '10px',
background: 'linear-gradient(135deg, #22c55e, #16a34a)',
borderRadius: '12px',
border: 'none',
cursor: 'pointer',
display: 'flex',
@ -437,17 +724,18 @@ export function SessionPausedModal({
justifyContent: 'center',
gap: '0.5rem',
transition: 'all 0.15s ease',
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.3)',
_hover: {
backgroundColor: 'green.400',
transform: 'translateY(-1px)',
transform: 'translateY(-2px)',
boxShadow: '0 6px 16px rgba(34, 197, 94, 0.4)',
},
_active: {
transform: 'translateY(0)',
},
})}
>
<span></span>
<span>Resume</span>
<span></span>
<span>Keep Going!</span>
</button>
<button
@ -455,19 +743,17 @@ export function SessionPausedModal({
data-action="end-session"
onClick={onEndSession}
className={css({
padding: '0.75rem',
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
padding: '0.625rem',
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
backgroundColor: 'transparent',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderColor: isDark ? 'red.700' : 'red.300',
color: isDark ? 'red.300' : 'red.600',
backgroundColor: isDark ? 'red.900/30' : 'red.50',
},
})}
>