fix: resolve abacus sizing and prefix matching issues in memory quiz

- Increase abacus SVG dimensions from 200pt×250pt to 600pt×500pt for better visibility during memory flash
- Remove SVG width/height attributes during optimization to allow proper CSS scaling
- Add transparent background support to TypstSoroban component
- Fix prefix matching logic to exclude already found numbers, resolving issue where entering 555 was blocked after finding 55
- Improve memory quiz card grid layout with adaptive sizing and better responsive design

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-09-15 11:13:50 -05:00
parent 6394218667
commit b1db02851c
3 changed files with 207 additions and 51 deletions

View File

@ -187,16 +187,12 @@ const generateQuizCards = (count: number, difficulty: DifficultyLevel): QuizCard
return numbers.map(number => ({
number,
svgComponent: <div style={{
transform: 'scale(2.0)',
transformOrigin: 'center'
}}>
<TypstSoroban
number={number}
width="280pt"
height="360pt"
/>
</div>,
svgComponent: <TypstSoroban
number={number}
width="600pt"
height="500pt"
transparent={true}
/>,
element: null
}))
}
@ -496,8 +492,8 @@ function DisplayPhase({ state, dispatch }: { state: SorobanQuizState; dispatch:
{showCard && currentCard && (
<div className={css({
width: 'min(80vw, 600px)',
height: 'min(40vh, 350px)',
width: 'min(90vw, 800px)',
height: 'min(70vh, 600px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -518,10 +514,35 @@ function DisplayPhase({ state, dispatch }: { state: SorobanQuizState; dispatch:
)
}
// Visual card grid component
// Visual card grid component with adaptive layout
function CardGrid({ state }: { state: SorobanQuizState }) {
if (state.quizCards.length === 0) return null
// Calculate optimal grid layout based on number of cards
const cardCount = state.quizCards.length
// Define static grid classes that Panda can generate
const getGridClass = (count: number) => {
if (count <= 2) return 'repeat(2, 1fr)'
if (count <= 4) return 'repeat(2, 1fr)'
if (count <= 6) return 'repeat(3, 1fr)'
if (count <= 9) return 'repeat(3, 1fr)'
if (count <= 12) return 'repeat(4, 1fr)'
return 'repeat(5, 1fr)'
}
const getCardSize = (count: number) => {
if (count <= 2) return { minSize: '180px', cardHeight: '160px' }
if (count <= 4) return { minSize: '160px', cardHeight: '150px' }
if (count <= 6) return { minSize: '140px', cardHeight: '140px' }
if (count <= 9) return { minSize: '120px', cardHeight: '130px' }
if (count <= 12) return { minSize: '110px', cardHeight: '120px' }
return { minSize: '100px', cardHeight: '110px' }
}
const gridClass = getGridClass(cardCount)
const cardSize = getCardSize(cardCount)
return (
<div className={css({
marginTop: '16px',
@ -529,22 +550,38 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
background: 'gray.50',
borderRadius: '12px',
border: '1px solid',
borderColor: 'gray.200'
borderColor: 'gray.200',
maxHeight: '60vh',
overflowY: 'auto'
})}>
<h4 className={css({
textAlign: 'center',
color: 'gray.700',
marginBottom: '12px',
marginBottom: '16px',
fontSize: '16px',
fontWeight: '600'
})}>Cards you saw:</h4>
<div className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: '12px',
maxWidth: '100%',
margin: '0 auto'
})}>
})}>Cards you saw ({cardCount}):</h4>
<div
className={css({
display: 'grid',
gap: '12px',
maxWidth: '100%',
margin: '0 auto',
width: 'fit-content',
// Responsive overrides with static values
'@media (max-width: 768px)': {
gap: '10px'
},
'@media (max-width: 480px)': {
gap: '8px'
}
})}
style={{
gridTemplateColumns: gridClass
}}
>
{state.quizCards.map((card, index) => {
const isRevealed = state.foundNumbers.includes(card.number)
return (
@ -552,8 +589,22 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
key={`card-${index}-${card.number}`}
className={css({
perspective: '1000px',
height: '140px'
maxWidth: '200px',
// Static responsive sizing fallbacks
'@media (max-width: 768px)': {
height: '130px',
minWidth: '100px'
},
'@media (max-width: 480px)': {
height: '120px',
minWidth: '90px'
}
})}
style={{
height: cardSize.cardHeight,
minWidth: cardSize.minSize
}}
>
<div className={css({
position: 'relative',
@ -578,6 +629,14 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
color: 'white',
fontSize: '48px',
// Responsive font sizing
'@media (max-width: 768px)': {
fontSize: '40px'
},
'@media (max-width: 480px)': {
fontSize: '32px'
},
fontWeight: 'bold',
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
border: '3px solid #5f3dc4'
@ -585,7 +644,7 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
<div className={css({ opacity: 0.8 })}>?</div>
</div>
{/* Card front (revealed state) - using ServerSorobanSVG */}
{/* Card front (revealed state) */}
<div className={css({
position: 'absolute',
width: '100%',
@ -605,18 +664,14 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
overflow: 'hidden'
})}>
<div style={{
transform: 'scale(2.8)',
transformOrigin: 'center'
}}>
<TypstSoroban
number={card.number}
width="100pt"
height="130pt"
/>
</div>
<TypstSoroban
number={card.number}
width="120pt"
height="160pt"
/>
</div>
</div>
</div>
@ -624,6 +679,28 @@ function CardGrid({ state }: { state: SorobanQuizState }) {
)
})}
</div>
{/* Summary row for large numbers of cards */}
{cardCount > 8 && (
<div className={css({
marginTop: '12px',
padding: '8px 12px',
background: 'blue.50',
borderRadius: '8px',
border: '1px solid',
borderColor: 'blue.200',
textAlign: 'center',
fontSize: '14px',
color: 'blue.700'
})}>
<strong>{state.foundNumbers.length}</strong> of <strong>{cardCount}</strong> cards found
{state.foundNumbers.length > 0 && (
<span className={css({ marginLeft: '8px', fontWeight: 'normal' })}>
({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete)
</span>
)}
</div>
)}
</div>
)
}
@ -633,8 +710,12 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
const containerRef = useRef<HTMLDivElement>(null)
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>('neutral')
const isPrefix = useCallback((input: string, numbers: number[]) => {
return numbers.some(n => n.toString().startsWith(input) && n.toString() !== input)
const isPrefix = useCallback((input: string, numbers: number[], foundNumbers: number[]) => {
return numbers.some(n =>
n.toString().startsWith(input) &&
n.toString() !== input &&
!foundNumbers.includes(n)
)
}, [])
const handleKeyPress = useCallback((e: KeyboardEvent) => {
@ -660,7 +741,7 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
// Check if correct and not already found
if (state.correctAnswers.includes(number) && !state.foundNumbers.includes(number)) {
if (!isPrefix(newInput, state.correctAnswers)) {
if (!isPrefix(newInput, state.correctAnswers, state.foundNumbers)) {
acceptCorrectNumber(number)
} else {
const timeout = setTimeout(() => {
@ -895,8 +976,13 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
</div>
{/* Visual card grid showing cards the user was shown - now more compact */}
<div className={css({ marginTop: '16px', flex: 1, overflow: 'auto' })}>
{/* Visual card grid showing cards the user was shown */}
<div className={css({
marginTop: '16px',
flex: 1,
overflow: 'auto',
minHeight: '0' // Allow flex child to shrink
})}>
<CardGrid state={state} />
</div>

View File

@ -14,6 +14,7 @@ interface TypstSorobanProps {
onSuccess?: () => void
enableServerFallback?: boolean
lazy?: boolean // New prop for lazy loading
transparent?: boolean // New prop for transparent background
}
export function TypstSoroban({
@ -24,7 +25,8 @@ export function TypstSoroban({
onError,
onSuccess,
enableServerFallback = false,
lazy = false
lazy = false,
transparent = false
}: TypstSorobanProps) {
const [svg, setSvg] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(!lazy) // Don't start loading if lazy
@ -66,6 +68,7 @@ export function TypstSoroban({
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
transparent,
enableServerFallback
}
@ -105,6 +108,7 @@ export function TypstSoroban({
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
transparent,
enableServerFallback
})
@ -121,7 +125,7 @@ export function TypstSoroban({
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
transparent: false,
transparent,
enableServerFallback
}
@ -162,7 +166,7 @@ export function TypstSoroban({
abortControllerRef.current.abort()
}
}
}, [shouldLoad, number, width, height, stableConfig, enableServerFallback])
}, [shouldLoad, number, width, height, stableConfig, enableServerFallback, transparent])
// Handler to trigger loading on user interaction
const handleLoadTrigger = useCallback(() => {
@ -289,10 +293,10 @@ export function TypstSoroban({
alignItems: 'center',
justifyContent: 'center',
'& svg': {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto'
maxHeight: '100%'
}
})}
dangerouslySetInnerHTML={{ __html: svg }}

View File

@ -19,6 +19,65 @@ if (typeof window !== 'undefined') {
}, 100) // Small delay to avoid blocking initial render
}
// SVG viewBox optimization - crops SVG to actual content bounds
function optimizeSvgViewBox(svgString: string): string {
try {
// Parse SVG to analyze content bounds
const parser = new DOMParser()
const doc = parser.parseFromString(svgString, 'image/svg+xml')
const svgElement = doc.querySelector('svg')
if (!svgElement) return svgString
// Create a temporary element to measure bounds
const tempDiv = document.createElement('div')
tempDiv.style.position = 'absolute'
tempDiv.style.visibility = 'hidden'
tempDiv.style.top = '-9999px'
tempDiv.innerHTML = svgString
document.body.appendChild(tempDiv)
const tempSvg = tempDiv.querySelector('svg')
if (!tempSvg) {
document.body.removeChild(tempDiv)
return svgString
}
// Get the bounding box of all content
try {
const bbox = tempSvg.getBBox()
document.body.removeChild(tempDiv)
// Add small padding around content (5% of content dimensions)
const padding = Math.max(bbox.width, bbox.height) * 0.05
const newX = Math.max(0, bbox.x - padding)
const newY = Math.max(0, bbox.y - padding)
const newWidth = bbox.width + (2 * padding)
const newHeight = bbox.height + (2 * padding)
// Update the viewBox to crop to content bounds
const newViewBox = `${newX} ${newY} ${newWidth} ${newHeight}`
// Replace viewBox and remove fixed dimensions to allow CSS scaling
let optimizedSvg = svgString
.replace(/viewBox="[^"]*"/, `viewBox="${newViewBox}"`)
.replace(/<svg[^>]*width="[^"]*"/, (match) => match.replace(/width="[^"]*"/, ''))
.replace(/<svg[^>]*height="[^"]*"/, (match) => match.replace(/height="[^"]*"/, ''))
console.log(`📐 Optimized SVG: ${bbox.width.toFixed(1)}×${bbox.height.toFixed(1)} content bounds, viewBox optimized for CSS scaling`)
return optimizedSvg
} catch (bboxError) {
document.body.removeChild(tempDiv)
console.warn('Could not get SVG bounding box, returning original:', bboxError)
return svgString
}
} catch (error) {
console.warn('SVG optimization failed, returning original:', error)
return svgString
}
}
// Preload WASM and template without blocking - starts in background
async function preloadTypstWasm() {
if ($typst || isPreloading || typstInitializationPromise) return
@ -262,8 +321,8 @@ ${template}
#align(center + horizon)[
#box(
width: ${width} - 2 * (${width} * 0.05),
height: ${height} - 2 * (${height} * 0.05)
width: ${width},
height: ${height}
)[
#align(center + horizon)[
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
@ -410,7 +469,10 @@ async function generateSVGInBrowser(config: SorobanConfig): Promise<string> {
console.log('✅ Generated browser SVG, length:', svg.length)
return svg
// Optimize viewBox to crop to actual content bounds
const optimizedSvg = optimizeSvgViewBox(svg)
return optimizedSvg
}
async function generateSVGOnServer(config: SorobanConfig): Promise<string> {
@ -435,7 +497,11 @@ async function generateSVGOnServer(config: SorobanConfig): Promise<string> {
}
console.log('🔄 Generated SVG on server, length:', data.svg.length)
return data.svg
// Optimize viewBox to crop to actual content bounds
const optimizedSvg = optimizeSvgViewBox(data.svg)
return optimizedSvg
}
export async function generateSorobanPreview(