feat(worksheets): add draggable dice easter egg with physics

Add a fun easter egg where the dice in the worksheet action menu can be
dragged and thrown. The dice:
- Tracks pointer movement and calculates throw velocity
- Uses physics simulation with gravity pulling back to origin
- Rolls continuously based on movement direction and speed
- Uses direct DOM manipulation for smooth 60fps animation
- Triggers shuffle when thrown and returns home

Also includes worksheet improvements:
- Conditional name field display (hide when empty/default)
- Date positioned top-right next to QR code
- Reduced problem number size
- Tightened header-to-grid spacing
- Problem numbers aligned to cell corners

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-05 16:24:58 -06:00
parent a0e73d971b
commit b8e66dfc17
7 changed files with 607 additions and 70 deletions

View File

@@ -96,7 +96,9 @@
"Bash(apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierStyle.ts )",
"Bash(apps/web/src/arcade-games/know-your-world/features/cursor/ )",
"Bash(apps/web/src/arcade-games/know-your-world/features/interaction/ )",
"Bash(apps/web/src/arcade-games/know-your-world/utils/heatStyles.ts)"
"Bash(apps/web/src/arcade-games/know-your-world/utils/heatStyles.ts)",
"Bash(ping:*)",
"WebFetch(domain:typst.app)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,79 @@
# Answer Key Feature Implementation Plan
## Design Decisions
1. **Format**: Compact list (e.g., `1. 45 + 27 = 72`)
2. **Placement**: End of PDF (after all worksheet pages)
3. **Problem numbers**: Match worksheet config - show if `displayRules.problemNumbers !== 'never'`
## Implementation Steps
### 1. Add config option
- **File**: `types.ts`
- Add `includeAnswerKey?: boolean` to `WorksheetFormState`
- Default: `false`
### 2. Update validation
- **File**: `validation.ts`
- Pass through `includeAnswerKey` in validated config
### 3. Create answer key generator
- **File**: `typstGenerator.ts` (new function)
- Function: `generateAnswerKeyTypst(config, problems, showProblemNumbers)`
- Output: Typst source for answer key page(s)
- Format: Compact multi-column list
```
Answer Key
1. 45 + 27 = 72 11. 33 + 18 = 51
2. 89 + 34 = 123 12. 56 + 77 = 133
...
```
### 4. Integrate into page generation
- **File**: `typstGenerator.ts`
- After worksheet pages, if `includeAnswerKey`:
```typescript
if (config.includeAnswerKey) {
const answerKeyPages = generateAnswerKeyTypst(config, problems, showProblemNumbers)
return [...worksheetPages, ...answerKeyPages]
}
```
### 5. Add UI toggle
- **File**: Find worksheet config form component
- Add checkbox: "Include Answer Key"
### 6. Update preview (optional)
- Show answer key pages in preview carousel
## Answer Key Typst Template
```typst
#set page(paper: "us-letter", margin: 0.5in)
#set text(font: "New Computer Modern Math", size: 12pt)
#align(center)[
#text(size: 18pt, weight: "bold")[Answer Key]
#v(0.5em)
#text(size: 12pt, fill: gray)[{worksheet name}]
]
#v(1em)
#columns(3, gutter: 1em)[
// Problem 1
#text[*1.* 45 + 27 = *72*]
// Problem 2
#text[*2.* 89 34 = *55*]
// etc...
]
```
## Files to Modify
1. `types.ts` - Add `includeAnswerKey` field
2. `validation.ts` - Pass through new field
3. `typstGenerator.ts` - Add answer key generation
4. Worksheet form component - Add UI toggle
5. Preview component (optional) - Show answer key pages

View File

