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:
parent
4f25e1fd52
commit
2f7cb03c3f
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
||||

|
||||
|
||||
**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 |
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue