From b8e66dfc17c359066b9e1bde656d2edc32420294 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Fri, 5 Dec 2025 16:24:58 -0600 Subject: [PATCH] feat(worksheets): add draggable dice easter egg with physics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/.claude/settings.local.json | 4 +- .../worksheets/.claude/ANSWER_KEY_PLAN.md | 79 ++++++ .../worksheets/components/PreviewCenter.tsx | 263 +++++++++++++++++- .../app/create/worksheets/qrCodeGenerator.ts | 65 ++++- .../app/create/worksheets/typstGenerator.ts | 222 +++++++++++++-- .../src/app/create/worksheets/typstHelpers.ts | 23 +- .../typstHelpers/subtraction/problemStack.ts | 21 +- 7 files changed, 607 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/app/create/worksheets/.claude/ANSWER_KEY_PLAN.md diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 4d1a7419..ea373b9e 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -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": [] diff --git a/apps/web/src/app/create/worksheets/.claude/ANSWER_KEY_PLAN.md b/apps/web/src/app/create/worksheets/.claude/ANSWER_KEY_PLAN.md new file mode 100644 index 00000000..70695324 --- /dev/null +++ b/apps/web/src/app/create/worksheets/.claude/ANSWER_KEY_PLAN.md @@ -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 diff --git a/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx b/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx index de0e0fc7..e1d3e16e 100644 --- a/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx +++ b/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx @@ -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({ }} > ((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(null) + const diceContainerRef = useRef(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() + + // Ref to the portal dice element for direct DOM manipulation during drag/flying (avoids re-renders) + const portalDiceRef = useRef(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({ {/* Shuffle Button - only in edit mode (1/3 split secondary action) */} + {/* Easter egg: drag the dice off and release to roll it back! */} {!isReadOnly && ( )} + {/* 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( +
+ {/* During flying, rotation is updated directly on data-dice-cube element */} + +
, + document.body + )} + {/* Dropdown Trigger */} diff --git a/apps/web/src/app/create/worksheets/qrCodeGenerator.ts b/apps/web/src/app/create/worksheets/qrCodeGenerator.ts index 0a36cf6e..848c014f 100644 --- a/apps/web/src/app/create/worksheets/qrCodeGenerator.ts +++ b/apps/web/src/app/create/worksheets/qrCodeGenerator.ts @@ -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 ` + + + + + + + + ` +} + +/** + * 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 { +export async function generateQRCodeSVG(url: string, size = 100, withLogo = true): Promise { + // 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(/]*>/, '').replace(/<\/svg>/, '') + + // Insert logo into QR code SVG (before closing tag) + const svgWithLogo = svg.replace( + '', + `${logoContent}` + ) + + return svgWithLogo } /** diff --git a/apps/web/src/app/create/worksheets/typstGenerator.ts b/apps/web/src/app/create/worksheets/typstGenerator.ts index 72872659..2de3688c 100644 --- a/apps/web/src/app/create/worksheets/typstGenerator.ts +++ b/apps/web/src/app/create/worksheets/typstGenerator.ts @@ -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 "1–3 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 { // 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 ) ) diff --git a/apps/web/src/app/create/worksheets/typstHelpers.ts b/apps/web/src/app/create/worksheets/typstHelpers.ts index b4d9a70d..fc053dd3 100644 --- a/apps/web/src/app/create/worksheets/typstHelpers.ts +++ b/apps/web/src/app/create/worksheets/typstHelpers.ts @@ -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 ] ) ] - ) } ` } diff --git a/apps/web/src/app/create/worksheets/typstHelpers/subtraction/problemStack.ts b/apps/web/src/app/create/worksheets/typstHelpers/subtraction/problemStack.ts index a52cf6ea..6201e6ca 100644 --- a/apps/web/src/app/create/worksheets/typstHelpers/subtraction/problemStack.ts +++ b/apps/web/src/app/create/worksheets/typstHelpers/subtraction/problemStack.ts @@ -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)} ] - ) } ` }