feat: add auto-submit on correct answer + Newton poem blog post

Practice session improvements:
- Auto-submit when correct answer entered with ≤2 corrections
- Show celebration animation ("Perfect!") before auto-submit
- Display prefix sum checkmarks/arrows before clicking "Get Help"

New blog post: "The Fluxion of Fortune"
- Poem about Newton losing money in the South Sea Bubble
- Hero image of Newton with his calculations and sinking ships
- Custom CSS for properly centered x-bar notation

🤖 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-07 06:46:02 -06:00
parent 4f25e1fd52
commit 2f7cb03c3f
5 changed files with 320 additions and 61 deletions

View File

@ -131,11 +131,14 @@
"Bash(docs/DAILY_PRACTICE_SYSTEM.md )",
"Bash(../../README.md )",
"Bash(.claude/CLAUDE.md)",
"Bash(mcp__sqlite__describe_table:*)"
"Bash(mcp__sqlite__describe_table:*)",
"Bash(ls:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["sqlite"]
"enabledMcpjsonServers": [
"sqlite"
]
}

View File

@ -0,0 +1,45 @@
---
title: "The Fluxion of Fortune"
description: "for Isaac, who mastered the Mint but not the market"
author: "Abaci.one Team"
publishedAt: "2025-12-07"
updatedAt: "2025-12-07"
tags: ["poetry", "history", "mathematics", "newton", "calculus"]
featured: false
---
![Newton contemplating his losses](/blog/newton-fluxional-lament.jpeg)
**Pronunciation Guide:** ẋ is "ex dot" · <span style="position: relative;">x<span style="position: absolute; top: -0.25em; left: 0; right: 0; text-align: center;">̄</span></span> is "ex bar"
---
In Woolsthorpe nights of candle-glow,
He watched the apple fall just so—
A whisper of ẋ murmured low,
A hint of what he'd one day know.
He carved the world in curves of light,
Bent stars with math he birthed outright;
But markets?—aye, a different fight,
Whose tides he failed to fluxion right.
For coin obeys no cosmic plot,
It drifts where fevered humors rot;
He'd trace a comet's path on the spot—
Yet never guessed his stocks were naught.
He could have tamed each rising bar,
Mapped fortunes by <span style="position: relative;">x<span style="position: absolute; top: -0.25em; left: 0; right: 0; text-align: center;">̄</span></span> afar,
And steered his purse like some bright star—
Not wrecked upon a South Sea scar.
But genius trips where mortals trot,
And even Newton, spared no blot,
Might've saved his gold—had he used ẋ
To see the crash begot.
And thus his wealth slipped through the sieve,
With no derivative to give;
He mastered laws by which stars live,
But found the Market... negative.

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

View File