@@ -5,6 +5,7 @@ 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 { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal'
import { useTheme } from '@/contexts/ThemeContext'
@@ -146,6 +147,7 @@ function DiceIcon({
}}
>
<animated.div
data-dice-cube
style={{
width: size,
height: size,
@@ -222,6 +224,104 @@ export function PreviewCenter({
// 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,
})
const lastPointerPos = useRef({ x: 0, y: 0, time: 0 })
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)
// Physics simulation for thrown dice - uses direct DOM manipulation for performance
useEffect(() => {
if (!isFlying) return
const GRAVITY_STRENGTH = 1.2 // Pull toward origin (spring-like)
const BASE_FRICTION = 0.92 // Base velocity dampening
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 = 30 // Distance at which extra damping kicks in
const animate = () => {
const p = dicePhysics.current
const el = portalDiceRef.current
if (!el) {
animationFrameRef.current = requestAnimationFrame(animate)
return
}
// Calculate distance to origin
const dist = Math.sqrt(p.x * p.x + p.y * p.y)
// Apply spring force toward origin (proportional to distance)
if (dist > 0) {
const springForce = GRAVITY_STRENGTH
p.vx += (-p.x / dist) * springForce * (dist / 50) // Stronger when further
p.vy += (-p.y / dist) * springForce * (dist / 50)
}
// Apply friction - extra damping when close to prevent oscillation
const friction = dist < CLOSE_RANGE ? 0.85 : BASE_FRICTION
p.vx *= friction
p.vy *= friction
// Update position
p.x += p.vx
p.y += p.vy
// Update rotation based on velocity (dice rolls as it moves)
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
p.rotationX += p.vy * ROTATION_FACTOR * 10
p.rotationY -= p.vx * ROTATION_FACTOR * 10
p.rotationZ += speed * ROTATION_FACTOR * 2
// Update DOM directly - no React re-renders
el.style.transform = `translate(${p.x}px, ${p.y}px)`
// Update dice rotation via CSS custom properties (the DiceIcon reads these)
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 and slow
const totalVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
if (dist < STOP_THRESHOLD && totalVelocity < VELOCITY_THRESHOLD) {
// Dice has returned home
setIsFlying(false)
dicePhysics.current = { x: 0, y: 0, vx: 0, vy: 0, rotationX: 0, rotationY: 0, rotationZ: 0 }
return
}
animationFrameRef.current = requestAnimationFrame(animate)
}
animationFrameRef.current = requestAnimationFrame(animate)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [isFlying])
// Compute target rotation: add dramatic spins, then land on the face rotation
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || { rotateX: 0, rotateY: 0 }
const diceRotation = {
@@ -256,6 +356,105 @@ export function PreviewCenter({
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() }
dicePhysics.current = { x: 0, y: 0, vx: 0, vy: 0, rotationX: 0, rotationY: 0, rotationZ: 0 }
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
// Update DOM directly to avoid React re-renders during drag
if (portalDiceRef.current) {
portalDiceRef.current.style.transform = `translate(${dx}px, ${dy}px)`
}
// Store position in ref for use when releasing
dicePhysics.current.x = dx
dicePhysics.current.y = dy
// Track velocity for throw calculation
lastPointerPos.current = { x: e.clientX, y: e.clientY, time: performance.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 recent movement
const now = performance.now()
const dt = Math.max(now - lastPointerPos.current.time, 16) // At least 1 frame
const vx = ((e.clientX - lastPointerPos.current.x) / dt) * 16 // Normalize to ~60fps
const vy = ((e.clientY - lastPointerPos.current.y) / dt) * 16
// 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)
// If dragged more than 20px, trigger throw physics
if (distance > 20) {
// Initialize physics with current position and throw velocity
dicePhysics.current = {
x: posX,
y: posY,
vx: vx * 1.5, // Amplify throw velocity
vy: vy * 1.5,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
}
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 }
}
setIsDragging(false)
},
[isDragging, handleShuffle]
)
// Detect scrolling in the scroll container
useEffect(() => {
const container = scrollContainerRef.current
@@ -406,26 +605,34 @@ export function PreviewCenter({
</button>
{/* 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}
disabled={isGenerating}
title="Shuffle problems (generate new set)"
title="Shuffle problems (drag or click to roll)"
className={css({
px: '3',
py: '2.5',
bg: 'brand.600',
color: 'white',
cursor: isGenerating ? 'not-allowed' : 'pointer',
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: 'all 0.2s',
transition: 'background 0.2s',
touchAction: 'none', // Prevent scroll on touch devices
userSelect: 'none',
_hover: isGenerating
? {}
: {
@@ -433,15 +640,53 @@ export function PreviewCenter({
},
})}
>
<DiceIcon
rotateX={diceRotation.rotateX}
rotateY={diceRotation.rotateY}
rotateZ={diceRotation.rotateZ}
isDark={isDark}
/>
{/* 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

@@ -8,25 +8,82 @@
import QRCode from 'qrcode'
/**
* Generate a QR code as an SVG string
* Generate a ± (plus-minus) operator icon SVG for the QR code center
* This matches the OperatorIcon React component's visual style
*
* @param size - Size of the icon in pixels
* @returns SVG string of the ± symbol
*/
function generateOperatorIcon(size: number): string {
const s = size
// Use a bold, clean ± symbol centered in the icon
// The symbol is drawn with thick strokes for good visibility
const strokeWidth = s * 0.12
const centerX = s / 2
const centerY = s / 2
// Position the + part higher and the - part lower
const plusCenterY = centerY - s * 0.15
const minusCenterY = centerY + s * 0.25
const barLength = s * 0.5
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}">
<rect x="0" y="0" width="${s}" height="${s}" fill="white"/>
<!-- Plus sign (horizontal bar) -->
<line x1="${centerX - barLength / 2}" y1="${plusCenterY}" x2="${centerX + barLength / 2}" y2="${plusCenterY}" stroke="#4F46E5" stroke-width="${strokeWidth}" stroke-linecap="round"/>
<!-- Plus sign (vertical bar) -->
<line x1="${centerX}" y1="${plusCenterY - barLength / 2}" x2="${centerX}" y2="${plusCenterY + barLength / 2}" stroke="#4F46E5" stroke-width="${strokeWidth}" stroke-linecap="round"/>
<!-- Minus sign -->
<line x1="${centerX - barLength / 2}" y1="${minusCenterY}" x2="${centerX + barLength / 2}" y2="${minusCenterY}" stroke="#4F46E5" stroke-width="${strokeWidth}" stroke-linecap="round"/>
</svg>`
}
/**
* Generate a QR code as an SVG string with an optional center logo
*
* @param url - The URL to encode in the QR code
* @param size - The size of the QR code in pixels (default 100)
* @param withLogo - Whether to add an abacus logo in the center (default true)
* @returns SVG string representing the QR code
*/
export async function generateQRCodeSVG(url: string, size = 100): Promise<string> {
export async function generateQRCodeSVG(url: string, size = 100, withLogo = true): Promise<string> {
// Use high error correction when adding a logo (can recover ~30% of data)
const errorCorrectionLevel = withLogo ? 'H' : 'M'
const svg = await QRCode.toString(url, {
type: 'svg',
width: size,
margin: 1,
errorCorrectionLevel: 'M', // Medium error correction - good balance
errorCorrectionLevel,
color: {
dark: '#000000',
light: '#ffffff',
},
})
return svg
if (!withLogo) {
return svg
}
// Add ± operator logo to the center of the QR code
// Logo should cover ~15-20% of the QR code area for best scannability
const logoSize = Math.round(size * 0.22)
const logoOffset = Math.round((size - logoSize) / 2)
// Parse the SVG and insert the logo
// The QR code SVG has a white background, so we add the logo on top
const logoSvg = generateOperatorIcon(logoSize)
// Extract just the content of the logo SVG (without the outer svg tags)
const logoContent = logoSvg.replace(/<svg[^>]*>/, '').replace(/<\/svg>/, '')
// Insert logo into QR code SVG (before closing </svg> tag)
const svgWithLogo = svg.replace(
'</svg>',
`<g transform="translate(${logoOffset}, ${logoOffset})">${logoContent}</g></svg>`
)
return svgWithLogo
}
/**

View File

@@ -11,6 +11,100 @@ import {
generateTypstHelpers,
} from './typstHelpers'
/**
* Generate a human-readable description of the worksheet settings
* Format matches the difficulty preset dropdown collapsed view:
* - Line 1: Digit range + operator + regrouping percentage
* - Line 2: Scaffolding summary (Always: X, Y / When needed: Z)
*
* @param config - The worksheet configuration
* @returns Object with title (for prominent display) and scaffolding (for detail line)
*/
function generateWorksheetDescription(config: WorksheetConfig): {
title: string
scaffolding: string
} {
// Line 1: Digit range + operator + regrouping percentage
const parts: string[] = []
// Digit range (e.g., "2-digit" or "13 digit")
const minDigits = config.digitRange?.min ?? 1
const maxDigits = config.digitRange?.max ?? 2
if (minDigits === maxDigits) {
parts.push(`${minDigits}-digit`)
} else {
parts.push(`${minDigits}${maxDigits} digit`)
}
// Operator
if (config.operator === 'addition') {
parts.push('addition')
} else if (config.operator === 'subtraction') {
parts.push('subtraction')
} else {
parts.push('mixed operations')
}
// Regrouping percentage (pAnyStart)
const pAnyStart = (config as any).pAnyStart ?? 0.25
const regroupingPercent = Math.round(pAnyStart * 100)
parts.push(`${regroupingPercent}% regrouping`)
const title = parts.join(' ')
// Line 2: Scaffolding summary (matches getScaffoldingSummary format)
const alwaysItems: string[] = []
const conditionalItems: string[] = []
if (config.displayRules) {
const rules = config.displayRules
const operator = config.operator
// Addition-specific scaffolds (skip for subtraction-only)
if (operator !== 'subtraction') {
if (rules.carryBoxes === 'always') alwaysItems.push('carry boxes')
else if (rules.carryBoxes && rules.carryBoxes !== 'never')
conditionalItems.push('carry boxes')
if (rules.tenFrames === 'always') alwaysItems.push('ten-frames')
else if (rules.tenFrames && rules.tenFrames !== 'never') conditionalItems.push('ten-frames')
}
// Universal scaffolds
if (rules.answerBoxes === 'always') alwaysItems.push('answer boxes')
else if (rules.answerBoxes && rules.answerBoxes !== 'never')
conditionalItems.push('answer boxes')
if (rules.placeValueColors === 'always') alwaysItems.push('place value colors')
else if (rules.placeValueColors && rules.placeValueColors !== 'never')
conditionalItems.push('place value colors')
// Subtraction-specific scaffolds (skip for addition-only)
if (operator !== 'addition') {
if (rules.borrowNotation === 'always') alwaysItems.push('borrow notation')
else if (rules.borrowNotation && rules.borrowNotation !== 'never')
conditionalItems.push('borrow notation')
if (rules.borrowingHints === 'always') alwaysItems.push('borrowing hints')
else if (rules.borrowingHints && rules.borrowingHints !== 'never')
conditionalItems.push('borrowing hints')
}
}
// Build scaffolding summary string
const scaffoldingParts: string[] = []
if (alwaysItems.length > 0) {
scaffoldingParts.push(`Always: ${alwaysItems.join(', ')}`)
}
if (conditionalItems.length > 0) {
scaffoldingParts.push(`When needed: ${conditionalItems.join(', ')}`)
}
const scaffolding = scaffoldingParts.length > 0 ? scaffoldingParts.join(' • ') : 'No scaffolding'
return { title, scaffolding }
}
/**
* Chunk array into pages of specified size
*/
@@ -49,6 +143,7 @@ function calculateMaxDigits(problems: WorksheetProblem[]): number {
* Generate Typst source code for a single page
* @param qrCodeSvg - Optional raw SVG string for QR code to embed
* @param shareCode - Optional share code to display under QR code (e.g., "k7mP2qR")
* @param domain - Optional domain name for branding (e.g., "abaci.one")
*/
function generatePageTypst(
config: WorksheetConfig,
@@ -56,7 +151,8 @@ function generatePageTypst(
problemOffset: number,
rowsPerPage: number,
qrCodeSvg?: string,
shareCode?: string
shareCode?: string,
domain?: string
): string {
// Calculate maximum digits for proper column layout
const maxDigits = calculateMaxDigits(pageProblems)
@@ -205,11 +301,17 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
let grid-stroke = if problem.showCellBorder { (thickness: 1pt, dash: "dashed", paint: gray.darken(20%)) } else { none }
box(
inset: 0pt,
inset: (top: 0pt, bottom: -${(cellSize / 3).toFixed(3)}in, left: 0pt, right: 0pt),
width: ${problemBoxWidth}in,
height: ${problemBoxHeight}in,
stroke: grid-stroke
)[
// Problem number in top-left corner of the cell
#if problem.showProblemNumbers and index != none {
place(top + left, dx: 0.02in, dy: 0.02in)[
#text(size: ${(cellSize * 72 * 0.4).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index + 1).]
]
}
#align(center + horizon)[
#if problem.operator == "+" {
problem-stack(
@@ -218,7 +320,7 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
problem.showAnswerBoxes,
problem.showPlaceValueColors,
problem.showTenFrames,
problem.showProblemNumbers
false // Don't show problem numbers here - shown at cell level
)
} else {
subtraction-problem-stack(
@@ -227,7 +329,7 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
problem.showAnswerBoxes,
problem.showPlaceValueColors,
problem.showTenFrames,
problem.showProblemNumbers,
false, // Don't show problem numbers here - shown at cell level
problem.showBorrowNotation, // show-borrow-notation (scratch work boxes in minuend)
problem.showBorrowingHints // show-borrowing-hints (hints with arrows)
)
@@ -240,20 +342,84 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
${problemsTypst}
)
// Compact header - name on left, date and QR code with share code on right
#grid(
columns: (1fr, auto, auto),
column-gutter: 0.15in,
align: (left, right, right),
text(size: 0.75em, weight: "bold")[${config.name}],
text(size: 0.65em)[${config.date}],
${
qrCodeSvg
? `stack(dir: ttb, spacing: 2pt, align(center)[#image.decode("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}", width: 0.35in, height: 0.35in)], align(center)[#text(size: 6pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])`
: `[]`
}
)
#v(${headerHeight}in - 0.25in)
// Letterhead with worksheet description, student info, and QR code
${(() => {
const description = generateWorksheetDescription(config)
// Check if user specified a real name (not the default 'Student' placeholder)
// Validation defaults empty names to 'Student', so we treat that as "no name specified"
const hasName = config.name && config.name.trim().length > 0 && config.name.trim() !== 'Student'
const brandDomain = domain || 'abaci.one'
const breadcrumb = 'Create Worksheets'
// When name is empty, description gets more prominence (larger font)
const titleSize = hasName ? '0.7em' : '0.85em'
const scaffoldSize = hasName ? '0.5em' : '0.6em'
return `#box(
width: 100%,
stroke: (bottom: 1pt + gray),
inset: (bottom: 2pt),
)[
#grid(
columns: (1fr, auto),
column-gutter: 0.1in,
align: (left + top, right + top),
// Left side: Description, Name (if specified)
[
// Title line: digit range, operator, regrouping %
#text(size: ${titleSize}, weight: "bold")[${description.title}] \\
// Scaffolding summary
#text(size: ${scaffoldSize}, fill: gray.darken(20%))[${description.scaffolding}]
${
hasName
? ` \\
// Name row (date moved to right side)
#grid(
columns: (auto, 1fr),
column-gutter: 4pt,
align: (left + horizon, left + horizon),
text(size: 0.65em)[*Name:*],
box(stroke: (bottom: 0.5pt + black))[#h(0.25em)${config.name}#h(1fr)]
)`
: ''
}
],
// Right side: Date + QR code, share code, domain, breadcrumb
[
${
qrCodeSvg
? `#align(right)[
#stack(dir: ttb, spacing: 0pt, align(right)[
// Date and QR code on same row, top-aligned
#grid(
columns: (auto, auto),
column-gutter: 0.1in,
align: (right + top, right + top),
text(size: 0.6em)[*Date:* ${config.date}],
image(bytes("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}"), format: "svg", width: 0.5in, height: 0.5in)
)
], align(right)[
// Share code and domain/breadcrumb tightly packed
#set par(leading: 0pt)
#text(size: 5pt, font: "Courier New", fill: gray.darken(20%))[${shareCode || 'PREVIEW'}] \\
#text(size: 0.4em, fill: gray.darken(10%), weight: "medium")[${brandDomain}] #text(size: 0.35em, fill: gray)[${breadcrumb}]
])
]`
: `#align(right)[
#stack(dir: ttb, spacing: 1pt, align(right)[
#text(size: 0.6em)[*Date:* ${config.date}]
], align(right)[
#text(size: 0.5em, fill: gray.darken(10%), weight: "medium")[${brandDomain}]
], align(right)[
#text(size: 0.4em, fill: gray)[${breadcrumb}]
])
]`
}
]
)
]`
})()}
#v(-0.25in)
// Problem grid - exactly ${actualRows} rows × ${config.cols} columns
#grid(
@@ -398,7 +564,7 @@ ${
qrCodeSvg
? `// QR code linking to shared worksheet with share code below
#place(bottom + left, dx: 0.1in, dy: -0.1in)[
#stack(dir: ttb, spacing: 2pt, align(center)[#image.decode("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}", width: 0.5in, height: 0.5in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])
#stack(dir: ttb, spacing: 2pt, align(center)[#image(bytes("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}"), format: "svg", width: 0.63in, height: 0.63in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])
]`
: ''
}
@@ -422,11 +588,13 @@ function extractShareCode(shareUrl?: string): string | undefined {
/**
* Generate Typst source code for the worksheet (returns array of page sources)
* @param shareUrl - Optional share URL for QR code embedding (required if config.includeQRCode is true)
* @param domain - Optional domain name for branding (defaults to extracting from shareUrl or "abaci.one")
*/
export async function generateTypstSource(
config: WorksheetConfig,
problems: WorksheetProblem[],
shareUrl?: string
shareUrl?: string,
domain?: string
): Promise<string[]> {
// Use the problemsPerPage directly from config (primary state)
const problemsPerPage = config.problemsPerPage
@@ -440,6 +608,17 @@ export async function generateTypstSource(
shareCode = extractShareCode(shareUrl)
}
// Extract domain from shareUrl if not provided explicitly
let brandDomain = domain
if (!brandDomain && shareUrl) {
try {
const url = new URL(shareUrl)
brandDomain = url.hostname
} catch {
// Invalid URL, use default
}
}
// Chunk problems into discrete pages
const pages = chunkProblems(problems, problemsPerPage)
@@ -451,7 +630,8 @@ export async function generateTypstSource(
pageIndex * problemsPerPage,
rowsPerPage,
qrCodeSvg,
shareCode
shareCode,
brandDomain
)
)

View File

@@ -90,23 +90,11 @@ export function generateProblemStackFunction(cellSize: number, maxDigits: number
column-list.push(${cellSizeIn})
}
// Show problem number (only if problem numbers are enabled)
let problem-number-display = if show-numbers and index-or-none != none {
align(top + left)[
#box(inset: (left: 0.08in, top: 0.05in))[
#text(size: ${(cellSizePt * 0.6).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index-or-none + 1).]
]
]
}
stack(
dir: ttb,
spacing: 0pt,
problem-number-display,
// Wrap grid in a box to enable place() overlay for operator
box[
#grid(
columns: column-list,
// Wrap grid in a box to enable place() overlay for operator
// Note: Problem numbers are now rendered at the problem-box level, not here
box[
#grid(
columns: column-list,
gutter: 0pt,
// Carry boxes row (one per place value, right to left)
@@ -256,7 +244,6 @@ export function generateProblemStackFunction(cellSize: number, maxDigits: number
]
)
]
)
}
`
}

View File

@@ -103,22 +103,10 @@ export function generateSubtractionProblemStackFunction(
column-list.push(${cellSizeIn})
}
// Show problem number (only if problem numbers are enabled)
let problem-number-display = if show-numbers and index-or-none != none {
align(top + left)[
#box(inset: (left: 0.08in, top: 0.05in))[
#text(size: ${(cellSizePt * 0.6).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index-or-none + 1).]
]
]
}
stack(
dir: ttb,
spacing: 0pt,
problem-number-display,
// Wrap grid in a box to enable place() overlay for operator
box[
#grid(
// Wrap grid in a box to enable place() overlay for operator
// Note: Problem numbers are now rendered at the problem-box level, not here
box[
#grid(
columns: column-list,
gutter: 0pt,
@@ -136,7 +124,6 @@ ${generateAnswerBoxesRow(cellDimensions)}
)
${generateOperatorOverlay(cellDimensions)}
]
)
}
`
}