feat(practice): add session HUD with tape-deck controls and PageWithNav

Major UI improvements to the practice session:
- Add dark control bar at top with session info and transport controls
- Replace pause/end buttons with tape-deck style buttons (⏸️/▶ and ⏹️)
- Move part type, problem count, and progress info into compact HUD
- Add overall progress counter (X/Y total) and health indicator
- Wrap practice page with PageWithNav for consistent app navigation
- Begin dark mode support with isDark prop from useTheme

🤖 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-06 19:16:13 -06:00
parent 871390d8e1
commit b19c6d0eca
2 changed files with 563 additions and 524 deletions

View File

@ -17,6 +17,8 @@ import {
OfflineSessionForm, OfflineSessionForm,
} from '@/components/practice/OfflineSessionForm' } from '@/components/practice/OfflineSessionForm'
import { PlacementTest } from '@/components/practice/PlacementTest' import { PlacementTest } from '@/components/practice/PlacementTest'
import { PageWithNav } from '@/components/PageWithNav'
import { useTheme } from '@/contexts/ThemeContext'
import type { SlotResult } from '@/db/schema/session-plans' import type { SlotResult } from '@/db/schema/session-plans'
import { usePlayerCurriculum } from '@/hooks/usePlayerCurriculum' import { usePlayerCurriculum } from '@/hooks/usePlayerCurriculum'
import { import {
@ -85,6 +87,9 @@ interface SessionConfig {
* 6. View summary * 6. View summary
*/ */
export default function PracticePage() { export default function PracticePage() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [viewState, setViewState] = useState<ViewState>('selecting') const [viewState, setViewState] = useState<ViewState>('selecting')
const [selectedStudent, setSelectedStudent] = useState<StudentWithProgress | null>(null) const [selectedStudent, setSelectedStudent] = useState<StudentWithProgress | null>(null)
const [sessionConfig, setSessionConfig] = useState<SessionConfig>({ const [sessionConfig, setSessionConfig] = useState<SessionConfig>({
@ -397,11 +402,12 @@ export default function PracticePage() {
} }
return ( return (
<PageWithNav>
<main <main
data-component="practice-page" data-component="practice-page"
className={css({ className={css({
minHeight: '100vh', minHeight: '100vh',
backgroundColor: 'gray.50', backgroundColor: isDark ? 'gray.900' : 'gray.50',
padding: viewState === 'practicing' ? '0' : '2rem', padding: viewState === 'practicing' ? '0' : '2rem',
})} })}
> >
@ -423,7 +429,7 @@ export default function PracticePage() {
className={css({ className={css({
fontSize: '2rem', fontSize: '2rem',
fontWeight: 'bold', fontWeight: 'bold',
color: 'gray.800', color: isDark ? 'white' : 'gray.800',
marginBottom: '0.5rem', marginBottom: '0.5rem',
})} })}
> >
@ -432,7 +438,7 @@ export default function PracticePage() {
<p <p
className={css({ className={css({
fontSize: '1rem', fontSize: '1rem',
color: 'gray.600', color: isDark ? 'gray.400' : 'gray.600',
})} })}
> >
Build your soroban skills one step at a time Build your soroban skills one step at a time
@ -838,5 +844,6 @@ export default function PracticePage() {
/> />
)} )}
</main> </main>
</PageWithNav>
) )
} }

View File

@ -560,55 +560,198 @@ export function ActiveSession({
minHeight: '100vh', minHeight: '100vh',
})} })}
> >
{/* Header with progress and health */} {/* Practice Session HUD - Control bar with session info and tape-deck controls */}
<div <div
data-section="session-header" data-section="session-hud"
className={css({ className={css({
display: 'flex', display: 'flex',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
padding: '0.75rem', gap: '0.75rem',
backgroundColor: 'white', padding: '0.75rem 1rem',
backgroundColor: 'gray.900',
borderRadius: '12px', borderRadius: '12px',
boxShadow: 'sm', boxShadow: 'lg',
})} })}
> >
<div> {/* Tape deck controls */}
<div <div
data-element="transport-controls"
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{/* Pause/Play button */}
<button
type="button"
data-action={isPaused ? 'resume' : 'pause'}
onClick={isPaused ? handleResume : handlePause}
className={css({
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
color: 'white',
backgroundColor: isPaused ? 'green.500' : 'gray.700',
borderRadius: '8px',
border: '2px solid',
borderColor: isPaused ? 'green.400' : 'gray.600',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isPaused ? 'green.400' : 'gray.600',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label={isPaused ? 'Resume session' : 'Pause session'}
>
{isPaused ? '▶' : '⏸'}
</button>
{/* Stop button */}
<button
type="button"
data-action="end-early"
onClick={() => onEndEarly('Session ended')}
className={css({
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
color: 'red.300',
backgroundColor: 'gray.700',
borderRadius: '8px',
border: '2px solid',
borderColor: 'gray.600',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: 'red.900',
borderColor: 'red.700',
color: 'red.200',
transform: 'scale(1.05)',
},
_active: {
transform: 'scale(0.95)',
},
})}
aria-label="End session"
>
</button>
</div>
{/* Session info display */}
<div
data-element="session-info"
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
})}
>
{/* Part type with emoji */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span
className={css({
fontSize: '1rem',
})}
>
{getPartTypeEmoji(currentPart.type)}
</span>
<span
className={css({ className={css({
fontSize: '0.875rem', fontSize: '0.875rem',
color: 'gray.500', fontWeight: 'bold',
color: 'white',
})} })}
> >
{studentName}'s Practice Part {currentPart.partNumber}: {getPartTypeLabel(currentPart.type)}
</span>
</div> </div>
{/* Progress within part */}
<div <div
className={css({ className={css({
fontSize: '1.25rem', fontSize: '0.75rem',
fontWeight: 'bold', color: 'gray.400',
color: 'gray.800',
})} })}
> >
Problem {completedProblems + 1} of {totalProblems} Problem {currentSlotIndex + 1} of {currentPart.slots.length} in this part
</div> </div>
</div> </div>
{/* Overall progress and health */}
<div
data-element="progress-display"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
})}
>
{/* Problem counter */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
})}
>
<div
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
fontFamily: 'monospace',
})}
>
{completedProblems + 1}/{totalProblems}
</div>
<div
className={css({
fontSize: '0.625rem',
color: 'gray.500',
textTransform: 'uppercase',
})}
>
Total
</div>
</div>
{/* Health indicator */}
{sessionHealth && ( {sessionHealth && (
<div <div
data-element="session-health" data-element="session-health"
className={css({ className={css({
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: '0.5rem', padding: '0.25rem 0.5rem',
padding: '0.5rem 1rem', backgroundColor: 'gray.800',
borderRadius: '20px', borderRadius: '6px',
backgroundColor: 'gray.50',
})} })}
> >
<span>{getHealthEmoji(sessionHealth.overall)}</span> <span className={css({ fontSize: '1rem' })}>
{getHealthEmoji(sessionHealth.overall)}
</span>
<span <span
className={css({ className={css({
fontSize: '0.875rem', fontSize: '0.625rem',
fontWeight: 'bold', fontWeight: 'bold',
color: getHealthColor(sessionHealth.overall), color: getHealthColor(sessionHealth.overall),
})} })}
@ -618,56 +761,25 @@ export function ActiveSession({
</div> </div>
)} )}
</div> </div>
</div>
{/* Part indicator */} {/* Part instruction banner - brief contextual hint */}
<div <div
data-element="part-indicator" data-element="part-instruction"
className={css({ className={css({
padding: '1rem', padding: '0.5rem 1rem',
backgroundColor: partColors.bg, backgroundColor: partColors.bg,
borderRadius: '12px', borderRadius: '8px',
border: '2px solid', border: '1px solid',
borderColor: partColors.border, borderColor: partColors.border,
textAlign: 'center', textAlign: 'center',
})}
>
<div
className={css({
fontSize: '1.5rem',
marginBottom: '0.25rem',
})}
>
{getPartTypeEmoji(currentPart.type)}
</div>
<div
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: partColors.text,
marginBottom: '0.25rem',
})}
>
Part {currentPart.partNumber}: {getPartTypeLabel(currentPart.type)}
</div>
<div
className={css({
fontSize: '0.875rem', fontSize: '0.875rem',
color: partColors.text, color: partColors.text,
})} })}
> >
{currentPart.type === 'abacus' && 'Use your physical abacus to solve these problems'} {currentPart.type === 'abacus' && '🧮 Use your physical abacus'}
{currentPart.type === 'visualization' && 'Picture the beads moving in your mind'} {currentPart.type === 'visualization' && '🧠 Picture the beads moving in your mind'}
{currentPart.type === 'linear' && 'Calculate the answer mentally'} {currentPart.type === 'linear' && '💭 Calculate the answer mentally'}
</div>
<div
className={css({
fontSize: '0.75rem',
color: partColors.text,
marginTop: '0.5rem',
})}
>
Problem {currentSlotIndex + 1} of {currentPart.slots.length} in this part
</div>
</div> </div>
{/* Problem display */} {/* Problem display */}
@ -906,86 +1018,6 @@ export function ActiveSession({
</div> </div>
)} )}
{/* Teacher controls */}
<div
data-section="teacher-controls"
className={css({
display: 'flex',
gap: '0.75rem',
marginTop: 'auto',
paddingTop: '1rem',
borderTop: '1px solid',
borderColor: 'gray.200',
})}
>
{isPaused ? (
<button
type="button"
data-action="resume"
onClick={handleResume}
className={css({
flex: 1,
padding: '0.75rem',
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: 'green.500',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor: 'green.600',
},
})}
>
Resume Practice
</button>
) : (
<button
type="button"
data-action="pause"
onClick={handlePause}
className={css({
flex: 1,
padding: '0.75rem',
fontSize: '0.875rem',
color: 'gray.600',
backgroundColor: 'gray.100',
borderRadius: '8px',
border: '1px solid',
borderColor: 'gray.200',
cursor: 'pointer',
_hover: {
backgroundColor: 'gray.200',
},
})}
>
Pause
</button>
)}
<button
type="button"
data-action="end-early"
onClick={() => onEndEarly('Teacher ended session')}
className={css({
padding: '0.75rem 1.5rem',
fontSize: '0.875rem',
color: 'red.600',
backgroundColor: 'red.50',
borderRadius: '8px',
border: '1px solid',
borderColor: 'red.200',
cursor: 'pointer',
_hover: {
backgroundColor: 'red.100',
},
})}
>
End Session
</button>
</div>
{/* Pause overlay */} {/* Pause overlay */}
{isPaused && ( {isPaused && (
<div <div