Add constraint-guided example generation with variety improvements

- Add constraint parser for flowchart problem generation
- Add interestingness constraints to prevent trivially easy problems:
  - fraction-add-sub: notIdentical, meaningfulSubtraction, nonTrivialNumerators
  - subtraction-regrouping: meaningfulDifference, notTooEasy
- Fix deterministic example selection with randomized picking from
  smaller half of candidates sorted by numeric sum
- Add deduplication to prevent duplicate examples from biasing selection
- Add TeacherConfigPanel for flowchart practice configuration
- Add InteractiveDice component for random problem generation UI
- Add MathML type declarations
- Various UI improvements to flowchart walker components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2026-01-16 09:22:39 -06:00
parent 2082710ab2
commit 0d0c9f4e73
21 changed files with 3922 additions and 860 deletions

View File

@@ -1,12 +1,11 @@
'use client'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { animated, useSpring } from '@react-spring/web'
import { css } from '@styled/css'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { InteractiveDice } from '@/components/ui/InteractiveDice'
import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal'
import { useTheme } from '@/contexts/ThemeContext'
import { extractConfigFields } from '../utils/extractConfigFields'
@@ -18,199 +17,6 @@ import { WorksheetPreview } from './WorksheetPreview'
import { DuplicateWarningBanner } from './worksheet-preview/DuplicateWarningBanner'
import { WorksheetPreviewProvider } from './worksheet-preview/WorksheetPreviewContext'
// Dot patterns for each dice face (positions as fractions of face size)
const DICE_DOT_PATTERNS: Record<number, Array<[number, number]>> = {
1: [[0.5, 0.5]],
2: [
[0.25, 0.25],
[0.75, 0.75],
],
3: [
[0.25, 0.25],
[0.5, 0.5],
[0.75, 0.75],
],
4: [
[0.25, 0.25],
[0.75, 0.25],
[0.25, 0.75],
[0.75, 0.75],
],
5: [
[0.25, 0.25],
[0.75, 0.25],
[0.5, 0.5],
[0.25, 0.75],
[0.75, 0.75],
],
6: [
[0.25, 0.2],
[0.25, 0.5],
[0.25, 0.8],
[0.75, 0.2],
[0.75, 0.5],
[0.75, 0.8],
],
}
// Rotation needed to show each face
// Standard dice: 1 opposite 6, 2 opposite 5, 3 opposite 4
const DICE_FACE_ROTATIONS: Record<number, { rotateX: number; rotateY: number }> = {
1: { rotateX: 0, rotateY: 0 }, // front
2: { rotateX: 0, rotateY: -90 }, // right
3: { rotateX: -90, rotateY: 0 }, // top
4: { rotateX: 90, rotateY: 0 }, // bottom
5: { rotateX: 0, rotateY: 90 }, // left
6: { rotateX: 0, rotateY: 180 }, // back
}
/**
* 3D Dice Icon using react-spring for smooth animations
*
* Creates a cube with 6 faces, each showing the appropriate dot pattern.
* The cube rotates on all 3 axes when rolling, with physics-based easing.
*/
function DiceIcon({
className,
rotateX,
rotateY,
rotateZ,
isDark,
}: {
className?: string
rotateX: number
rotateY: number
rotateZ: number
isDark: boolean
}) {
const size = 22
const halfSize = size / 2
// Animate rotation with react-spring
const springProps = useSpring({
rotateX,
rotateY,
rotateZ,
config: {
tension: 120,
friction: 14,
},
})
// Theme-aware colors
// Dark mode: lighter indigo with more contrast against dark backgrounds
// Light mode: deeper indigo that stands out against light backgrounds
const faceBackground = isDark ? '#818cf8' : '#4f46e5' // indigo-400 dark, indigo-600 light
const faceBorder = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(255, 255, 255, 0.5)'
const dotColor = isDark ? '#1e1b4b' : 'white' // indigo-950 dots on light bg in dark mode
// Render dots for a face
const renderDots = (face: number) => {
const dots = DICE_DOT_PATTERNS[face] || []
return dots.map(([x, y], i) => (
<div
key={i}
style={{
position: 'absolute',
left: `${x * 100}%`,
top: `${y * 100}%`,
width: '18%',
height: '18%',
backgroundColor: dotColor,
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
))
}
// Common face styles - opaque background to prevent artifacts during rotation
const faceStyle: React.CSSProperties = {
position: 'absolute',
width: size,
height: size,
backgroundColor: faceBackground,
border: `1.5px solid ${faceBorder}`,
borderRadius: 2,
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
}
return (
<div
className={className}
style={{
width: size,
height: size,
perspective: 100,
perspectiveOrigin: 'center',
}}
>
<animated.div
data-dice-cube
style={{
width: size,
height: size,
position: 'relative',
transformStyle: 'preserve-3d',
transform: springProps.rotateX.to(
(rx) =>
`rotateX(${rx}deg) rotateY(${springProps.rotateY.get()}deg) rotateZ(${springProps.rotateZ.get()}deg)`
),
}}
>
{/* Front face (1) */}
<div style={{ ...faceStyle, transform: `translateZ(${halfSize}px)` }}>{renderDots(1)}</div>
{/* Back face (6) */}
<div
style={{
...faceStyle,
transform: `rotateY(180deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(6)}
</div>
{/* Right face (2) */}
<div
style={{
...faceStyle,
transform: `rotateY(90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(2)}
</div>
{/* Left face (5) */}
<div
style={{
...faceStyle,
transform: `rotateY(-90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(5)}
</div>
{/* Top face (3) */}
<div
style={{
...faceStyle,
transform: `rotateX(90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(3)}
</div>
{/* Bottom face (4) */}
<div
style={{
...faceStyle,
transform: `rotateX(-90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(4)}
</div>
</animated.div>
</div>
)
}
interface PreviewCenterProps {
formState: WorksheetFormState
initialPreview?: string[]
@@ -242,261 +48,7 @@ export function PreviewCenter({
const [isLoadShareModalOpen, setIsLoadShareModalOpen] = useState(false)
const [isGeneratingShare, setIsGeneratingShare] = useState(false)
const [justCopied, setJustCopied] = useState(false)
// Dice rotation state for react-spring animation
// We track cumulative spins to add visual flair, then compute final rotation
// to land on the correct face derived from the seed
const [spinCount, setSpinCount] = useState(0)
// Track which face we're showing (for ensuring consecutive rolls differ)
const [currentFace, setCurrentFace] = useState(() => ((formState.seed ?? 0) % 5) + 2)
// Draggable dice state with physics simulation
const [isDragging, setIsDragging] = useState(false)
const [diceOrigin, setDiceOrigin] = useState({ x: 0, y: 0 })
const diceButtonRef = useRef<HTMLButtonElement>(null)
const diceContainerRef = useRef<HTMLDivElement>(null)
const dragStartPos = useRef({ x: 0, y: 0 })
// Physics state for thrown dice
const [isFlying, setIsFlying] = useState(false)
const dicePhysics = useRef({
x: 0,
y: 0,
vx: 0,
vy: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
scale: 1, // Grows to 3x when flying, shrinks back when settling
})
const lastPointerPos = useRef({ x: 0, y: 0, time: 0 })
// Track velocity samples for smoother flick detection
const velocitySamples = useRef<Array<{ vx: number; vy: number; time: number }>>([])
const animationFrameRef = useRef<number>()
// Ref to the portal dice element for direct DOM manipulation during drag/flying (avoids re-renders)
const portalDiceRef = useRef<HTMLDivElement>(null)
// Compute target rotation for the current face (needed by physics simulation)
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || {
rotateX: 0,
rotateY: 0,
}
// Physics simulation for thrown dice - uses direct DOM manipulation for performance
useEffect(() => {
if (!isFlying) return
const BASE_GRAVITY = 0.8 // Base pull toward origin
const BASE_FRICTION = 0.94 // Base velocity dampening (slightly less friction for longer flight)
const ROTATION_FACTOR = 0.5 // How much velocity affects rotation
const STOP_THRESHOLD = 2 // Distance threshold to snap home
const VELOCITY_THRESHOLD = 0.5 // Velocity threshold to snap home
const CLOSE_RANGE = 40 // Distance at which extra damping kicks in
const MAX_SCALE = 3 // Maximum scale when flying
const SCALE_GROW_SPEED = 0.2 // How fast to grow (faster)
const SCALE_SHRINK_SPEED = 0.06 // How fast to shrink when close (slower for drama)
const BOUNCE_DAMPING = 0.7 // How much velocity is retained on bounce (0-1)
const DICE_SIZE = 22 // Size of the dice in pixels
// Calculate initial throw power to adjust gravity (stronger throws = weaker initial gravity)
const initialSpeed = Math.sqrt(
dicePhysics.current.vx * dicePhysics.current.vx +
dicePhysics.current.vy * dicePhysics.current.vy
)
const throwPower = Math.min(initialSpeed / 20, 1) // 0-1 based on throw strength
let frameCount = 0
const animate = () => {
const p = dicePhysics.current
const el = portalDiceRef.current
if (!el) {
animationFrameRef.current = requestAnimationFrame(animate)
return
}
frameCount++
// Calculate distance to origin
const dist = Math.sqrt(p.x * p.x + p.y * p.y)
// Gravity ramps up over time (weak at first for strong throws, then strengthens)
const gravityRampUp = Math.min(frameCount / 30, 1) // Full gravity after ~0.5s
const effectiveGravity = BASE_GRAVITY * (0.3 + 0.7 * gravityRampUp) * (1 - throwPower * 0.5)
// Apply spring force toward origin (proportional to distance)
if (dist > 0) {
// Quadratic falloff for more natural feel
const pullStrength = effectiveGravity * (dist / 50) ** 1.2
p.vx += (-p.x / dist) * pullStrength
p.vy += (-p.y / dist) * pullStrength
}
// Apply friction - extra damping when close to prevent oscillation
const friction = dist < CLOSE_RANGE ? 0.88 : BASE_FRICTION
p.vx *= friction
p.vy *= friction
// Update position
p.x += p.vx
p.y += p.vy
// Viewport edge bounce - calculate absolute position and check bounds
const scaledSize = DICE_SIZE * p.scale
const absoluteX = diceOrigin.x + p.x
const absoluteY = diceOrigin.y + p.y
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Left edge bounce
if (absoluteX < 0) {
p.x = -diceOrigin.x // Position at left edge
p.vx = Math.abs(p.vx) * BOUNCE_DAMPING // Reverse and dampen
// Add extra spin on bounce
p.rotationZ += p.vx * 5
}
// Right edge bounce
if (absoluteX + scaledSize > viewportWidth) {
p.x = viewportWidth - diceOrigin.x - scaledSize
p.vx = -Math.abs(p.vx) * BOUNCE_DAMPING
p.rotationZ -= p.vx * 5
}
// Top edge bounce
if (absoluteY < 0) {
p.y = -diceOrigin.y
p.vy = Math.abs(p.vy) * BOUNCE_DAMPING
p.rotationZ += p.vy * 5
}
// Bottom edge bounce
if (absoluteY + scaledSize > viewportHeight) {
p.y = viewportHeight - diceOrigin.y - scaledSize
p.vy = -Math.abs(p.vy) * BOUNCE_DAMPING
p.rotationZ -= p.vy * 5
}
// Update rotation based on velocity (dice rolls as it moves)
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
// As dice gets closer to home, gradually lerp rotation toward final face
// settleProgress: 0 = far away (full physics rotation), 1 = at home (target rotation)
const SETTLE_START_DIST = 150 // Start settling rotation at this distance
const settleProgress = Math.max(0, 1 - dist / SETTLE_START_DIST)
const settleFactor = settleProgress * settleProgress * settleProgress // Cubic easing for smooth settle
// Physics rotation (tumbling)
const physicsRotationDelta = {
x: p.vy * ROTATION_FACTOR * 12,
y: -p.vx * ROTATION_FACTOR * 12,
z: speed * ROTATION_FACTOR * 3,
}
// Apply physics rotation, but reduced as we settle
p.rotationX += physicsRotationDelta.x * (1 - settleFactor)
p.rotationY += physicsRotationDelta.y * (1 - settleFactor)
p.rotationZ += physicsRotationDelta.z * (1 - settleFactor)
// Lerp toward target face rotation as we settle
// Target rotation should show the correct face (from DICE_FACE_ROTATIONS)
const lerpSpeed = 0.08 * settleFactor // Faster lerp as we get closer
if (settleFactor > 0.01) {
// Normalize rotations to find shortest path to target
const targetX = targetFaceRotation.rotateX
const targetY = targetFaceRotation.rotateY
const targetZ = 0 // Final Z rotation should be 0 (flat)
// Normalize current rotation to -180 to 180 range for smooth interpolation
const normalizeAngle = (angle: number) => {
let normalized = angle % 360
if (normalized > 180) normalized -= 360
if (normalized < -180) normalized += 360
return normalized
}
const currentX = normalizeAngle(p.rotationX)
const currentY = normalizeAngle(p.rotationY)
const currentZ = normalizeAngle(p.rotationZ)
// Lerp each axis toward target
p.rotationX = currentX + (targetX - currentX) * lerpSpeed
p.rotationY = currentY + (targetY - currentY) * lerpSpeed
p.rotationZ = currentZ + (targetZ - currentZ) * lerpSpeed
}
// Update scale - grow when far/fast, shrink when close/slow
const targetScale =
dist > CLOSE_RANGE ? MAX_SCALE : 1 + ((MAX_SCALE - 1) * dist) / CLOSE_RANGE
if (p.scale < targetScale) {
p.scale = Math.min(p.scale + SCALE_GROW_SPEED, targetScale)
} else if (p.scale > targetScale) {
p.scale = Math.max(p.scale - SCALE_SHRINK_SPEED, targetScale)
}
// Update DOM directly - no React re-renders
// Scale from center, offset position to keep visual center stable
const scaleOffset = ((p.scale - 1) * 22) / 2 // 22 is dice size
el.style.transform = `translate(${p.x - scaleOffset}px, ${p.y - scaleOffset}px) scale(${p.scale})`
// Dynamic shadow based on scale (larger = higher = bigger shadow)
const shadowSize = (p.scale - 1) * 10
const shadowOpacity = Math.min((p.scale - 1) * 0.2, 0.4)
el.style.filter =
shadowSize > 0
? `drop-shadow(0 ${shadowSize}px ${shadowSize * 1.5}px rgba(0,0,0,${shadowOpacity}))`
: 'none'
// Update dice rotation
const diceEl = el.querySelector('[data-dice-cube]') as HTMLElement | null
if (diceEl) {
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
}
// Check if we should stop - snap to home when close, slow, small, AND rotation is settled
const totalVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
const rotationSettled =
Math.abs(p.rotationX - targetFaceRotation.rotateX) < 5 &&
Math.abs(p.rotationY - targetFaceRotation.rotateY) < 5 &&
Math.abs(p.rotationZ) < 5
if (
dist < STOP_THRESHOLD &&
totalVelocity < VELOCITY_THRESHOLD &&
p.scale < 1.1 &&
rotationSettled
) {
// Dice has returned home - clear shadow
el.style.filter = 'none'
setIsFlying(false)
dicePhysics.current = {
x: 0,
y: 0,
vx: 0,
vy: 0,
rotationX: targetFaceRotation.rotateX,
rotationY: targetFaceRotation.rotateY,
rotationZ: 0,
scale: 1,
}
return
}
animationFrameRef.current = requestAnimationFrame(animate)
}
animationFrameRef.current = requestAnimationFrame(animate)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [isFlying, diceOrigin.x, diceOrigin.y, targetFaceRotation.rotateX, targetFaceRotation.rotateY])
// Compute dice rotation for react-spring animation (used when not flying)
const diceRotation = {
rotateX: spinCount * 360 + targetFaceRotation.rotateX,
rotateY: spinCount * 360 + targetFaceRotation.rotateY,
rotateZ: spinCount * 180, // Z rotation for extra tumble effect
}
const isGenerating = status === 'generating'
const [pageData, setPageData] = useState<{
currentPage: number
@@ -509,194 +61,7 @@ export function PreviewCenter({
// Generate a new random seed (use modulo to keep it in 32-bit int range)
const newSeed = Date.now() % 2147483647
onChange({ seed: newSeed })
// Calculate target face from seed (2-6, excluding 1 so it's clearly a dice)
// baseFace is deterministic based on seed
const baseFace = (newSeed % 5) + 2
// Ensure it's different from the current face
const targetFace = baseFace === currentFace ? (baseFace === 6 ? 2 : baseFace + 1) : baseFace
// Add 1-2 full spins for visual drama
const extraSpins = Math.floor(Math.random() * 2) + 1
setSpinCount((prev) => prev + extraSpins)
setCurrentFace(targetFace)
}, [onChange, currentFace])
// Dice drag handlers for the easter egg - drag dice off and release to roll
const handleDicePointerDown = useCallback(
(e: React.PointerEvent) => {
if (isGenerating) return
e.preventDefault()
e.stopPropagation()
// Cancel any ongoing physics animation
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
setIsFlying(false)
// Capture the pointer
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
// Get the dice container's position for portal rendering
if (diceContainerRef.current) {
const rect = diceContainerRef.current.getBoundingClientRect()
setDiceOrigin({ x: rect.left, y: rect.top })
}
dragStartPos.current = { x: e.clientX, y: e.clientY }
lastPointerPos.current = {
x: e.clientX,
y: e.clientY,
time: performance.now(),
}
velocitySamples.current = [] // Reset velocity tracking
dicePhysics.current = {
x: 0,
y: 0,
vx: 0,
vy: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
scale: 1,
}
setIsDragging(true)
},
[isGenerating]
)
const handleDicePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging) return
e.preventDefault()
const dx = e.clientX - dragStartPos.current.x
const dy = e.clientY - dragStartPos.current.y
// Calculate drag velocity for live rotation
const now = performance.now()
const dt = Math.max(now - lastPointerPos.current.time, 8)
const vx = (e.clientX - lastPointerPos.current.x) / dt
const vy = (e.clientY - lastPointerPos.current.y) / dt
// Update rotation based on drag velocity (dice tumbles while being dragged)
const p = dicePhysics.current
p.rotationX += vy * 8
p.rotationY -= vx * 8
p.rotationZ += Math.sqrt(vx * vx + vy * vy) * 2
// Scale up slightly based on distance (feels like pulling it out)
const dist = Math.sqrt(dx * dx + dy * dy)
p.scale = 1 + Math.min(dist / 150, 0.5) // Max 1.5x during drag
// Update DOM directly to avoid React re-renders during drag
if (portalDiceRef.current) {
const scaleOffset = ((p.scale - 1) * 22) / 2
portalDiceRef.current.style.transform = `translate(${dx - scaleOffset}px, ${dy - scaleOffset}px) scale(${p.scale})`
// Add shadow that grows with distance
const shadowSize = Math.min(dist / 10, 20)
const shadowOpacity = Math.min(dist / 200, 0.4)
portalDiceRef.current.style.filter = `drop-shadow(0 ${shadowSize}px ${shadowSize * 1.5}px rgba(0,0,0,${shadowOpacity}))`
// Update dice rotation
const diceEl = portalDiceRef.current.querySelector('[data-dice-cube]') as HTMLElement | null
if (diceEl) {
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
}
}
// Store position in ref for use when releasing
p.x = dx
p.y = dy
// Track velocity samples for flick detection (keep last 5 samples, ~80ms window)
velocitySamples.current.push({ vx, vy, time: now })
if (velocitySamples.current.length > 5) {
velocitySamples.current.shift()
}
// Track velocity for throw calculation
lastPointerPos.current = { x: e.clientX, y: e.clientY, time: now }
},
[isDragging]
)
const handleDicePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!isDragging) return
e.preventDefault()
// Release the pointer
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
// Calculate throw velocity from velocity samples (average of recent samples for smooth flick)
const samples = velocitySamples.current
let vx = 0
let vy = 0
if (samples.length > 0) {
// Weight recent samples more heavily
let totalWeight = 0
for (let i = 0; i < samples.length; i++) {
const weight = i + 1 // Later samples get higher weight
vx += samples[i].vx * weight
vy += samples[i].vy * weight
totalWeight += weight
}
vx /= totalWeight
vy /= totalWeight
}
// Get position from physics ref (set during drag)
const posX = dicePhysics.current.x
const posY = dicePhysics.current.y
// Calculate distance dragged
const distance = Math.sqrt(posX ** 2 + posY ** 2)
// Calculate flick speed
const flickSpeed = Math.sqrt(vx * vx + vy * vy)
// If dragged more than 20px OR flicked fast enough, trigger throw physics
if (distance > 20 || flickSpeed > 0.3) {
// Amplify throw velocity significantly for satisfying flick
const throwMultiplier = 25 // Much stronger throw!
// Initialize physics with current position and throw velocity
dicePhysics.current = {
x: posX,
y: posY,
vx: vx * throwMultiplier,
vy: vy * throwMultiplier,
rotationX: dicePhysics.current.rotationX, // Keep current rotation
rotationY: dicePhysics.current.rotationY,
rotationZ: dicePhysics.current.rotationZ,
scale: dicePhysics.current.scale, // Keep current scale
}
setIsFlying(true)
// Trigger shuffle when thrown
handleShuffle()
} else {
// Not thrown far enough, snap back
dicePhysics.current = {
x: 0,
y: 0,
vx: 0,
vy: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
scale: 1,
}
}
setIsDragging(false)
},
[isDragging, handleShuffle]
)
}, [onChange])
// Detect scrolling in the scroll container
useEffect(() => {
@@ -850,15 +215,8 @@ export function PreviewCenter({
{/* Shuffle Button - only in edit mode (1/3 split secondary action) */}
{/* Easter egg: drag the dice off and release to roll it back! */}
{!isReadOnly && (
<button
ref={diceButtonRef}
type="button"
data-action="shuffle-problems"
onClick={handleShuffle}
onPointerDown={handleDicePointerDown}
onPointerMove={handleDicePointerMove}
onPointerUp={handleDicePointerUp}
onPointerCancel={handleDicePointerUp}
<InteractiveDice
onRoll={handleShuffle}
disabled={isGenerating}
title="Shuffle problems (drag or click to roll)"
className={css({
@@ -866,70 +224,18 @@ export function PreviewCenter({
py: '2.5',
bg: 'brand.600',
color: 'white',
cursor: isDragging ? 'grabbing' : isGenerating ? 'not-allowed' : 'grab',
opacity: isGenerating ? '0.7' : '1',
borderLeft: '1px solid',
borderColor: 'brand.700',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
touchAction: 'none', // Prevent scroll on touch devices
userSelect: 'none',
_hover: isGenerating
? {}
: {
bg: 'brand.700',
},
})}
>
{/* Dice container - hidden when dragging/flying since we show portal version */}
<div
ref={diceContainerRef}
style={{
opacity: isDragging || isFlying ? 0 : 1,
pointerEvents: 'none',
}}
>
<DiceIcon
rotateX={diceRotation.rotateX}
rotateY={diceRotation.rotateY}
rotateZ={diceRotation.rotateZ}
isDark={isDark}
/>
</div>
</button>
/>
)}
{/* Portal-rendered dice when dragging/flying - renders outside button to avoid clipping */}
{/* All transforms are applied directly via ref for performance - no React re-renders */}
{(isDragging || isFlying) &&
createPortal(
<div
ref={portalDiceRef}
style={{
position: 'fixed',
left: diceOrigin.x,
top: diceOrigin.y,
// Transform is updated directly via ref during drag/flying
transform: 'translate(0px, 0px)',
pointerEvents: 'none',
zIndex: 100000,
cursor: isDragging ? 'grabbing' : 'default',
willChange: 'transform', // Hint to browser for GPU acceleration
}}
>
{/* During flying, rotation is updated directly on data-dice-cube element */}
<DiceIcon
rotateX={diceRotation.rotateX}
rotateY={diceRotation.rotateY}
rotateZ={diceRotation.rotateZ}
isDark={isDark}
/>
</div>,
document.body
)}
{/* Dropdown Trigger */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>

View File

@@ -157,6 +157,7 @@ export default function FlowchartPage() {
schema={state.flowchart.definition.problemInput}
onSubmit={handleProblemSubmit}
title="Enter your problem"
flowchart={state.flowchart}
/>
)}

View File

@@ -86,7 +86,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const messages = await getMessages(locale)
return (
<html lang={locale}>
<html lang={locale} suppressHydrationWarning>
<body>
<ClientProviders initialLocale={locale} initialMessages={messages}>
{children}

View File

@@ -51,9 +51,16 @@ export function FlowchartCheckpoint({
}
return (
<div className={vstack({ gap: '4', alignItems: 'center' })}>
<div
data-testid="checkpoint-container"
data-input-type={inputType}
data-has-feedback={!!feedback}
data-feedback-correct={feedback?.correct}
className={vstack({ gap: '4', alignItems: 'center' })}
>
{/* Prompt */}
<p
data-testid="checkpoint-prompt"
className={css({
fontSize: 'lg',
fontWeight: 'medium',
@@ -65,8 +72,9 @@ export function FlowchartCheckpoint({
</p>
{/* Input and button */}
<div className={hstack({ gap: '3' })}>
<div data-testid="checkpoint-input-row" className={hstack({ gap: '3' })}>
<input
data-testid="checkpoint-input"
type={inputType === 'number' ? 'number' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
@@ -104,6 +112,7 @@ export function FlowchartCheckpoint({
})}
/>
<button
data-testid="checkpoint-check-button"
onClick={handleSubmit}
disabled={disabled || !value.trim()}
className={css({
@@ -131,6 +140,10 @@ export function FlowchartCheckpoint({
{/* Feedback */}
{feedback && (
<div
data-testid="checkpoint-feedback"
data-feedback-correct={feedback.correct}
data-expected={feedback.expected}
data-user-answer={feedback.userAnswer}
className={css({
padding: '3 4',
borderRadius: 'md',
@@ -158,6 +171,7 @@ export function FlowchartCheckpoint({
{/* Hint */}
{hint && (
<div
data-testid="checkpoint-hint"
className={css({
padding: '3 4',
borderRadius: 'md',

View File

@@ -61,6 +61,10 @@ export function FlowchartDecision({
return (
<RadioGroup.Root
data-testid="decision-radio-group"
data-option-count={options.length}
data-is-shaking={isShaking}
data-showing-feedback={showFeedback}
value={selectedValue}
onValueChange={handleSelect}
className={cx(
@@ -68,13 +72,17 @@ export function FlowchartDecision({
isShaking ? 'shake-animation' : ''
)}
>
{options.map((option) => {
{options.map((option, idx) => {
const isCorrect = showFeedback && correctAnswer === option.value
const isWrong = showFeedback && wrongAnswer === option.value
return (
<RadioGroup.Item
key={option.value}
data-testid={`decision-option-${idx}`}
data-option-value={option.value}
data-is-correct={isCorrect}
data-is-wrong={isWrong}
value={option.value}
disabled={showFeedback}
className={css({
@@ -267,6 +275,7 @@ interface WrongAnswerFeedbackProps {
export function FlowchartWrongAnswerFeedback({ message }: WrongAnswerFeedbackProps) {
return (
<div
data-testid="wrong-answer-feedback"
className={css({
padding: '3 4',
backgroundColor: { base: 'amber.100', _dark: 'amber.900' },

View File

@@ -16,9 +16,16 @@ interface FlowchartNodeContentProps {
*/
export function FlowchartNodeContent({ content, compact = false }: FlowchartNodeContentProps) {
return (
<div className={vstack({ gap: compact ? '2' : '3', alignItems: 'stretch' })}>
<div
data-testid="node-content"
data-has-warning={!!content.warning}
data-has-example={!!content.example}
data-has-checklist={!!content.checklist?.length}
className={vstack({ gap: compact ? '2' : '3', alignItems: 'stretch' })}
>
{/* Title */}
<h3
data-testid="node-content-title"
className={css({
fontSize: compact ? 'lg' : 'xl',
fontWeight: 'bold',
@@ -31,6 +38,8 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
{/* Body */}
{content.body.length > 0 && (
<div
data-testid="node-content-body"
data-line-count={content.body.length}
className={css({
fontSize: compact ? 'sm' : 'md',
color: { base: 'gray.700', _dark: 'gray.300' },
@@ -48,6 +57,7 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
{/* Warning */}
{content.warning && (
<div
data-testid="node-content-warning"
className={css({
padding: '3',
backgroundColor: { base: 'orange.100', _dark: 'orange.900' },
@@ -65,6 +75,7 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
{/* Example */}
{content.example && (
<div
data-testid="node-content-example"
className={css({
padding: '3',
backgroundColor: { base: 'blue.50', _dark: 'blue.900' },
@@ -82,6 +93,8 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
{/* Checklist */}
{content.checklist && content.checklist.length > 0 && (
<ul
data-testid="node-content-checklist"
data-item-count={content.checklist.length}
className={css({
listStyle: 'none',
padding: '3',
@@ -92,6 +105,7 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
{content.checklist.map((item, i) => (
<li
key={i}
data-testid={`checklist-item-${i}`}
className={css({
fontSize: 'sm',
color: { base: 'green.800', _dark: 'green.200' },

View File

@@ -1,13 +1,17 @@
'use client'
import { useMemo } from 'react'
import type { ExecutableFlowchart, FlowchartState } from '@/lib/flowcharts/schema'
import { css } from '../../../styled-system/css'
import { useMemo, useState, useEffect } from 'react'
import type { ExecutableFlowchart, FlowchartState, DecisionNode } from '@/lib/flowcharts/schema'
import { css, cx } from '../../../styled-system/css'
import { hstack, vstack } from '../../../styled-system/patterns'
interface FlowchartPhaseRailProps {
flowchart: ExecutableFlowchart
state: FlowchartState
/** Callback when user selects a decision option */
onDecisionSelect?: (value: string) => void
/** Wrong answer for feedback animation */
wrongDecision?: { value: string; correctValue: string; attempt: number } | null
}
interface PathNode {
@@ -20,10 +24,36 @@ interface PathNode {
/**
* Horizontal phase rail showing all phases with current phase expanded.
* Shows path taken with ellipsis if more than 2 previous nodes.
* When at a decision node, renders the options as clickable cards in the flowchart.
*/
export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps) {
export function FlowchartPhaseRail({
flowchart,
state,
onDecisionSelect,
wrongDecision,
}: FlowchartPhaseRailProps) {
const phases = flowchart.mermaid.phases
// Shake animation state
const [isShaking, setIsShaking] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
// Handle wrong answer feedback
useEffect(() => {
if (wrongDecision) {
setIsShaking(true)
setShowFeedback(true)
const shakeTimer = setTimeout(() => setIsShaking(false), 500)
const feedbackTimer = setTimeout(() => setShowFeedback(false), 1500)
return () => {
clearTimeout(shakeTimer)
clearTimeout(feedbackTimer)
}
}
}, [wrongDecision?.attempt])
// Build the path taken through the flowchart
const pathTaken = useMemo((): PathNode[] => {
const path: PathNode[] = []
@@ -64,6 +94,22 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
return path
}, [flowchart.nodes, state.history, state.currentNode])
// Get decision options for current node if it's a decision
const decisionOptions = useMemo(() => {
const currentNode = flowchart.nodes[state.currentNode]
if (!currentNode || currentNode.definition.type !== 'decision') return null
const def = currentNode.definition as DecisionNode
return def.options.map((opt) => {
const nextNode = flowchart.nodes[opt.next]
return {
label: opt.label,
value: opt.value,
leadsTo: nextNode?.content?.title || opt.next,
}
})
}, [flowchart.nodes, state.currentNode])
// Find which phase the current node is in
const currentPhaseIndex = useMemo(() => {
for (let i = 0; i < phases.length; i++) {
@@ -97,10 +143,18 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
}
}, [pathTaken])
const handleOptionClick = (value: string) => {
if (showFeedback) return
onDecisionSelect?.(value)
}
return (
<div className={vstack({ gap: '3', alignItems: 'stretch' })}>
<div data-testid="phase-rail" className={vstack({ gap: '3', alignItems: 'stretch' })}>
{/* Horizontal phase rail */}
<div
data-testid="phase-rail-horizontal"
data-current-phase={currentPhaseIndex}
data-total-phases={phases.length}
className={css({
display: 'flex',
gap: '2',
@@ -116,6 +170,9 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
return (
<div
key={phase.id}
data-testid={`phase-${idx}`}
data-phase-id={phase.id}
data-phase-status={status}
className={css({
flex: isCurrent ? '2 0 auto' : '1 0 auto',
minWidth: isCurrent ? '160px' : '60px',
@@ -173,9 +230,11 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
})}
</div>
{/* Expanded current phase - Path taken */}
{/* Expanded current phase - Path taken + Decision */}
{currentPhase && (
<div
data-testid="current-phase-expanded"
data-phase-id={currentPhase.id}
className={css({
padding: '3',
backgroundColor: { base: 'gray.50', _dark: 'gray.800' },
@@ -184,26 +243,18 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
borderColor: { base: 'gray.200', _dark: 'gray.700' },
})}
>
<div className={vstack({ gap: '3', alignItems: 'stretch' })}>
<div className={vstack({ gap: '4', alignItems: 'stretch' })}>
{/* Path taken */}
<div className={vstack({ gap: '1', alignItems: 'flex-start' })}>
<span
className={css({
fontSize: '2xs',
fontWeight: 'medium',
color: { base: 'gray.500', _dark: 'gray.400' },
textTransform: 'uppercase',
letterSpacing: 'wide',
})}
>
Path
</span>
<div data-testid="path-taken" className={vstack({ gap: '1', alignItems: 'center' })}>
<div
data-testid="path-nodes-container"
data-path-length={pathTaken.length}
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
flexWrap: 'wrap',
justifyContent: 'center',
})}
>
{displayPath.showEllipsis && (
@@ -227,7 +278,13 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
</>
)}
{displayPath.nodes.map((node, idx) => (
<div key={node.nodeId} className={hstack({ gap: '2', alignItems: 'center' })}>
<div
key={node.nodeId}
data-testid={`path-node-${idx}`}
data-node-id={node.nodeId}
data-is-current={node.isCurrent}
className={hstack({ gap: '2', alignItems: 'center' })}
>
<span
className={css({
padding: '1 2',
@@ -276,9 +333,215 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
</div>
</div>
{/* Decision branching - rendered as part of the flowchart */}
{decisionOptions && onDecisionSelect && (
<div
data-testid="decision-branching"
data-option-count={decisionOptions.length}
className={vstack({ gap: '2', alignItems: 'center' })}
>
{/* Branch line */}
<div
className={css({
width: '2px',
height: '16px',
backgroundColor: { base: 'gray.300', _dark: 'gray.600' },
})}
/>
{/* Branch split */}
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
position: 'relative',
})}
>
{/* Horizontal connector line */}
<div
className={css({
position: 'absolute',
top: '0',
left: '50%',
transform: 'translateX(-50%)',
width: 'calc(100% - 80px)',
height: '2px',
backgroundColor: { base: 'gray.300', _dark: 'gray.600' },
})}
/>
{/* Option cards */}
<div
className={cx(
hstack({ gap: '4', justifyContent: 'center', alignItems: 'stretch' }),
isShaking ? 'shake-animation' : ''
)}
>
{decisionOptions.map((opt, idx) => {
const isCorrect = showFeedback && wrongDecision?.correctValue === opt.value
const isWrong = showFeedback && wrongDecision?.value === opt.value
return (
<div
key={opt.value}
data-testid={`decision-option-${idx}`}
data-option-value={opt.value}
data-is-correct={isCorrect}
data-is-wrong={isWrong}
className={vstack({ gap: '0', alignItems: 'center' })}
>
{/* Vertical connector */}
<div
className={css({
width: '2px',
height: '12px',
backgroundColor: { base: 'gray.300', _dark: 'gray.600' },
})}
/>
{/* Arrow */}
<div
className={css({
width: '0',
height: '0',
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '8px solid',
borderTopColor: { base: 'gray.300', _dark: 'gray.600' },
marginBottom: '2',
})}
/>
{/* Clickable option card */}
<button
data-testid={`decision-option-button-${idx}`}
onClick={() => handleOptionClick(opt.value)}
disabled={showFeedback}
className={css({
display: 'flex',
flexDirection: 'column',
padding: '0',
borderRadius: 'lg',
border: '3px solid',
cursor: showFeedback ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
minWidth: '120px',
maxWidth: '160px',
overflow: 'hidden',
backgroundColor: { base: 'white', _dark: 'gray.700' },
borderColor: isCorrect
? { base: 'green.500', _dark: 'green.400' }
: isWrong
? { base: 'red.500', _dark: 'red.400' }
: { base: 'blue.300', _dark: 'blue.600' },
_hover: showFeedback
? {}
: {
borderColor: { base: 'blue.500', _dark: 'blue.400' },
transform: 'scale(1.03)',
boxShadow: 'lg',
},
_active: showFeedback
? {}
: {
transform: 'scale(0.97)',
},
})}
>
{/* Choice header */}
<div
className={css({
padding: '2 3',
fontSize: 'sm',
fontWeight: 'bold',
textAlign: 'center',
backgroundColor: isCorrect
? { base: 'green.100', _dark: 'green.800' }
: isWrong
? { base: 'red.100', _dark: 'red.800' }
: { base: 'blue.100', _dark: 'blue.800' },
color: isCorrect
? { base: 'green.800', _dark: 'green.200' }
: isWrong
? { base: 'red.800', _dark: 'red.200' }
: { base: 'blue.800', _dark: 'blue.200' },
borderBottom: '1px solid',
borderColor: isCorrect
? { base: 'green.200', _dark: 'green.600' }
: isWrong
? { base: 'red.200', _dark: 'red.600' }
: { base: 'blue.200', _dark: 'blue.600' },
position: 'relative',
})}
>
{isCorrect && (
<span
className={css({
position: 'absolute',
top: '4px',
right: '4px',
fontSize: 'xs',
})}
>
</span>
)}
{isWrong && (
<span
className={css({
position: 'absolute',
top: '4px',
right: '4px',
fontSize: 'xs',
})}
>
</span>
)}
{opt.label}
</div>
{/* Where it leads */}
<div
className={css({
padding: '2',
fontSize: '2xs',
color: { base: 'gray.600', _dark: 'gray.400' },
textAlign: 'center',
lineHeight: 'tight',
})}
>
{opt.leadsTo.length > 25 ? opt.leadsTo.slice(0, 23) + '…' : opt.leadsTo}
</div>
</button>
</div>
)
})}
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Shake animation styles */}
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-8px); }
20%, 40%, 60%, 80% { transform: translateX(8px); }
}
.shake-animation {
animation: shake 0.5s ease-in-out;
}
`,
}}
/>
</div>
)
}

View File

@@ -1,35 +1,136 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useMemo, useRef } from 'react'
import type {
ProblemInputSchema,
Field,
ProblemValue,
MixedNumberValue,
ExecutableFlowchart,
} from '@/lib/flowcharts/schema'
import { evaluate, createEmptyContext } from '@/lib/flowcharts/evaluator'
import { evaluate } from '@/lib/flowcharts/evaluator'
import {
generateDiverseExamples,
analyzeFlowchart,
type GeneratedExample,
type FlowchartAnalysis,
type GenerationConstraints,
DEFAULT_CONSTRAINTS,
} from '@/lib/flowcharts/loader'
import { InteractiveDice } from '@/components/ui/InteractiveDice'
import { TeacherConfigPanel } from './TeacherConfigPanel'
import { css } from '../../../styled-system/css'
import { vstack, hstack } from '../../../styled-system/patterns'
import { MathDisplay } from './MathDisplay'
interface FlowchartProblemInputProps {
schema: ProblemInputSchema
onSubmit: (values: Record<string, ProblemValue>) => void
title?: string
/** The loaded flowchart (used to calculate path complexity for examples) */
flowchart?: ExecutableFlowchart
}
/**
* Dynamic problem input form based on schema definition.
* Renders appropriate input fields based on field types.
*/
export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProblemInputProps) {
export function FlowchartProblemInput({ schema, onSubmit, title, flowchart }: FlowchartProblemInputProps) {
const [values, setValues] = useState<Record<string, ProblemValue>>(() =>
initializeValues(schema.fields)
)
const [error, setError] = useState<string | null>(null)
const [selectedExampleIdx, setSelectedExampleIdx] = useState<number | null>(null)
// Teacher-configurable constraints for problem generation
const [constraints, setConstraints] = useState<GenerationConstraints>(DEFAULT_CONSTRAINTS)
// Displayed examples - updated when dice roll completes
const [displayedExamples, setDisplayedExamples] = useState<GeneratedExample[]>([])
// Pending examples - pre-computed during drag, shown on roll complete
const pendingExamplesRef = useRef<GeneratedExample[] | null>(null)
// Analyze flowchart structure (paths, complexity, etc.)
const analysis = useMemo(() => {
if (!flowchart) return null
try {
return analyzeFlowchart(flowchart)
} catch (e) {
console.error('Error analyzing flowchart:', e)
return null
}
}, [flowchart])
// Generate initial examples on first render (re-generates when constraints change)
const initialExamples = useMemo(() => {
if (!flowchart) return []
try {
return generateDiverseExamples(flowchart, 6, constraints)
} catch (e) {
console.error('Error generating examples:', e)
return []
}
}, [flowchart, constraints])
// Set initial examples on first render
const generatedExamples = displayedExamples.length > 0 ? displayedExamples : initialExamples
// Calculate coverage: how many unique paths are represented in current examples
const pathsCovered = useMemo(() => {
const uniquePaths = new Set(generatedExamples.map(ex => ex.pathSignature))
return uniquePaths.size
}, [generatedExamples])
// Precompute new examples when user starts dragging the dice
const handleDragStart = useCallback(() => {
if (!flowchart) return
try {
// Compute new examples during drag - user won't notice the computation
pendingExamplesRef.current = generateDiverseExamples(flowchart, 6, constraints)
} catch (e) {
console.error('Error pre-generating examples:', e)
}
}, [flowchart, constraints])
// Show new examples after the dice roll animation completes
const handleRollComplete = useCallback(() => {
// If we have pre-computed examples from drag, use them
if (pendingExamplesRef.current) {
setDisplayedExamples(pendingExamplesRef.current)
pendingExamplesRef.current = null
} else if (flowchart) {
// Click-only roll - compute now (animation masked the compute time)
try {
setDisplayedExamples(generateDiverseExamples(flowchart, 6, constraints))
} catch (e) {
console.error('Error generating examples:', e)
}
}
setSelectedExampleIdx(null)
}, [flowchart, constraints])
// Handler for dice roll - we don't update examples here anymore
// (examples update on completion via handleRollComplete)
const handleRoll = useCallback(() => {
// The actual example update happens in handleRollComplete
// This is called immediately when rolled - could add a spinning indicator here
}, [])
const handleChange = useCallback((name: string, value: ProblemValue) => {
setValues((prev) => ({ ...prev, [name]: value }))
setError(null)
setSelectedExampleIdx(null) // Clear example selection when user manually changes
}, [])
// Handle constraint changes - regenerate examples with new constraints
const handleConstraintsChange = useCallback((newConstraints: GenerationConstraints) => {
setConstraints(newConstraints)
setDisplayedExamples([]) // Clear displayed examples so they regenerate with new constraints
setSelectedExampleIdx(null)
}, [])
const handleExampleSelect = useCallback((example: GeneratedExample, idx: number) => {
setValues(example.values)
setSelectedExampleIdx(idx)
setError(null)
}, [])
const handleSubmit = useCallback(() => {
@@ -67,6 +168,7 @@ export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProb
return (
<div
data-testid="flowchart-problem-input"
className={vstack({
gap: '6',
padding: '6',
@@ -79,6 +181,13 @@ export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProb
margin: '0 auto',
})}
>
{/* Teacher configuration panel */}
<TeacherConfigPanel
constraints={constraints}
onConstraintsChange={handleConstraintsChange}
defaultCollapsed={true}
/>
{title && (
<h2
className={css({
@@ -99,9 +208,127 @@ export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProb
textAlign: 'center',
})}
>
Enter your problem to get started
{generatedExamples.length > 0 ? 'Choose an example or enter your own' : 'Enter your problem to get started'}
</p>
{/* Monte Carlo generated example problem buttons */}
{generatedExamples.length > 0 && (
<div data-testid="examples-section" className={vstack({ gap: '2', alignItems: 'stretch' })}>
<div className={vstack({ gap: '1', alignItems: 'center' })}>
<div className={hstack({ gap: '2', justifyContent: 'center', alignItems: 'center' })}>
<span
className={css({
fontSize: 'xs',
fontWeight: 'medium',
color: { base: 'gray.500', _dark: 'gray.400' },
textTransform: 'uppercase',
letterSpacing: 'wide',
})}
>
Try These
</span>
<InteractiveDice
onRoll={handleRoll}
onDragStart={handleDragStart}
onRollComplete={handleRollComplete}
size={18}
title="Roll for new examples"
className={css({
padding: '1',
borderRadius: 'md',
backgroundColor: 'transparent',
border: 'none',
transition: 'background 0.2s',
_hover: {
backgroundColor: { base: 'gray.100', _dark: 'gray.700' },
},
})}
/>
</div>
{/* Coverage stats */}
{analysis && (
<span
className={css({
fontSize: '2xs',
color: { base: 'gray.400', _dark: 'gray.500' },
})}
title={`${analysis.stats.totalPaths} unique paths, ${analysis.stats.minDecisions}-${analysis.stats.maxDecisions} decisions, ${analysis.stats.minCheckpoints}-${analysis.stats.maxCheckpoints} checkpoints`}
>
{pathsCovered}/{analysis.stats.totalPaths} paths {analysis.stats.minPathLength}-{analysis.stats.maxPathLength} steps
</span>
)}
</div>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '2',
justifyContent: 'center',
})}
>
{generatedExamples.map((example, idx) => {
const { complexity } = example
return (
<button
key={`${example.pathSignature}-${idx}`}
data-testid={`example-button-${idx}`}
data-path-signature={example.pathSignature}
data-complexity-path={complexity.pathLength}
data-complexity-decisions={complexity.decisions}
data-complexity-checkpoints={complexity.checkpoints}
onClick={() => handleExampleSelect(example, idx)}
title={`Path: ${complexity.pathLength} steps, ${complexity.decisions} decisions, ${complexity.checkpoints} checkpoints`}
className={css({
padding: '2 3',
borderRadius: 'lg',
border: '2px solid',
borderColor: selectedExampleIdx === idx
? { base: 'blue.500', _dark: 'blue.400' }
: { base: 'gray.200', _dark: 'gray.600' },
backgroundColor: selectedExampleIdx === idx
? { base: 'blue.50', _dark: 'blue.900' }
: { base: 'white', _dark: 'gray.800' },
color: selectedExampleIdx === idx
? { base: 'blue.700', _dark: 'blue.200' }
: { base: 'gray.700', _dark: 'gray.300' },
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: { base: 'blue.400', _dark: 'blue.500' },
backgroundColor: { base: 'blue.50', _dark: 'blue.900/50' },
},
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1',
})}
>
<MathDisplay
expression={formatExampleDisplay(schema.schema, example.values)}
size="sm"
/>
<div
data-testid="path-descriptor"
data-path-descriptor={example.pathDescriptor}
data-path-length={complexity.pathLength}
data-decisions={complexity.decisions}
data-checkpoints={complexity.checkpoints}
className={css({
fontSize: '2xs',
color: { base: 'gray.500', _dark: 'gray.400' },
fontWeight: 'medium',
})}
title={`${complexity.pathLength} steps, ${complexity.decisions} decisions, ${complexity.checkpoints} checkpoints`}
>
{example.pathDescriptor}
</div>
</button>
)
})}
</div>
</div>
)}
{/* Render fields based on schema */}
{schema.schema === 'two-digit-subtraction' ? (
<TwoDigitSubtractionInput values={values} onChange={handleChange} />
@@ -128,6 +355,7 @@ export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProb
{/* Submit button */}
<button
data-testid="start-button"
onClick={handleSubmit}
className={css({
width: '100%',
@@ -576,6 +804,44 @@ function renderFieldInput(
// Helpers
// =============================================================================
/**
* Format example values into a readable problem expression based on schema type
*/
function formatExampleDisplay(schema: string, values: Record<string, ProblemValue>): string {
switch (schema) {
case 'two-digit-subtraction':
return `${values.minuend} ${values.subtrahend}`
case 'linear-equation': {
const coef = values.coefficient as number
const op = values.operation as string
const constant = values.constant as number
const equals = values.equals as number
if (constant === 0) {
return `${coef}x = ${equals}`
}
return `${coef}x ${op} ${constant} = ${equals}`
}
case 'two-fractions-with-op': {
const lw = values.leftWhole as number
const ln = values.leftNum as number
const ld = values.leftDenom as number
const op = values.op as string
const rw = values.rightWhole as number
const rn = values.rightNum as number
const rd = values.rightDenom as number
const left = lw > 0 ? `${lw} ${ln}/${ld}` : `${ln}/${ld}`
const right = rw > 0 ? `${rw} ${rn}/${rd}` : `${rn}/${rd}`
return `${left} ${op} ${right}`
}
default:
return JSON.stringify(values)
}
}
function initializeValues(fields: Field[]): Record<string, ProblemValue> {
const values: Record<string, ProblemValue> = {}

View File

@@ -227,6 +227,7 @@ export function FlowchartWalker({
case 'instruction':
return (
<button
data-testid="instruction-advance-button"
onClick={handleInstructionAdvance}
className={css({
padding: '4 8',
@@ -280,6 +281,7 @@ export function FlowchartWalker({
if (phase.correct) {
return (
<div
data-testid="checkpoint-correct-feedback"
className={css({
padding: '4',
backgroundColor: { base: 'green.100', _dark: 'green.800' },
@@ -296,7 +298,7 @@ export function FlowchartWalker({
}
return (
<div className={vstack({ gap: '4' })}>
<div data-testid="checkpoint-wrong-feedback" className={vstack({ gap: '4' })}>
<FlowchartCheckpoint
prompt={checkpointDef.prompt}
inputType={checkpointDef.inputType}
@@ -309,6 +311,7 @@ export function FlowchartWalker({
hint={showHint ? `Hint: The answer is ${phase.expected}` : undefined}
/>
<button
data-testid="checkpoint-retry-button"
onClick={handleCheckpointRetry}
className={css({
padding: '2 4',
@@ -339,6 +342,7 @@ export function FlowchartWalker({
setTimeout(() => advanceToNext(), 500)
return (
<div
data-testid="milestone-display"
className={css({
fontSize: '4xl',
textAlign: 'center',
@@ -363,6 +367,8 @@ export function FlowchartWalker({
if (phase.type === 'complete') {
return (
<div
data-testid="completion-screen"
data-mistakes={state.mistakes}
className={vstack({
gap: '6',
padding: '8',
@@ -371,7 +377,7 @@ export function FlowchartWalker({
minHeight: '400px',
})}
>
<div className={css({ fontSize: '6xl' })}>🎉</div>
<div data-testid="celebration-emoji" className={css({ fontSize: '6xl' })}>🎉</div>
<h2
className={css({
fontSize: '2xl',
@@ -391,6 +397,7 @@ export function FlowchartWalker({
</p>
{onRestart && (
<button
data-testid="restart-button"
onClick={onRestart}
className={css({
padding: '3 6',
@@ -411,9 +418,14 @@ export function FlowchartWalker({
}
return (
<div className={vstack({ gap: '6', padding: '4', alignItems: 'stretch' })}>
<div
data-testid="flowchart-walker"
data-current-node={state.currentNode}
data-phase={phase.type}
className={vstack({ gap: '6', padding: '4', alignItems: 'stretch' })}
>
{/* Problem display header */}
<div className={hstack({ justifyContent: 'center', fontSize: 'sm' })}>
<div data-testid="problem-header" className={hstack({ justifyContent: 'center', fontSize: 'sm' })}>
<span className={css({ color: { base: 'gray.500', _dark: 'gray.500' } })}>
{problemDisplay}
</span>
@@ -425,6 +437,8 @@ export function FlowchartWalker({
{/* Working problem ledger */}
{state.workingProblemHistory.length > 0 && (
<div
data-testid="working-problem-ledger"
data-step-count={state.workingProblemHistory.length}
className={css({
padding: '4',
backgroundColor: { base: 'blue.50', _dark: 'blue.900' },
@@ -456,6 +470,10 @@ export function FlowchartWalker({
return (
<div
key={idx}
data-testid={`ledger-step-${idx}`}
data-step-index={idx}
data-is-latest={isLatest}
data-node-id={step.nodeId}
className={css({
display: 'flex',
alignItems: 'center',
@@ -517,6 +535,8 @@ export function FlowchartWalker({
{/* Node content */}
<div
data-testid="node-content-container"
data-node-type={currentNode?.definition.type}
className={css({
padding: '6',
backgroundColor: { base: 'white', _dark: 'gray.800' },
@@ -531,6 +551,7 @@ export function FlowchartWalker({
{/* Interaction area */}
<div
data-testid="interaction-area"
className={css({
padding: '4',
display: 'flex',

View File

@@ -1,7 +1,6 @@
'use client'
import { css } from '../../../styled-system/css'
import { hstack } from '../../../styled-system/patterns'
interface MathDisplayProps {
/** Math expression string to render (e.g., "3 2/9 1 1/2" or "2x = 12") */
@@ -11,8 +10,10 @@ interface MathDisplayProps {
}
/**
* Renders math expressions with proper fraction formatting.
* Parses strings like "3 2/9 1 1/2" and renders fractions stacked.
* Renders math expressions using native MathML.
* Parses strings like "3 2/9 1 1/2" and renders proper semantic math.
*
* Browser support: 94%+ (Chrome 109+, Firefox, Safari 10+, Edge 109+)
*/
export function MathDisplay({ expression, size = 'lg' }: MathDisplayProps) {
const fontSize = {
@@ -26,19 +27,22 @@ export function MathDisplay({ expression, size = 'lg' }: MathDisplayProps) {
const tokens = parseExpression(expression)
return (
<span
className={hstack({
gap: '2',
<math
data-testid="math-display"
data-expression={expression}
data-size={size}
data-token-count={tokens.length}
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
verticalAlign: 'middle',
})}
style={{ fontSize }}
>
{tokens.map((token, idx) => (
<span key={idx}>{renderToken(token)}</span>
))}
</span>
<mrow>
{tokens.map((token, idx) => renderToken(token, idx))}
</mrow>
</math>
)
}
@@ -117,110 +121,45 @@ function parseExpression(expr: string): Token[] {
return tokens
}
function renderToken(token: Token): React.ReactNode {
function renderToken(token: Token, key: number): React.ReactNode {
switch (token.type) {
case 'number':
return (
<span className={css({ fontWeight: 'bold' })}>{token.value}</span>
)
return <mn key={key}>{token.value}</mn>
case 'fraction':
return (
<span
className={css({
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
verticalAlign: 'middle',
lineHeight: 1.1,
})}
>
<span className={css({ fontWeight: 'bold' })}>{token.numerator}</span>
<span
className={css({
width: '100%',
height: '2px',
backgroundColor: 'currentColor',
margin: '1px 0',
})}
/>
<span className={css({ fontWeight: 'bold' })}>{token.denominator}</span>
</span>
<mfrac key={key}>
<mn>{token.numerator}</mn>
<mn>{token.denominator}</mn>
</mfrac>
)
case 'mixed':
return (
<span className={hstack({ gap: '1', alignItems: 'center' })}>
<span className={css({ fontWeight: 'bold' })}>{token.whole}</span>
<span
className={css({
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
verticalAlign: 'middle',
lineHeight: 1.1,
fontSize: '0.85em',
})}
>
<span className={css({ fontWeight: 'bold' })}>{token.numerator}</span>
<span
className={css({
width: '100%',
height: '2px',
backgroundColor: 'currentColor',
margin: '1px 0',
})}
/>
<span className={css({ fontWeight: 'bold' })}>{token.denominator}</span>
</span>
</span>
<mrow key={key}>
<mn>{token.whole}</mn>
<mfrac>
<mn>{token.numerator}</mn>
<mn>{token.denominator}</mn>
</mfrac>
</mrow>
)
case 'operator':
return (
<span
className={css({
fontWeight: 'normal',
padding: '0 0.25em',
opacity: 0.8,
})}
>
{token.value}
</span>
)
return <mo key={key}>{token.value}</mo>
case 'variable':
return (
<span
className={css({
fontWeight: 'bold',
fontStyle: 'italic',
color: 'purple.600',
})}
>
{token.name}
</span>
)
return <mi key={key}>{token.name}</mi>
case 'term':
return (
<span className={css({ display: 'inline-flex', alignItems: 'baseline' })}>
<span className={css({ fontWeight: 'bold' })}>{token.coefficient}</span>
<span
className={css({
fontWeight: 'bold',
fontStyle: 'italic',
color: 'purple.600',
})}
>
{token.variable}
</span>
</span>
<mrow key={key}>
<mn>{token.coefficient}</mn>
<mi>{token.variable}</mi>
</mrow>
)
case 'text':
return (
<span className={css({ fontWeight: 'bold' })}>{token.value}</span>
)
return <mtext key={key}>{token.value}</mtext>
}
}

View File

@@ -0,0 +1,196 @@
'use client'
import { useState, useCallback } from 'react'
import * as Switch from '@radix-ui/react-switch'
import type { GenerationConstraints } from '@/lib/flowcharts/loader'
import { DEFAULT_CONSTRAINTS } from '@/lib/flowcharts/loader'
import { css } from '../../../styled-system/css'
import { vstack, hstack } from '../../../styled-system/patterns'
interface TeacherConfigPanelProps {
/** Current constraints */
constraints: GenerationConstraints
/** Called when constraints change */
onConstraintsChange: (constraints: GenerationConstraints) => void
/** Whether the panel is collapsed by default */
defaultCollapsed?: boolean
}
/**
* Teacher-facing configuration panel for controlling problem generation constraints.
* Collapsible panel with settings like "positive answers only".
*/
export function TeacherConfigPanel({
constraints,
onConstraintsChange,
defaultCollapsed = true,
}: TeacherConfigPanelProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
const handleToggle = useCallback(
(key: keyof GenerationConstraints) => {
onConstraintsChange({
...constraints,
[key]: !constraints[key],
})
},
[constraints, onConstraintsChange]
)
return (
<div
data-testid="teacher-config-panel"
data-collapsed={isCollapsed}
className={css({
backgroundColor: { base: 'gray.50', _dark: 'gray.800' },
borderRadius: 'lg',
border: '1px solid',
borderColor: { base: 'gray.200', _dark: 'gray.700' },
overflow: 'hidden',
transition: 'all 0.2s',
})}
>
{/* Header - always visible */}
<button
type="button"
onClick={() => setIsCollapsed(!isCollapsed)}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '2 3',
fontSize: 'sm',
fontWeight: 'medium',
color: { base: 'gray.600', _dark: 'gray.400' },
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
_hover: {
backgroundColor: { base: 'gray.100', _dark: 'gray.700' },
},
})}
>
<span className={hstack({ gap: '2', alignItems: 'center' })}>
<span></span>
<span>Teacher Settings</span>
</span>
<span
className={css({
transition: 'transform 0.2s',
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(180deg)',
})}
>
</span>
</button>
{/* Collapsible content */}
{!isCollapsed && (
<div
className={vstack({
gap: '3',
padding: '3',
paddingTop: '0',
alignItems: 'stretch',
})}
>
{/* Positive Answers Only */}
<ConfigSwitch
id="positive-answers"
label="Positive answers only"
description="Generated problems will always have non-negative results"
checked={constraints.positiveAnswersOnly ?? DEFAULT_CONSTRAINTS.positiveAnswersOnly ?? true}
onCheckedChange={() => handleToggle('positiveAnswersOnly')}
/>
</div>
)}
</div>
)
}
interface ConfigSwitchProps {
id: string
label: string
description: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}
function ConfigSwitch({ id, label, description, checked, onCheckedChange }: ConfigSwitchProps) {
return (
<div
className={hstack({
justifyContent: 'space-between',
gap: '3',
padding: '2',
borderRadius: 'md',
backgroundColor: { base: 'white', _dark: 'gray.900' },
border: '1px solid',
borderColor: { base: 'gray.200', _dark: 'gray.700' },
})}
>
<label
htmlFor={id}
className={vstack({
gap: '0.5',
alignItems: 'flex-start',
cursor: 'pointer',
})}
>
<span
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: { base: 'gray.800', _dark: 'gray.200' },
})}
>
{label}
</span>
<span
className={css({
fontSize: 'xs',
color: { base: 'gray.500', _dark: 'gray.500' },
})}
>
{description}
</span>
</label>
<Switch.Root
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
className={css({
width: '42px',
height: '24px',
backgroundColor: checked
? { base: 'green.500', _dark: 'green.600' }
: { base: 'gray.300', _dark: 'gray.600' },
borderRadius: 'full',
position: 'relative',
cursor: 'pointer',
transition: 'background-color 0.2s',
flexShrink: 0,
_focusVisible: {
outline: '2px solid',
outlineColor: { base: 'blue.500', _dark: 'blue.400' },
outlineOffset: '2px',
},
})}
>
<Switch.Thumb
className={css({
display: 'block',
width: '20px',
height: '20px',
backgroundColor: 'white',
borderRadius: 'full',
boxShadow: 'sm',
transition: 'transform 0.2s',
transform: checked ? 'translateX(20px)' : 'translateX(2px)',
})}
/>
</Switch.Root>
</div>
)
}

View File

@@ -0,0 +1,809 @@
'use client'
import { animated, useSpring } from '@react-spring/web'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTheme } from '@/contexts/ThemeContext'
// Dot patterns for each dice face (positions as fractions of face size)
const DICE_DOT_PATTERNS: Record<number, Array<[number, number]>> = {
1: [[0.5, 0.5]],
2: [
[0.25, 0.25],
[0.75, 0.75],
],
3: [
[0.25, 0.25],
[0.5, 0.5],
[0.75, 0.75],
],
4: [
[0.25, 0.25],
[0.75, 0.25],
[0.25, 0.75],
[0.75, 0.75],
],
5: [
[0.25, 0.25],
[0.75, 0.25],
[0.5, 0.5],
[0.25, 0.75],
[0.75, 0.75],
],
6: [
[0.25, 0.2],
[0.25, 0.5],
[0.25, 0.8],
[0.75, 0.2],
[0.75, 0.5],
[0.75, 0.8],
],
}
// Rotation needed to show each face
// Standard dice: 1 opposite 6, 2 opposite 5, 3 opposite 4
const DICE_FACE_ROTATIONS: Record<number, { rotateX: number; rotateY: number }> = {
1: { rotateX: 0, rotateY: 0 }, // front
2: { rotateX: 0, rotateY: -90 }, // right
3: { rotateX: -90, rotateY: 0 }, // top
4: { rotateX: 90, rotateY: 0 }, // bottom
5: { rotateX: 0, rotateY: 90 }, // left
6: { rotateX: 0, rotateY: 180 }, // back
}
/**
* 3D Dice Icon using react-spring for smooth animations
*
* Creates a cube with 6 faces, each showing the appropriate dot pattern.
* The cube rotates on all 3 axes when rolling, with physics-based easing.
*/
function DiceIcon({
className,
rotateX,
rotateY,
rotateZ,
isDark,
size = 22,
}: {
className?: string
rotateX: number
rotateY: number
rotateZ: number
isDark: boolean
size?: number
}) {
const halfSize = size / 2
// Animate rotation with react-spring
const springProps = useSpring({
rotateX,
rotateY,
rotateZ,
config: {
tension: 120,
friction: 14,
},
})
// Theme-aware colors
// Dark mode: lighter indigo with more contrast against dark backgrounds
// Light mode: deeper indigo that stands out against light backgrounds
const faceBackground = isDark ? '#818cf8' : '#4f46e5' // indigo-400 dark, indigo-600 light
const faceBorder = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(255, 255, 255, 0.5)'
const dotColor = isDark ? '#1e1b4b' : 'white' // indigo-950 dots on light bg in dark mode
// Render dots for a face
const renderDots = (face: number) => {
const dots = DICE_DOT_PATTERNS[face] || []
return dots.map(([x, y], i) => (
<div
key={i}
style={{
position: 'absolute',
left: `${x * 100}%`,
top: `${y * 100}%`,
width: '18%',
height: '18%',
backgroundColor: dotColor,
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
))
}
// Common face styles - opaque background to prevent artifacts during rotation
const faceStyle: React.CSSProperties = {
position: 'absolute',
width: size,
height: size,
backgroundColor: faceBackground,
border: `1.5px solid ${faceBorder}`,
borderRadius: 2,
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
}
return (
<div
className={className}
style={{
width: size,
height: size,
perspective: 100,
perspectiveOrigin: 'center',
}}
>
<animated.div
data-dice-cube
style={{
width: size,
height: size,
position: 'relative',
transformStyle: 'preserve-3d',
transform: springProps.rotateX.to(
(rx) =>
`rotateX(${rx}deg) rotateY(${springProps.rotateY.get()}deg) rotateZ(${springProps.rotateZ.get()}deg)`
),
}}
>
{/* Front face (1) */}
<div style={{ ...faceStyle, transform: `translateZ(${halfSize}px)` }}>{renderDots(1)}</div>
{/* Back face (6) */}
<div
style={{
...faceStyle,
transform: `rotateY(180deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(6)}
</div>
{/* Right face (2) */}
<div
style={{
...faceStyle,
transform: `rotateY(90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(2)}
</div>
{/* Left face (5) */}
<div
style={{
...faceStyle,
transform: `rotateY(-90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(5)}
</div>
{/* Top face (3) */}
<div
style={{
...faceStyle,
transform: `rotateX(90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(3)}
</div>
{/* Bottom face (4) */}
<div
style={{
...faceStyle,
transform: `rotateX(-90deg) translateZ(${halfSize}px)`,
}}
>
{renderDots(4)}
</div>
</animated.div>
</div>
)
}
export interface InteractiveDiceProps {
/** Called when the dice is rolled (clicked or thrown) - fires immediately */
onRoll: () => void
/** Called when dragging starts - useful for preloading/prefetching */
onDragStart?: () => void
/** Called when the dice animation completes and settles back home */
onRollComplete?: () => void
/** Whether the dice is disabled */
disabled?: boolean
/** Size of the dice in pixels (default: 22) */
size?: number
/** Title/tooltip for the dice button */
title?: string
/** Additional CSS class for the button */
className?: string
/** Button style overrides */
style?: React.CSSProperties
}
/**
* Interactive 3D dice that can be clicked or dragged and thrown.
*
* Features:
* - Click to roll
* - Drag and release to throw (physics simulation)
* - Bounces off viewport edges
* - Grows when flying, shrinks when returning
* - Smooth spring-based animations
*/
export function InteractiveDice({
onRoll,
onDragStart,
onRollComplete,
disabled = false,
size = 22,
title = 'Roll dice (drag or click)',
className,
style,
}: InteractiveDiceProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Dice rotation state for react-spring animation
// We track cumulative spins to add visual flair, then compute final rotation
// to land on the correct face derived from a random value
const [spinCount, setSpinCount] = useState(0)
// Track which face we're showing (for ensuring consecutive rolls differ)
const [currentFace, setCurrentFace] = useState(() => Math.floor(Math.random() * 5) + 2)
// Draggable dice state with physics simulation
const [isDragging, setIsDragging] = useState(false)
const [diceOrigin, setDiceOrigin] = useState({ x: 0, y: 0 })
const diceButtonRef = useRef<HTMLButtonElement>(null)
const diceContainerRef = useRef<HTMLDivElement>(null)
const dragStartPos = useRef({ x: 0, y: 0 })
// Physics state for thrown dice
const [isFlying, setIsFlying] = useState(false)
const dicePhysics = useRef({
x: 0,
y: 0,
vx: 0,
vy: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
scale: 1, // Grows to 3x when flying, shrinks back when settling
})
const lastPointerPos = useRef({ x: 0, y: 0, time: 0 })
// Track velocity samples for smoother flick detection
const velocitySamples = useRef<Array<{ vx: number; vy: number; time: number }>>([])
const animationFrameRef = useRef<number>()
// Timeout ref for click-only roll completion callback
const rollCompleteTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
// Flag to prevent click from firing after a throw (pointerup + click both fire)
const justThrewRef = useRef(false)
// Ref to the portal dice element for direct DOM manipulation during drag/flying (avoids re-renders)
const portalDiceRef = useRef<HTMLDivElement>(null)
// Compute target rotation for the current face (needed by physics simulation)
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || {
rotateX: 0,
rotateY: 0,
}
// Roll the dice - animate and call onRoll
// isFromThrow: true if this roll is from a drag-throw (physics simulation will handle completion)
const handleRoll = useCallback(
(isFromThrow = false) => {
// Clear any pending completion timeout from previous click roll
if (rollCompleteTimeoutRef.current) {
clearTimeout(rollCompleteTimeoutRef.current)
rollCompleteTimeoutRef.current = undefined
}
// Calculate target face (2-6, excluding 1 so it's clearly a dice)
const baseFace = Math.floor(Math.random() * 5) + 2
// Ensure it's different from the current face
const targetFace = baseFace === currentFace ? (baseFace === 6 ? 2 : baseFace + 1) : baseFace
// Add 1-2 full spins for visual drama
const extraSpins = Math.floor(Math.random() * 2) + 1
setSpinCount((prev) => prev + extraSpins)
setCurrentFace(targetFace)
onRoll()
// For click-only rolls (not throws), schedule completion callback after spring animation
// Spring config is tension: 120, friction: 14, which settles in ~600-700ms
if (!isFromThrow && onRollComplete) {
rollCompleteTimeoutRef.current = setTimeout(() => {
onRollComplete()
rollCompleteTimeoutRef.current = undefined
}, 700)
}
},
[currentFace, onRoll, onRollComplete]
)
// Physics simulation for thrown dice - uses direct DOM manipulation for performance
useEffect(() => {
if (!isFlying) return
const BASE_GRAVITY = 0.8 // Base pull toward origin
const BASE_FRICTION = 0.94 // Base velocity dampening (slightly less friction for longer flight)
const ROTATION_FACTOR = 0.5 // How much velocity affects rotation
const STOP_THRESHOLD = 2 // Distance threshold to snap home
const VELOCITY_THRESHOLD = 0.5 // Velocity threshold to snap home
const CLOSE_RANGE = 40 // Distance at which extra damping kicks in
const MAX_SCALE = 3 // Maximum scale when flying
const SCALE_GROW_SPEED = 0.2 // How fast to grow (faster)
const SCALE_SHRINK_SPEED = 0.06 // How fast to shrink when close (slower for drama)
const BOUNCE_DAMPING = 0.7 // How much velocity is retained on bounce (0-1)
const DICE_SIZE = size // Size of the dice in pixels
// Calculate initial throw power to adjust gravity (stronger throws = weaker initial gravity)
const initialSpeed = Math.sqrt(
dicePhysics.current.vx * dicePhysics.current.vx +
dicePhysics.current.vy * dicePhysics.current.vy
)
const throwPower = Math.min(initialSpeed / 20, 1) // 0-1 based on throw strength
let frameCount = 0
const animate = () => {
const p = dicePhysics.current
const el = portalDiceRef.current
if (!el) {
animationFrameRef.current = requestAnimationFrame(animate)
return
}
frameCount++
// Calculate distance to origin
const dist = Math.sqrt(p.x * p.x + p.y * p.y)
// Gravity ramps up over time (weak at first for strong throws, then strengthens)
const gravityRampUp = Math.min(frameCount / 30, 1) // Full gravity after ~0.5s
const effectiveGravity = BASE_GRAVITY * (0.3 + 0.7 * gravityRampUp) * (1 - throwPower * 0.5)
// Apply spring force toward origin (proportional to distance)
if (dist > 0) {
// Quadratic falloff for more natural feel
const pullStrength = effectiveGravity * (dist / 50) ** 1.2
p.vx += (-p.x / dist) * pullStrength
p.vy += (-p.y / dist) * pullStrength
}
// Apply friction - extra damping when close to prevent oscillation
const friction = dist < CLOSE_RANGE ? 0.88 : BASE_FRICTION
p.vx *= friction
p.vy *= friction
// Update position
p.x += p.vx
p.y += p.vy
// Viewport edge bounce - calculate absolute position and check bounds
const scaledSize = DICE_SIZE * p.scale
const absoluteX = diceOrigin.x + p.x
const absoluteY = diceOrigin.y + p.y
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Left edge bounce
if (absoluteX < 0) {
p.x = -diceOrigin.x // Position at left edge
p.vx = Math.abs(p.vx) * BOUNCE_DAMPING // Reverse and dampen
// Add extra spin on bounce
p.rotationZ += p.vx * 5
}
// Right edge bounce
if (absoluteX + scaledSize > viewportWidth) {
p.x = viewportWidth - diceOrigin.x - scaledSize
p.vx = -Math.abs(p.vx) * BOUNCE_DAMPING
p.rotationZ -= p.vx * 5
}
// Top edge bounce
if (absoluteY < 0) {
p.y = -diceOrigin.y
p.vy = Math.abs(p.vy) * BOUNCE_DAMPING
p.rotationZ += p.vy * 5
}
// Bottom edge bounce
if (absoluteY + scaledSize > viewportHeight) {
p.y = viewportHeight - diceOrigin.y - scaledSize
p.vy = -Math.abs(p.vy) * BOUNCE_DAMPING
p.rotationZ -= p.vy * 5
}
// Update rotation based on velocity (dice rolls as it moves)
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
// As dice gets closer to home, gradually lerp rotation toward final face
// settleProgress: 0 = far away (full physics rotation), 1 = at home (target rotation)
const SETTLE_START_DIST = 150 // Start settling rotation at this distance
const settleProgress = Math.max(0, 1 - dist / SETTLE_START_DIST)
const settleFactor = settleProgress * settleProgress * settleProgress // Cubic easing for smooth settle
// Physics rotation (tumbling)
const physicsRotationDelta = {
x: p.vy * ROTATION_FACTOR * 12,
y: -p.vx * ROTATION_FACTOR * 12,
z: speed * ROTATION_FACTOR * 3,
}
// Apply physics rotation, but reduced as we settle
p.rotationX += physicsRotationDelta.x * (1 - settleFactor)
p.rotationY += physicsRotationDelta.y * (1 - settleFactor)
p.rotationZ += physicsRotationDelta.z * (1 - settleFactor)
// Lerp toward target face rotation as we settle
// Target rotation should show the correct face (from DICE_FACE_ROTATIONS)
const lerpSpeed = 0.08 * settleFactor // Faster lerp as we get closer
if (settleFactor > 0.01) {
// Normalize rotations to find shortest path to target
const targetX = targetFaceRotation.rotateX
const targetY = targetFaceRotation.rotateY
const targetZ = 0 // Final Z rotation should be 0 (flat)
// Normalize current rotation to -180 to 180 range for smooth interpolation
const normalizeAngle = (angle: number) => {
let normalized = angle % 360
if (normalized > 180) normalized -= 360
if (normalized < -180) normalized += 360
return normalized
}
const currentX = normalizeAngle(p.rotationX)
const currentY = normalizeAngle(p.rotationY)
const currentZ = normalizeAngle(p.rotationZ)
// Lerp each axis toward target
p.rotationX = currentX + (targetX - currentX) * lerpSpeed
p.rotationY = currentY + (targetY - currentY) * lerpSpeed
p.rotationZ = currentZ + (targetZ - currentZ) * lerpSpeed
}
// Update scale - grow when far/fast, shrink when close/slow
const targetScale =
dist > CLOSE_RANGE ? MAX_SCALE : 1 + ((MAX_SCALE - 1) * dist) / CLOSE_RANGE
if (p.scale < targetScale) {
p.scale = Math.min(p.scale + SCALE_GROW_SPEED, targetScale)
} else if (p.scale > targetScale) {
p.scale = Math.max(p.scale - SCALE_SHRINK_SPEED, targetScale)
}
// Update DOM directly - no React re-renders
// Scale from center, offset position to keep visual center stable
const scaleOffset = ((p.scale - 1) * DICE_SIZE) / 2
el.style.transform = `translate(${p.x - scaleOffset}px, ${p.y - scaleOffset}px) scale(${p.scale})`
// Dynamic shadow based on scale (larger = higher = bigger shadow)
const shadowSize = (p.scale - 1) * 10
const shadowOpacity = Math.min((p.scale - 1) * 0.2, 0.4)
el.style.filter =
shadowSize > 0
? `drop-shadow(0 ${shadowSize}px ${shadowSize * 1.5}px rgba(0,0,0,${shadowOpacity}))`
: 'none'
// Update dice rotation
const diceEl = el.querySelector('[data-dice-cube]') as HTMLElement | null
if (diceEl) {
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
}
// Check if we should stop - snap to home when close, slow, small, AND rotation is settled
const totalVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
const rotationSettled =
Math.abs(p.rotationX - targetFaceRotation.rotateX) < 5 &&
Math.abs(p.rotationY - targetFaceRotation.rotateY) < 5 &&
Math.abs(p.rotationZ) < 5
if (
dist < STOP_THRESHOLD &&
totalVelocity < VELOCITY_THRESHOLD &&
p.scale < 1.1 &&
rotationSettled
) {
// Dice has returned home - clear shadow
el.style.filter = 'none'
setIsFlying(false)
dicePhysics.current = {
x: 0,
y: 0,
vx: 0,
vy: 0,
rotationX: targetFaceRotation.rotateX,
rotationY: targetFaceRotation.rotateY,
rotationZ: 0,
scale: 1,
}
// Notify that roll animation is complete
onRollComplete?.()
return
}
animationFrameRef.current = requestAnimationFrame(animate)
}
animationFrameRef.current = requestAnimationFrame(animate)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [isFlying, diceOrigin.x, diceOrigin.y, targetFaceRotation.rotateX, targetFaceRotation.rotateY, size, onRollComplete])
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (rollCompleteTimeoutRef.current) {
clearTimeout(rollCompleteTimeoutRef.current)
}
}
}, [])
// Compute dice rotation for react-spring animation (used when not flying)
const diceRotation = {
rotateX: spinCount * 360 + targetFaceRotation.rotateX,
rotateY: spinCount * 360 + targetFaceRotation.rotateY,
rotateZ: spinCount * 180, // Z rotation for extra tumble effect
}
// Dice drag handlers for the easter egg - drag dice off and release to roll
const handleDicePointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabled) return
e.preventDefault()
e.stopPropagation()
// Cancel any ongoing physics animation
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
setIsFlying(false)
// Capture the pointer
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
// Get the dice container's position for portal rendering
if (diceContainerRef.current) {
const rect = diceContainerRef.current.getBoundingClientRect()
setDiceOrigin({ x: rect.left, y: rect.top })
}
dragStartPos.current = { x: e.clientX, y: e.clientY }
lastPointerPos.current = {
x: e.clientX,
y: e.clientY,
time: performance.now(),
}
velocitySamples.current = [] // Reset velocity tracking
dicePhysics.current = {
x: 0,
y: 0,
vx: 0,
vy: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
scale: 1,
}
setIsDragging(true)
// Notify that drag started - useful for prefetching
onDragStart?.()
},
[disabled, onDragStart]
)
const handleDicePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging) return
e.preventDefault()
const dx = e.clientX - dragStartPos.current.x
const dy = e.clientY - dragStartPos.current.y
// Calculate drag velocity for live rotation
const now = performance.now()
const dt = Math.max(now - lastPointerPos.current.time, 8)
const vx = (e.clientX - lastPointerPos.current.x) / dt
const vy = (e.clientY - lastPointerPos.current.y) / dt
// Update rotation based on drag velocity (dice tumbles while being dragged)
const p = dicePhysics.current
p.rotationX += vy * 8
p.rotationY -= vx * 8
p.rotationZ += Math.sqrt(vx * vx + vy * vy) * 2
// Scale up slightly based on distance (feels like pulling it out)
const dist = Math.sqrt(dx * dx + dy * dy)
p.scale = 1 + Math.min(dist / 150, 0.5) // Max 1.5x during drag
// Update DOM directly to avoid React re-renders during drag
if (portalDiceRef.current) {
const scaleOffset = ((p.scale - 1) * size) / 2
portalDiceRef.current.style.transform = `translate(${dx - scaleOffset}px, ${dy - scaleOffset}px) scale(${p.scale})`
// Add shadow that grows with distance
const shadowSize = Math.min(dist / 10, 20)
const shadowOpacity = Math.min(dist / 200, 0.4)
portalDiceRef.current.style.filter = `drop-shadow(0 ${shadowSize}px ${shadowSize * 1.5}px rgba(0,0,0,${shadowOpacity}))`
// Update dice rotation
const diceEl = portalDiceRef.current.querySelector('[data-dice-cube]') as HTMLElement | null
if (diceEl) {
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
}
}
// Store position in ref for use when releasing
p.x = dx
p.y = dy
// Track velocity samples for flick detection (keep last 5 samples, ~80ms window)
velocitySamples.current.push({ vx, vy, time: now })
if (velocitySamples.current.length > 5) {
velocitySamples.current.shift()
}
// Track velocity for throw calculation
lastPointerPos.current = { x: e.clientX, y: e.clientY, time: now }
},
[isDragging, size]
)
const handleDicePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!isDragging) return
e.preventDefault()
// Release the pointer
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
// Calculate throw velocity from velocity samples (average of recent samples for smooth flick)
const samples = velocitySamples.current
let vx = 0
let vy = 0
if (samples.length > 0) {
// Weight recent samples more heavily
let totalWeight = 0
for (let i = 0; i < samples.length; i++) {
const weight = i + 1 // Later samples get higher weight
vx += samples[i].vx * weight
vy += samples[i].vy * weight
totalWeight += weight
}
vx /= totalWeight
vy /= totalWeight
}
// Get position from physics ref (set during drag)
const posX = dicePhysics.current.x
const posY = dicePhysics.current.y
// Calculate distance dragged
const distance = Math.sqrt(posX ** 2 + posY ** 2)
// Calculate flick speed
const flickSpeed = Math.sqrt(vx * vx + vy * vy)
// If dragged more than 20px OR flicked fast enough, trigger throw physics
if (distance > 20 || flickSpeed > 0.3) {
// Amplify throw velocity significantly for satisfying flick
const throwMultiplier = 25 // Much stronger throw!
// Initialize physics with current position and throw velocity
dicePhysics.current = {
x: posX,
y: posY,
vx: vx * throwMultiplier,
vy: vy * throwMultiplier,
rotationX: dicePhysics.current.rotationX, // Keep current rotation
rotationY: dicePhysics.current.rotationY,
rotationZ: dicePhysics.current.rotationZ,
scale: dicePhysics.current.scale, // Keep current scale
}
setIsFlying(true)
// Mark that we just threw - prevents click event from also firing handleRoll
justThrewRef.current = true
// Trigger roll when thrown (pass true to indicate physics will handle completion)
handleRoll(true)
} else {
// Not thrown far enough, snap back
dicePhysics.current = {
x: 0,
y: 0,
vx: 0,
vy: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
scale: 1,
}
}
setIsDragging(false)
},
[isDragging, handleRoll]
)
return (
<>
<button
ref={diceButtonRef}
type="button"
data-action="roll-dice"
onClick={() => {
// Skip if we just did a throw (pointerup already called handleRoll)
if (justThrewRef.current) {
justThrewRef.current = false
return
}
handleRoll()
}}
onPointerDown={handleDicePointerDown}
onPointerMove={handleDicePointerMove}
onPointerUp={handleDicePointerUp}
onPointerCancel={handleDicePointerUp}
disabled={disabled}
title={title}
className={className}
style={{
cursor: isDragging ? 'grabbing' : disabled ? 'not-allowed' : 'grab',
opacity: disabled ? 0.7 : 1,
touchAction: 'none', // Prevent scroll on touch devices
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...style,
}}
>
{/* Dice container - hidden when dragging/flying since we show portal version */}
<div
ref={diceContainerRef}
style={{
opacity: isDragging || isFlying ? 0 : 1,
pointerEvents: 'none',
}}
>
<DiceIcon
rotateX={diceRotation.rotateX}
rotateY={diceRotation.rotateY}
rotateZ={diceRotation.rotateZ}
isDark={isDark}
size={size}
/>
</div>
</button>
{/* Portal-rendered dice when dragging/flying - renders outside button to avoid clipping */}
{/* All transforms are applied directly via ref for performance - no React re-renders */}
{(isDragging || isFlying) &&
createPortal(
<div
ref={portalDiceRef}
style={{
position: 'fixed',
left: diceOrigin.x,
top: diceOrigin.y,
// Transform is updated directly via ref during drag/flying
transform: 'translate(0px, 0px)',
pointerEvents: 'none',
zIndex: 100000,
cursor: isDragging ? 'grabbing' : 'default',
willChange: 'transform', // Hint to browser for GPU acceleration
}}
>
{/* During flying, rotation is updated directly on data-dice-cube element */}
<DiceIcon
rotateX={diceRotation.rotateX}
rotateY={diceRotation.rotateY}
rotateZ={diceRotation.rotateZ}
isDark={isDark}
size={size}
/>
</div>,
document.body
)}
</>
)
}

View File

@@ -0,0 +1,434 @@
/**
* Constraint Parser
*
* Parses correctAnswer expressions from flowchart decision nodes into
* structured constraints that can guide problem generation.
*
* Recognizes patterns like:
* - "a == b" (equality)
* - "a != b" (inequality)
* - "a > b", "a >= b", "a < b", "a <= b" (comparison)
* - "a % b == 0" (divisibility)
* - "a == 0", "a != 0" (zero checks)
* - Boolean expressions with && and ||
*/
// =============================================================================
// Types
// =============================================================================
export type ComparisonOp = '==' | '!=' | '>' | '>=' | '<' | '<='
/**
* A parsed constraint representing a comparison between two operands.
*/
export interface ParsedConstraint {
/** Type of constraint */
type: 'comparison' | 'divisibility' | 'boolean'
/** Left operand (field name or expression) */
left: string
/** Comparison operator */
op: ComparisonOp
/** Right operand (field name, literal, or expression) */
right: string
/**
* Whether the right side is a literal value.
* If true, we can directly constrain the left field.
*/
rightIsLiteral: boolean
/**
* The literal value if rightIsLiteral is true.
*/
literalValue?: number | string | boolean
/**
* For divisibility constraints (a % b == 0), the divisor field.
*/
divisor?: string
/**
* The original expression string.
*/
original: string
}
/**
* Result of parsing a correctAnswer expression.
*/
export interface ParseResult {
/** Successfully parsed constraints */
constraints: ParsedConstraint[]
/** Whether the expression was fully parsed */
fullyParsed: boolean
/** Original expression */
original: string
}
// =============================================================================
// Parser
// =============================================================================
/**
* Parse a correctAnswer expression into structured constraints.
*
* @param expression The correctAnswer expression string
* @returns ParseResult with extracted constraints
*/
export function parseConstraintExpression(expression: string): ParseResult {
const constraints: ParsedConstraint[] = []
const original = expression.trim()
// Handle simple boolean literals
if (original === 'true' || original === 'false') {
return {
constraints: [{
type: 'boolean',
left: original,
op: '==',
right: 'true',
rightIsLiteral: true,
literalValue: original === 'true',
original,
}],
fullyParsed: true,
original,
}
}
// Try to parse as a single comparison
const singleConstraint = parseSingleComparison(original)
if (singleConstraint) {
constraints.push(singleConstraint)
return { constraints, fullyParsed: true, original }
}
// Try to parse && expressions (all must be true)
if (original.includes('&&')) {
const parts = splitOnOperator(original, '&&')
let allParsed = true
for (const part of parts) {
const constraint = parseSingleComparison(part.trim())
if (constraint) {
constraints.push(constraint)
} else {
allParsed = false
}
}
return { constraints, fullyParsed: allParsed, original }
}
// Try to parse || expressions (any can be true)
// For generation, we'll pick one branch to satisfy
if (original.includes('||')) {
const parts = splitOnOperator(original, '||')
// For OR expressions, we parse all branches but mark as not fully parsed
// since the generator needs to decide which branch to take
for (const part of parts) {
const constraint = parseSingleComparison(part.trim())
if (constraint) {
constraints.push(constraint)
}
}
return { constraints, fullyParsed: false, original }
}
// Couldn't parse - return empty constraints
return { constraints: [], fullyParsed: false, original }
}
/**
* Parse a single comparison expression like "a == b" or "a != 0".
*/
function parseSingleComparison(expr: string): ParsedConstraint | null {
const trimmed = expr.trim()
// Remove outer parentheses if present
const unwrapped = unwrapParens(trimmed)
// Check for divisibility pattern: a % b == 0
const divMatch = unwrapped.match(/^(.+?)\s*%\s*(.+?)\s*==\s*0$/)
if (divMatch) {
return {
type: 'divisibility',
left: divMatch[1].trim(),
op: '==',
right: '0',
rightIsLiteral: true,
literalValue: 0,
divisor: divMatch[2].trim(),
original: expr,
}
}
// Check for comparison operators (order matters - check >= before >)
const ops: ComparisonOp[] = ['==', '!=', '>=', '<=', '>', '<']
for (const op of ops) {
const parts = splitOnOperator(unwrapped, op)
if (parts.length === 2) {
const left = parts[0].trim()
const right = parts[1].trim()
// Check if right side is a literal
const literalValue = parseLiteral(right)
const rightIsLiteral = literalValue !== undefined
return {
type: 'comparison',
left,
op,
right,
rightIsLiteral,
literalValue,
original: expr,
}
}
}
return null
}
/**
* Split an expression on an operator, respecting parentheses.
*/
function splitOnOperator(expr: string, operator: string): string[] {
const result: string[] = []
let current = ''
let parenDepth = 0
let i = 0
while (i < expr.length) {
const char = expr[i]
if (char === '(') {
parenDepth++
current += char
i++
} else if (char === ')') {
parenDepth--
current += char
i++
} else if (parenDepth === 0 && expr.substring(i, i + operator.length) === operator) {
result.push(current)
current = ''
i += operator.length
} else {
current += char
i++
}
}
if (current) {
result.push(current)
}
return result
}
/**
* Remove outer parentheses from an expression.
*/
function unwrapParens(expr: string): string {
const trimmed = expr.trim()
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
// Check if the parens actually wrap the whole expression
let depth = 0
for (let i = 0; i < trimmed.length - 1; i++) {
if (trimmed[i] === '(') depth++
if (trimmed[i] === ')') depth--
if (depth === 0) return trimmed // Parens don't wrap whole thing
}
return unwrapParens(trimmed.slice(1, -1))
}
return trimmed
}
/**
* Try to parse a string as a literal value.
*/
function parseLiteral(value: string): number | string | boolean | undefined {
const trimmed = value.trim()
// Boolean
if (trimmed === 'true') return true
if (trimmed === 'false') return false
// Number
const num = parseFloat(trimmed)
if (!isNaN(num) && isFinite(num) && String(num) === trimmed) {
return num
}
// Integer (handles cases like "0" that parseFloat would also handle)
const int = parseInt(trimmed, 10)
if (!isNaN(int) && String(int) === trimmed) {
return int
}
// String literal (quoted)
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1)
}
return undefined
}
// =============================================================================
// Constraint Analysis
// =============================================================================
/**
* Extract field names referenced in a constraint.
*/
export function extractFieldsFromConstraint(constraint: ParsedConstraint): string[] {
const fields: string[] = []
// Add left side (could be a field or expression)
const leftFields = extractFieldsFromExpression(constraint.left)
fields.push(...leftFields)
// Add right side if not a literal
if (!constraint.rightIsLiteral) {
const rightFields = extractFieldsFromExpression(constraint.right)
fields.push(...rightFields)
}
// Add divisor if present
if (constraint.divisor) {
const divisorFields = extractFieldsFromExpression(constraint.divisor)
fields.push(...divisorFields)
}
return [...new Set(fields)] // Deduplicate
}
/**
* Extract field names from an expression string.
* Recognizes patterns like: fieldName, obj.field, field1 + field2
*/
function extractFieldsFromExpression(expr: string): string[] {
const fields: string[] = []
// Match identifiers (including dot notation like left.denom)
const identifierRegex = /[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*/g
const matches = expr.match(identifierRegex) || []
for (const match of matches) {
// Filter out keywords and function names
const keywords = ['true', 'false', 'null', 'undefined', 'floor', 'ceil', 'abs', 'min', 'max', 'lcm', 'gcd']
if (!keywords.includes(match)) {
fields.push(match)
}
}
return fields
}
/**
* Negate a constraint (for when correctAnswer must be false).
*/
export function negateConstraint(constraint: ParsedConstraint): ParsedConstraint {
const negatedOps: Record<ComparisonOp, ComparisonOp> = {
'==': '!=',
'!=': '==',
'>': '<=',
'>=': '<',
'<': '>=',
'<=': '>',
}
return {
...constraint,
op: negatedOps[constraint.op],
original: `!(${constraint.original})`,
}
}
/**
* Determine if a constraint can be directly used to filter field values.
* Returns the field name and filter function if applicable.
*/
export interface FieldFilter {
field: string
filter: (value: number | string) => boolean
description: string
}
export function constraintToFieldFilter(constraint: ParsedConstraint): FieldFilter | null {
// Only handle simple cases where left is a single field and right is a literal
if (!constraint.rightIsLiteral) return null
if (constraint.literalValue === undefined) return null
const field = constraint.left
const literal = constraint.literalValue
// Check if left is a simple field (no operators)
if (field.includes('+') || field.includes('-') || field.includes('*') || field.includes('/')) {
return null
}
switch (constraint.op) {
case '==':
return {
field,
filter: (v) => v === literal,
description: `${field} must equal ${literal}`,
}
case '!=':
return {
field,
filter: (v) => v !== literal,
description: `${field} must not equal ${literal}`,
}
case '>':
if (typeof literal === 'number') {
return {
field,
filter: (v) => typeof v === 'number' && v > literal,
description: `${field} must be greater than ${literal}`,
}
}
break
case '>=':
if (typeof literal === 'number') {
return {
field,
filter: (v) => typeof v === 'number' && v >= literal,
description: `${field} must be at least ${literal}`,
}
}
break
case '<':
if (typeof literal === 'number') {
return {
field,
filter: (v) => typeof v === 'number' && v < literal,
description: `${field} must be less than ${literal}`,
}
}
break
case '<=':
if (typeof literal === 'number') {
return {
field,
filter: (v) => typeof v === 'number' && v <= literal,
description: `${field} must be at most ${literal}`,
}
}
break
}
return null
}

View File

@@ -3,6 +3,26 @@
"title": "Fraction Addition & Subtraction",
"mermaidFile": "fraction-flowchart.mmd",
"generation": {
"preferred": {
"leftWhole": [0, 1, 2, 3, 4, 5],
"leftNum": [1, 2, 3, 4, 5],
"leftDenom": [2, 3, 4, 5, 6, 8, 10, 12],
"rightWhole": [0, 1, 2, 3, 4],
"rightNum": [1, 2, 3, 4, 5],
"rightDenom": [2, 3, 4, 5, 6, 8, 10, 12]
}
},
"constraints": {
"properLeftFraction": "leftNum < leftDenom",
"properRightFraction": "rightNum < rightDenom",
"positiveResult": "op == '+' || (leftWhole + leftNum / leftDenom) >= (rightWhole + rightNum / rightDenom)",
"notIdentical": "leftWhole != rightWhole || leftNum != rightNum || leftDenom != rightDenom",
"meaningfulSubtraction": "op == '+' || (leftWhole + leftNum / leftDenom) - (rightWhole + rightNum / rightDenom) >= 0.25",
"nonTrivialNumerators": "leftNum > 0 && rightNum > 0"
},
"problemInput": {
"schema": "two-fractions-with-op",
"fields": [
@@ -14,7 +34,59 @@
{ "name": "rightNum", "type": "integer", "min": 0, "max": 99, "label": "Right numerator" },
{ "name": "rightDenom", "type": "integer", "min": 1, "max": 99, "label": "Right denominator" }
],
"validation": "leftDenom > 0 && rightDenom > 0"
"validation": "leftDenom > 0 && rightDenom > 0",
"examples": [
{
"name": "Same denom + add (simple)",
"description": "Denominators match, no whole numbers",
"values": { "leftWhole": 0, "leftNum": 1, "leftDenom": 4, "op": "+", "rightWhole": 0, "rightNum": 2, "rightDenom": 4 }
},
{
"name": "Same denom + add (mixed)",
"description": "Denominators match, with whole numbers",
"values": { "leftWhole": 2, "leftNum": 1, "leftDenom": 5, "op": "+", "rightWhole": 1, "rightNum": 2, "rightDenom": 5 }
},
{
"name": "Same denom + sub (no borrow)",
"description": "Denominators match, top fraction bigger",
"values": { "leftWhole": 3, "leftNum": 3, "leftDenom": 4, "op": "", "rightWhole": 1, "rightNum": 1, "rightDenom": 4 }
},
{
"name": "Same denom + sub (borrow)",
"description": "Denominators match, need to borrow",
"values": { "leftWhole": 3, "leftNum": 1, "leftDenom": 4, "op": "", "rightWhole": 1, "rightNum": 3, "rightDenom": 4 }
},
{
"name": "One divides + add (simple)",
"description": "4 divides 8, no whole numbers",
"values": { "leftWhole": 0, "leftNum": 1, "leftDenom": 4, "op": "+", "rightWhole": 0, "rightNum": 3, "rightDenom": 8 }
},
{
"name": "One divides + add (mixed)",
"description": "4 divides 8, with whole numbers",
"values": { "leftWhole": 2, "leftNum": 1, "leftDenom": 4, "op": "+", "rightWhole": 1, "rightNum": 3, "rightDenom": 8 }
},
{
"name": "One divides + sub (borrow)",
"description": "3 divides 6, need to borrow",
"values": { "leftWhole": 3, "leftNum": 1, "leftDenom": 6, "op": "", "rightWhole": 1, "rightNum": 2, "rightDenom": 3 }
},
{
"name": "LCD + add (simple)",
"description": "Find LCD=12, no whole numbers",
"values": { "leftWhole": 0, "leftNum": 1, "leftDenom": 3, "op": "+", "rightWhole": 0, "rightNum": 1, "rightDenom": 4 }
},
{
"name": "LCD + add (mixed)",
"description": "Find LCD=12, with whole numbers",
"values": { "leftWhole": 2, "leftNum": 2, "leftDenom": 3, "op": "+", "rightWhole": 1, "rightNum": 3, "rightDenom": 4 }
},
{
"name": "LCD + sub (borrow)",
"description": "Find LCD=12 and borrow",
"values": { "leftWhole": 4, "leftNum": 1, "leftDenom": 3, "op": "", "rightWhole": 2, "rightNum": 3, "rightDenom": 4 }
}
]
},
"variables": {
@@ -44,8 +116,8 @@
"type": "decision",
"correctAnswer": "sameBottom",
"options": [
{ "label": "YES ✓", "value": "yes", "next": "READY1" },
{ "label": "NO", "value": "no", "next": "STEP2" }
{ "label": "YES ✓", "value": "yes", "next": "READY1", "pathLabel": "Same" },
{ "label": "NO", "value": "no", "next": "STEP2", "pathLabel": "Diff" }
]
},
"READY1": {
@@ -56,8 +128,8 @@
"type": "decision",
"correctAnswer": "oneDividesOther",
"options": [
{ "label": "YES", "value": "yes", "next": "CONV1A" },
{ "label": "NO", "value": "no", "next": "STEP3" }
{ "label": "YES", "value": "yes", "next": "CONV1A", "pathLabel": "Divides" },
{ "label": "NO", "value": "no", "next": "STEP3", "pathLabel": "LCD" }
]
},
"CONV1A": {
@@ -112,8 +184,8 @@
"type": "decision",
"correctAnswer": "!isSubtraction",
"options": [
{ "label": " Adding", "value": "add", "next": "GOSTEP4" },
{ "label": " Subtracting", "value": "sub", "next": "BORROWCHECK" }
{ "label": " Adding", "value": "add", "next": "GOSTEP4", "pathLabel": "+" },
{ "label": " Subtracting", "value": "sub", "next": "BORROWCHECK", "pathLabel": "" }
]
},
"GOSTEP4": {
@@ -124,8 +196,8 @@
"type": "decision",
"correctAnswer": "!needsBorrow",
"options": [
{ "label": "YES ✓ (big enough)", "value": "yes", "next": "GOSTEP4B" },
{ "label": "😱 NO! (need borrow)", "value": "no", "next": "BORROW" }
{ "label": "YES ✓ (big enough)", "value": "yes", "next": "GOSTEP4B", "pathLabel": "no borrow" },
{ "label": "😱 NO! (need borrow)", "value": "no", "next": "BORROW", "pathLabel": "borrow" }
]
},
"GOSTEP4B": {

View File

@@ -174,7 +174,7 @@ export const FLOWCHARTS: Record<
{ definition: FlowchartDefinition; mermaid: string; meta: FlowchartMeta }
> = {
'subtraction-regrouping': {
definition: subtractionDefinition as FlowchartDefinition,
definition: subtractionDefinition as unknown as FlowchartDefinition,
mermaid: SUBTRACTION_MERMAID,
meta: {
id: 'subtraction-regrouping',

View File

@@ -3,6 +3,29 @@
"title": "Solving Linear Equations",
"mermaidFile": "linear-equations-flowchart.mmd",
"generation": {
"target": "answer",
"derived": {
"coefficient": "constant == 0 ? 2 + (answer % 5) : 1",
"equals": "constant == 0 ? coefficient * answer : (operation == '+' ? answer + constant : answer - constant)"
},
"preferred": {
"answer": [2, 3, 4, 5, 6, 7, 8, 9, 10],
"constant": [0, 2, 3, 4, 5, 6, 7, 8, 9, 10]
}
},
"constraints": {
"positiveAnswer": "answer > 0",
"integerAnswer": "floor(answer) == answer",
"oneStep": "(constant == 0) != (coefficient == 1)",
"positiveEquals": "equals > 0"
},
"display": {
"problem": "coefficient == 1 ? 'x ' + operation + ' ' + constant + ' = ' + equals : coefficient + 'x = ' + equals"
},
"problemInput": {
"schema": "linear-equation",
"fields": [
@@ -11,7 +34,24 @@
{ "name": "constant", "type": "integer", "min": 0, "max": 99, "label": "Constant" },
{ "name": "equals", "type": "integer", "min": 0, "max": 99, "label": "Equals" }
],
"validation": "coefficient > 0"
"validation": "coefficient > 0",
"examples": [
{
"name": "Addition constant",
"description": "2x + 4 = 10 → subtract to isolate x term",
"values": { "coefficient": 2, "operation": "+", "constant": 4, "equals": 10 }
},
{
"name": "Subtraction constant",
"description": "3x 6 = 9 → add to isolate x term",
"values": { "coefficient": 3, "operation": "", "constant": 6, "equals": 9 }
},
{
"name": "No constant",
"description": "4x = 20 → just divide both sides",
"values": { "coefficient": 4, "operation": "+", "constant": 0, "equals": 20 }
}
]
},
"variables": {
@@ -40,8 +80,8 @@
"type": "decision",
"correctAnswer": "constant != 0",
"options": [
{ "label": "ADDED ON (+/)", "value": "add", "next": "STUCK_ADD" },
{ "label": "MULTIPLIED IN (×/÷)", "value": "mul", "next": "STUCK_MUL" }
{ "label": "ADDED ON (+/)", "value": "add", "next": "STUCK_ADD", "pathLabel": "Undo +" },
{ "label": "MULTIPLIED IN (×/÷)", "value": "mul", "next": "STUCK_MUL", "pathLabel": "÷" }
]
},
"STUCK_ADD": {

View File

@@ -3,13 +3,38 @@
"title": "Subtraction with Regrouping",
"mermaidFile": "subtraction-regrouping-flowchart.mmd",
"generation": {
"preferred": {
"minuend": { "range": [30, 89], "step": 1 },
"subtrahend": { "range": [11, 49], "step": 1 }
}
},
"constraints": {
"positiveAnswer": "minuend > subtrahend",
"meaningfulDifference": "minuend - subtrahend >= 5",
"notTooEasy": "minuend - subtrahend <= minuend - 10"
},
"problemInput": {
"schema": "two-digit-subtraction",
"fields": [
{ "name": "minuend", "type": "integer", "min": 10, "max": 99, "label": "Top number" },
{ "name": "subtrahend", "type": "integer", "min": 10, "max": 99, "label": "Bottom number" }
],
"validation": "minuend > subtrahend"
"validation": "minuend > subtrahend",
"examples": [
{
"name": "No regrouping",
"description": "Top ones digit is bigger - no borrowing needed",
"values": { "minuend": 57, "subtrahend": 23 }
},
{
"name": "With regrouping",
"description": "Top ones digit is smaller - need to borrow",
"values": { "minuend": 52, "subtrahend": 37 }
}
]
},
"variables": {
@@ -38,8 +63,8 @@
"type": "decision",
"correctAnswer": "!needsBorrow",
"options": [
{ "label": "YES", "value": "yes", "next": "HAPPY" },
{ "label": "NO", "value": "no", "next": "SAD" }
{ "label": "YES", "value": "yes", "next": "HAPPY", "pathLabel": "No regroup" },
{ "label": "NO", "value": "no", "next": "SAD", "pathLabel": "Regroup" }
]
},
"HAPPY": {

File diff suppressed because it is too large Load Diff

View File

@@ -31,32 +31,33 @@ export function parseNodeContent(raw: string): ParsedNodeContent {
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
const lines = decoded.split(/<br\s*\/?>/i)
let title = ''
const body: string[] = []
let example: string | undefined
let warning: string | undefined
const checklist: string[] = []
// First, try to extract title from <b>...</b> tags (may span multiple lines via <br/>)
// This handles cases like <b>Title Line 1<br/>Title Line 2</b>
const boldMatch = decoded.match(/<b>([\s\S]*?)<\/b>/)
if (boldMatch) {
// Replace <br/> with spaces in the title to make it single line
title = stripHtml(boldMatch[1].replace(/<br\s*\/?>/gi, ' '))
}
// Remove the bold section from content before splitting for body processing
const contentWithoutTitle = boldMatch
? decoded.replace(/<b>[\s\S]*?<\/b>/, '')
: decoded
const lines = contentWithoutTitle.split(/<br\s*\/?>/i)
let inExample = false
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
// Extract title from <b>...</b>
const boldMatch = trimmed.match(/<b>([^<]+)<\/b>/)
if (boldMatch && !title) {
title = stripHtml(boldMatch[1])
// If there's more content after the title on the same line, add to body
const afterBold = trimmed.replace(/<b>[^<]+<\/b>/, '').trim()
if (afterBold && !afterBold.match(/^─+$/)) {
body.push(stripHtml(afterBold))
}
continue
}
// Skip divider lines
if (trimmed.match(/^─+$/)) {
continue
@@ -92,7 +93,8 @@ export function parseNodeContent(raw: string): ParsedNodeContent {
}
return {
title: title || stripHtml(raw.split(/<br/i)[0]),
// If no <b>...</b> title was found, use the first line as the title
title: title || stripHtml(decoded.split(/<br/i)[0]),
body,
example,
warning,

View File

@@ -57,12 +57,24 @@ export interface DynamicField extends BaseField {
export type Field = IntegerField | NumberField | ChoiceField | MixedNumberField | DynamicField
/** An example problem that can be pre-loaded */
export interface ProblemExample {
/** Display name for the example (e.g., "No regrouping") */
name: string
/** Description of the path this covers */
description?: string
/** Values to populate the form with */
values: Record<string, ProblemValue>
}
/** Problem input schema definition */
export interface ProblemInputSchema {
schema: string
fields: Field[]
/** Optional expression that must evaluate to true for input to be valid */
validation?: string
/** Pre-defined example problems that cover different paths */
examples?: ProblemExample[]
}
// =============================================================================
@@ -143,6 +155,8 @@ export interface DecisionOption {
value: string
/** Next node if this option is selected */
next: string
/** Human-readable label for path descriptors (e.g., "Undo +", "Same denom") */
pathLabel?: string
}
/**
@@ -189,6 +203,63 @@ export type FlowchartNode =
| MilestoneNode
| TerminalNode
// =============================================================================
// Generation Configuration
// =============================================================================
/**
* Preferred values for a field during generation.
* Can be an array of specific values or a range configuration.
*/
export type PreferredValues = number[] | string[] | {
range: [number, number]
step?: number
}
/**
* Configuration for constraint-guided example generation.
* This allows the flowchart to express how to generate pedagogically
* appropriate problems without schema-specific code in the engine.
*/
export interface GenerationConfig {
/**
* The "target" field - typically the answer we're solving for.
* Generated first with nice values, then other fields are derived/generated.
* Should reference a variable name from the variables section.
*/
target?: string
/**
* Fields that are computed from other fields rather than generated randomly.
* Maps field name to expression that computes it.
* Example: { "equals": "coefficient * answer + constant" }
*/
derived?: Record<string, string>
/**
* Preferred values for fields - pedagogically nice numbers.
* The engine will bias generation toward these values.
*/
preferred?: Record<string, PreferredValues>
}
/**
* Teacher/flowchart-defined constraints that generated problems must satisfy.
* Each constraint is an expression that must evaluate to true.
*/
export type GenerationConstraints = Record<string, string>
/**
* Configuration for how to display the problem.
*/
export interface DisplayConfig {
/**
* Expression that evaluates to the problem display string.
* Example: "coefficient + 'x ' + operation + ' ' + constant + ' = ' + equals"
*/
problem?: string
}
// =============================================================================
// Flowchart Definition
// =============================================================================
@@ -221,6 +292,24 @@ export interface FlowchartDefinition {
* When defined, displays an evolving problem representation as the user progresses.
*/
workingProblem?: WorkingProblemConfig
/**
* Optional: Configuration for constraint-guided example generation.
* Defines how to generate pedagogically appropriate problems.
*/
generation?: GenerationConfig
/**
* Optional: Constraints that generated problems must satisfy.
* Each key is a constraint name, value is an expression that must be true.
* Example: { "positiveAnswer": "answer > 0", "integerAnswer": "floor(answer) == answer" }
*/
constraints?: GenerationConstraints
/**
* Optional: Configuration for how to display the problem.
*/
display?: DisplayConfig
}
// =============================================================================

48
apps/web/src/types/mathml.d.ts vendored Normal file
View File

@@ -0,0 +1,48 @@
/**
* MathML JSX type declarations for React
* Browser support: 94%+ (Chrome 109+, Firefox, Safari 10+, Edge 109+)
*/
import * as React from 'react'
declare global {
namespace JSX {
interface IntrinsicElements {
// MathML root element
math: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement>
// MathML token elements
mi: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // identifier
mn: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // number
mo: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // operator
ms: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // string literal
mtext: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // text
// MathML layout elements
mrow: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // group
mfrac: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // fraction
msqrt: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // square root
mroot: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // nth root
msup: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // superscript
msub: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // subscript
msubsup: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // sub+superscript
munder: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // underscript
mover: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // overscript
munderover: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // under+overscript
mtable: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // table
mtr: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // table row
mtd: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // table cell
mfenced: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // fenced (parentheses)
mspace: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // space
mpadded: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // padded
mphantom: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // phantom (invisible)
menclose: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // enclose
// MathML semantic elements
semantics: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement>
annotation: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement>
}
}
}
export {}