@ -1,6 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { StudentHelpSettings } from '@/db/schema/players'
import type {
GeneratedProblem,
ProblemConstraints,
@ -10,8 +12,6 @@ import type {
SessionPlan,
SlotResult,
} from '@/db/schema/session-plans'
import type { StudentHelpSettings } from '@/db/schema/players'
import { useTheme } from '@/contexts/ThemeContext'
import { usePracticeHelp } from '@/hooks/usePracticeHelp'
import { createBasicSkillSet, type SkillSet } from '@/types/tutorial'
import {
@ -220,6 +220,10 @@ export function ActiveSession({
const [confirmedTermCount, setConfirmedTermCount] = useState(0)
// Which term we're currently showing help for (null = not showing help)
const [helpTermIndex, setHelpTermIndex] = useState<number | null>(null)
// Track corrections for auto-submit (allow 1 correction, then require manual submit)
const [correctionCount, setCorrectionCount] = useState(0)
// Track if auto-submit was triggered (for celebration animation)
const [autoSubmitTriggered, setAutoSubmitTriggered] = useState(false)
const hasPhysicalKeyboard = useHasPhysicalKeyboard()
@ -374,35 +378,17 @@ export function ActiveSession({
}
}, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, currentProblem])
// Handle keyboard input
useEffect(() => {
if (!hasPhysicalKeyboard || isPaused || !currentProblem || isSubmitting) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
setUserAnswer((prev) => prev.slice(0, -1))
} else if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (/^[0-9]$/.test(e.key)) {
setUserAnswer((prev) => prev + e.key)
} else if (e.key === '-' && userAnswer.length === 0) {
// Allow negative sign at start
setUserAnswer('-')
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [hasPhysicalKeyboard, isPaused, currentProblem, isSubmitting, userAnswer])
const handleDigit = useCallback((digit: string) => {
setUserAnswer((prev) => prev + digit)
}, [])
const handleBackspace = useCallback(() => {
setUserAnswer((prev) => prev.slice(0, -1))
setUserAnswer((prev) => {
if (prev.length > 0) {
setCorrectionCount((c) => c + 1)
}
return prev.slice(0, -1)
})
}, [])
// Handle "Get Help" button - show help for the next term
@ -494,6 +480,8 @@ export function ActiveSession({
setConfirmedTermCount(0)
setHelpTermIndex(null)
setIsSubmitting(false)
setCorrectionCount(0)
setAutoSubmitTriggered(false)
},
isCorrect ? 500 : 1500
)
@ -510,6 +498,55 @@ export function ActiveSession({
helpActions,
])
// Auto-submit when correct answer is entered on first attempt (allow minor corrections)
useEffect(() => {
if (!currentProblem || isSubmitting || feedback !== 'none' || !userAnswer) return
// Allow up to 2 backspaces (one typo fix), but no more
if (correctionCount > 2) return
const answerNum = parseInt(userAnswer, 10)
if (Number.isNaN(answerNum)) return
// Check if answer matches
if (answerNum === currentProblem.problem.answer) {
// Trigger auto-submit with celebration
setAutoSubmitTriggered(true)
// Small delay to show the celebration animation before submitting
const timer = setTimeout(() => {
handleSubmit()
}, 400)
return () => clearTimeout(timer)
}
}, [userAnswer, currentProblem, isSubmitting, feedback, correctionCount, handleSubmit])
// Handle keyboard input (placed after handleSubmit to avoid temporal dead zone)
useEffect(() => {
if (!hasPhysicalKeyboard || isPaused || !currentProblem || isSubmitting) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
setUserAnswer((prev) => {
if (prev.length > 0) {
setCorrectionCount((c) => c + 1)
}
return prev.slice(0, -1)
})
} else if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (/^[0-9]$/.test(e.key)) {
setUserAnswer((prev) => prev + e.key)
} else if (e.key === '-' && userAnswer.length === 0) {
// Allow negative sign at start
setUserAnswer('-')
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [hasPhysicalKeyboard, isPaused, currentProblem, isSubmitting, userAnswer, handleSubmit])
const handlePause = useCallback(() => {
setIsPaused(true)
onPause?.()
@ -891,6 +928,12 @@ export function ActiveSession({
size="large"
confirmedTermCount={confirmedTermCount}
currentHelpTermIndex={helpTermIndex ?? undefined}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
autoSubmitPending={autoSubmitTriggered}
/>
) : (
<LinearProblem
@ -951,6 +994,71 @@ export function ActiveSession({
</button>
</div>
{/* Provenance breakdown - shows which digits come from the term */}
{helpState.content?.beadSteps && helpState.content.beadSteps.length > 0 && (
<div
data-element="provenance-breakdown"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '0.75rem',
padding: '0.5rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'purple.700' : 'purple.200',
})}
>
{/* Group steps by unique provenance to show digit breakdown */}
{(() => {
const seenPlaces = new Set<string>()
return helpState.content?.beadSteps
.filter((step) => {
if (!step.provenance) return false
const key = `${step.provenance.rhsPlace}-${step.provenance.rhsDigit}`
if (seenPlaces.has(key)) return false
seenPlaces.add(key)
return true
})
.map((step, idx) => {
const prov = step.provenance
if (!prov) return null
return (
<div
key={idx}
data-element="provenance-chip"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
backgroundColor: isDark ? 'purple.800' : 'purple.100',
borderRadius: '6px',
fontSize: '0.75rem',
})}
>
<span
className={css({
fontWeight: 'bold',
color: isDark ? 'purple.200' : 'purple.800',
})}
>
{prov.rhsDigit}
</span>
<span className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>
{prov.rhsPlaceName}
</span>
<span className={css({ color: isDark ? 'gray.500' : 'gray.400' })}>
= {prov.rhsValue}
</span>
</div>
)
})
})()}
</div>
)}
<HelpAbacus
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}

View File

@ -20,6 +20,10 @@ interface VerticalProblemProps {
confirmedTermCount?: number
/** Index of the term currently being helped with (highlighted) */
currentHelpTermIndex?: number
/** Detected prefix index - shows preview checkmarks/arrow before user clicks "Get Help" */
detectedPrefixIndex?: number
/** Whether auto-submit is about to trigger (shows celebration animation) */
autoSubmitPending?: boolean
}
/**
@ -40,6 +44,8 @@ export function VerticalProblem({
size = 'normal',
confirmedTermCount = 0,
currentHelpTermIndex,
detectedPrefixIndex,
autoSubmitPending = false,
}: VerticalProblemProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -113,29 +119,52 @@ export function VerticalProblem({
// Term status for highlighting
const isConfirmed = index < confirmedTermCount
const isCurrentHelp = index === currentHelpTermIndex
// Preview states - shown when user's input matches a prefix sum (before clicking "Get Help")
const isPreviewConfirmed =
detectedPrefixIndex !== undefined && index <= detectedPrefixIndex && !isConfirmed
const isPreviewNext =
detectedPrefixIndex !== undefined &&
index === detectedPrefixIndex + 1 &&
!isCurrentHelp &&
detectedPrefixIndex < terms.length - 1 // Don't show if prefix is the full answer
return (
<div
key={index}
data-element="term-row"
data-term-status={isConfirmed ? 'confirmed' : isCurrentHelp ? 'current' : 'pending'}
data-term-status={
isConfirmed
? 'confirmed'
: isCurrentHelp
? 'current'
: isPreviewConfirmed
? 'preview-confirmed'
: isPreviewNext
? 'preview-next'
: 'pending'
}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2px',
position: 'relative',
// Confirmed terms are dimmed with checkmark
opacity: isConfirmed ? 0.5 : 1,
opacity: isConfirmed ? 0.5 : isPreviewConfirmed ? 0.7 : 1,
// Current help term is highlighted
backgroundColor: isCurrentHelp
? isDark
? 'purple.800'
: 'purple.100'
: 'transparent',
borderRadius: isCurrentHelp ? '4px' : '0',
padding: isCurrentHelp ? '2px 4px' : '0',
marginLeft: isCurrentHelp ? '-4px' : '0',
marginRight: isCurrentHelp ? '-4px' : '0',
: isPreviewNext
? isDark
? 'yellow.900'
: 'yellow.50'
: 'transparent',
borderRadius: isCurrentHelp || isPreviewNext ? '4px' : '0',
padding: isCurrentHelp || isPreviewNext ? '2px 4px' : '0',
marginLeft: isCurrentHelp || isPreviewNext ? '-4px' : '0',
marginRight: isCurrentHelp || isPreviewNext ? '-4px' : '0',
transition: 'all 0.2s ease',
})}
>
{/* Checkmark for confirmed terms */}
@ -153,6 +182,22 @@ export function VerticalProblem({
</div>
)}
{/* Preview checkmark for detected prefix terms (shown in muted color with subtle pulse) */}
{isPreviewConfirmed && (
<div
data-element="preview-check"
className={css({
position: 'absolute',
left: '-1.5rem',
color: isDark ? 'yellow.400' : 'yellow.600',
fontSize: '0.875rem',
opacity: 0.8,
})}
>
</div>
)}
{/* Arrow indicator for current help term */}
{isCurrentHelp && (
<div
@ -168,6 +213,21 @@ export function VerticalProblem({
</div>
)}
{/* Preview arrow for next term after detected prefix */}
{isPreviewNext && (
<div
data-element="preview-arrow"
className={css({
position: 'absolute',
left: '-1.5rem',
color: isDark ? 'yellow.400' : 'yellow.600',
fontSize: '0.875rem',
})}
>
</div>
)}
{/* Operator column (only show minus for negative) */}
<div
data-element="operator"
@ -226,12 +286,45 @@ export function VerticalProblem({
{/* Answer row */}
<div
data-element="answer-row"
data-auto-submit={autoSubmitPending ? 'pending' : undefined}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2px',
position: 'relative',
// Auto-submit celebration animation
...(autoSubmitPending && {
animation: 'successPulse 0.3s ease-out',
}),
})}
>
{/* Auto-submit celebration indicator */}
{autoSubmitPending && (
<div
data-element="auto-submit-indicator"
className={css({
position: 'absolute',
top: '-1.5rem',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.125rem 0.5rem',
backgroundColor: isDark ? 'green.700' : 'green.100',
borderRadius: '999px',
fontSize: '0.625rem',
fontWeight: 'bold',
color: isDark ? 'green.200' : 'green.700',
whiteSpace: 'nowrap',
animation: 'bounceIn 0.3s ease-out',
zIndex: 10,
})}
>
<span></span>
<span>Perfect!</span>
</div>
)}
{/* Equals sign column */}
<div
data-element="equals"
@ -268,36 +361,46 @@ export function VerticalProblem({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isCompleted
? isCorrect
? isDark
? 'green.800'
: 'green.100'
backgroundColor: autoSubmitPending
? isDark
? 'green.800'
: 'green.100'
: isCompleted
? isCorrect
? isDark
? 'green.800'
: 'green.100'
: isDark
? 'red.800'
: 'red.100'
: isDark
? 'red.800'
: 'red.100'
: isDark
? 'gray.700'
: 'white',
? 'gray.700'
: 'white',
borderRadius: '4px',
border: isEmpty && !isCompleted ? '1px dashed' : '1px solid',
borderColor: isCompleted
? isCorrect
? isDark
? 'green.600'
: 'green.300'
: isDark
? 'red.600'
: 'red.300'
: isEmpty
? isFocused
? 'blue.400'
border:
isEmpty && !isCompleted && !autoSubmitPending ? '1px dashed' : '1px solid',
borderColor: autoSubmitPending
? isDark
? 'green.500'
: 'green.400'
: isCompleted
? isCorrect
? isDark
? 'green.600'
: 'green.300'
: isDark
? 'red.600'
: 'red.300'
: isEmpty
? isFocused
? 'blue.400'
: isDark
? 'gray.600'
: 'gray.300'
: isDark
? 'gray.600'
: 'gray.300'
: isDark
? 'gray.600'
: 'gray.300',
: 'gray.300',
transition: 'all 0.15s ease-out',
color: isCompleted
? isCorrect
? isDark