feat(worksheets): enhance addition worksheets with ten-frames and refinements

- Add ten-frames visualization for regrouping concepts
  - Stacked vertical layout (top frame = carry, bottom frame = current place)
  - Square cells with differentiated borders (0.8pt outer, 0.4pt internal at 30% opacity)
  - Place value color coding matches answer boxes
  - Ten-frames sized at 90% of cell width for proper alignment
  - 2.5pt gap between tens and ones ten-frames for visual clarity

- Add "for all place values" option for ten-frames
  - Show ten-frames only for regrouping (default) or for all problems
  - Dynamic label updates based on checkbox state
  - Indented sub-option UI in config panel

- Improve typography with "New Computer Modern Math" font

- Fix layout calculations
  - Remove gutter-based spacing for consistent problem sizing
  - Dynamic cell size based on ten-frames option
  - Proper height allocation for ten-frames row

- Add cache busting for ten-frames settings in preview

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-06 06:04:25 -06:00
parent 2a98946e25
commit 71ad300c23
8 changed files with 932 additions and 556 deletions

View File

@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
if (!validation.isValid || !validation.config) {
return NextResponse.json(
{ error: 'Invalid configuration', errors: validation.errors },
{ status: 400 },
{ status: 400 }
)
}
@@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
config.pAnyStart,
config.pAllStart,
config.interpolate,
config.seed,
config.seed
)
// Generate Typst sources (one per page)
@@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
error: `Failed to compile preview (page ${i + 1})`,
details: stderr,
},
{ status: 500 },
{ status: 500 }
)
}
}
@@ -80,7 +80,7 @@ export async function POST(request: NextRequest) {
error: 'Failed to generate preview',
message: errorMessage,
},
{ status: 500 },
{ status: 500 }
)
}
}

View File

@@ -61,7 +61,7 @@ export async function POST(request: NextRequest) {
typstSource: typstSource.split('\n').slice(0, 20).join('\n') + '\n...',
}),
},
{ status: 500 },
{ status: 500 }
)
}

View File

@@ -53,16 +53,26 @@ function PreviewContent({ formState }: WorksheetPreviewProps) {
const { data: pages } = useSuspenseQuery({
queryKey: [
'worksheet-preview',
formState.total,
// PRIMARY state
formState.problemsPerPage,
formState.cols,
formState.rows,
formState.pages,
formState.orientation,
// Other settings that affect appearance
formState.name,
formState.pAnyStart,
formState.pAllStart,
formState.interpolate,
formState.showCarryBoxes,
formState.showAnswerBoxes,
formState.showPlaceValueColors,
formState.showProblemNumbers,
formState.showCellBorder,
// Note: seed, fontSize, and date intentionally excluded
formState.showTenFrames,
formState.showTenFramesForAll,
formState.seed, // Include seed to bust cache when problem set regenerates
// Note: fontSize, date, rows, total intentionally excluded
// (rows and total are derived from primary state)
],
queryFn: () => fetchWorksheetPreview(formState),
})

View File

@@ -30,20 +30,32 @@ export default function AdditionWorksheetPage() {
const [error, setError] = useState<string | null>(null)
// Immediate form state (for controls - updates instantly)
// PRIMARY state: problemsPerPage, cols, pages (what user controls)
// DERIVED state: rows, total (calculated from primary)
const [formState, setFormState] = useState<WorksheetFormState>({
total: 20,
// Primary state
problemsPerPage: 20,
cols: 5,
rows: 4,
pages: 1,
orientation: 'landscape',
// Derived state
rows: 4, // (20 / 5) * 1 = 4
total: 20, // 20 * 1 = 20
// Other settings
name: '',
date: '', // Will be set at generation time
pAnyStart: 0.75,
pAllStart: 0.25,
interpolate: true,
showCarryBoxes: true,
showAnswerBoxes: true,
showPlaceValueColors: true,
showProblemNumbers: true,
showCellBorder: true,
showTenFrames: false,
showTenFramesForAll: false,
fontSize: 16,
seed: Date.now() % 2147483647,
orientation: 'landscape',
})
// Debounced form state (for preview - updates after delay)
@@ -64,9 +76,9 @@ export default function AdditionWorksheetPage() {
// Generate new seed when problem settings change
const affectsProblems =
updates.total !== undefined ||
updates.problemsPerPage !== undefined ||
updates.cols !== undefined ||
updates.rows !== undefined ||
updates.pages !== undefined ||
updates.orientation !== undefined ||
updates.pAnyStart !== undefined ||
updates.pAllStart !== undefined ||

View File

@@ -5,10 +5,14 @@
* All fields have concrete values (no undefined/null)
*/
export interface WorksheetConfig {
// Problem set
total: number
cols: number
rows: number
// Problem set - PRIMARY state
problemsPerPage: number // Number of problems per page (6, 8, 10, 12, 15, 16, 20)
cols: number // Column count
pages: number // Number of pages
// Problem set - DERIVED state
total: number // total = problemsPerPage * pages
rows: number // rows = (problemsPerPage / cols) * pages
// Personalization
name: string
@@ -33,28 +37,47 @@ export interface WorksheetConfig {
// Display options
showCarryBoxes: boolean
showAnswerBoxes: boolean
showPlaceValueColors: boolean
showProblemNumbers: boolean
showCellBorder: boolean
showTenFrames: boolean // Show empty ten-frames
showTenFramesForAll: boolean // Show ten-frames for all place values (not just regrouping)
fontSize: number
seed: number
}
/**
* Partial form state - user may be editing, fields optional
* PRIMARY state: problemsPerPage, cols, pages (what user controls)
* DERIVED state: rows, total (calculated from primary)
*/
export interface WorksheetFormState {
total?: number
cols?: number
// PRIMARY state (what user selects in UI)
problemsPerPage?: number // 6, 8, 10, 12, 15, 16, 20
cols?: number // 2, 3, 4, 5 - column count for layout
pages?: number // 1, 2, 3, 4
orientation?: 'portrait' | 'landscape'
// DERIVED state (calculated: rows = (problemsPerPage / cols) * pages, total = problemsPerPage * pages)
rows?: number
total?: number
// Other settings
name?: string
date?: string
pAnyStart?: number
pAllStart?: number
interpolate?: boolean
showCarryBoxes?: boolean
showAnswerBoxes?: boolean
showPlaceValueColors?: boolean
showProblemNumbers?: boolean
showCellBorder?: boolean
showTenFrames?: boolean
showTenFramesForAll?: boolean
fontSize?: number
seed?: number
orientation?: 'portrait' | 'landscape'
}
/**

View File

@@ -20,7 +20,7 @@ function generatePageTypst(
config: WorksheetConfig,
pageProblems: AdditionProblem[],
problemOffset: number,
rowsPerPage: number,
rowsPerPage: number
): string {
const problemsTypst = pageProblems.map((p) => ` (a: ${p.a}, b: ${p.b}),`).join('\n')
@@ -35,20 +35,14 @@ function generatePageTypst(
// Calculate grid spacing based on ACTUAL rows on this page
const headerHeight = 0.35 // inches for header
const availableHeight = contentHeight - headerHeight
const gutterSize = 0.15 // inches between items
const gutterHeightTotal = gutterSize * (actualRows - 1)
const problemBoxHeight = (availableHeight - gutterHeightTotal) / actualRows
const problemBoxHeight = availableHeight / actualRows
const problemBoxWidth = contentWidth / config.cols
const gutterWidthTotal = gutterSize * (config.cols - 1)
const problemBoxWidth = (contentWidth - gutterWidthTotal) / config.cols
// Calculate cell size to fit within problem box
// Problem has 5 rows: carry boxes, first number, second number, line, answer boxes
// Reserve space for problem number and insets
const problemNumberHeight = 0.15
const insetTotal = 0.05 * 2
const availableCellHeight = problemBoxHeight - problemNumberHeight - insetTotal
const cellSize = availableCellHeight / 5 // 5 rows in the grid, no max cap
// Calculate cell size to fill the entire problem box
// Without ten-frames: 5 rows (carry, first number, second number, line, answer)
// With ten-frames: 5 rows + ten-frames row (0.8 * cellSize for square cells)
// Total with ten-frames: 5.8 rows, use 6.4 for breathing room
const cellSize = config.showTenFrames ? problemBoxHeight / 6.4 : problemBoxHeight / 5
return String.raw`
// addition-worksheet-page.typ (auto-generated)
@@ -59,14 +53,71 @@ function generatePageTypst(
margin: ${margin}in,
fill: white
)
#set text(size: ${config.fontSize}pt)
#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math")
// Single non-breakable block to ensure one page
#block(breakable: false)[
#let cell-outline = ${config.showCellBorder ? '0.6pt' : 'none'}
#let grid-stroke = ${config.showCellBorder ? '(thickness: 1pt, dash: "dashed", paint: gray.darken(20%))' : 'none'}
#let heavy-stroke = 0.8pt
#let show-carries = ${config.showCarryBoxes ? 'true' : 'false'}
#let show-answers = ${config.showAnswerBoxes ? 'true' : 'false'}
#let show-colors = ${config.showPlaceValueColors ? 'true' : 'false'}
#let show-numbers = ${config.showProblemNumbers ? 'true' : 'false'}
#let show-ten-frames = ${config.showTenFrames ? 'true' : 'false'}
#let show-ten-frames-for-all = ${config.showTenFramesForAll ? 'true' : 'false'}
// Place value colors (light pastels)
#let color-ones = rgb(227, 242, 253) // Light blue
#let color-tens = rgb(232, 245, 233) // Light green
#let color-hundreds = rgb(255, 249, 196) // Light yellow
#let color-none = white // No color
// Ten-frame helper - stacked 2 frames vertically, sized to fit cell width
// top-color: background for top frame (represents carry to next place value)
// bottom-color: background for bottom frame (represents current place value)
#let ten-frame-spacing = 0pt // No gap between frames
#let ten-frame-cell-stroke = 0.4pt // Internal cell strokes - slightly thinner
#let ten-frame-cell-color = rgb(0, 0, 0, 30%) // Light gray for internal lines
#let ten-frame-outer-stroke = 0.8pt // Dark outer border for frame visibility
#let ten-frames-stacked(cell-width, top-color, bottom-color) = {
let cell-w = cell-width / 5
let cell-h = cell-w // Square cells
stack(
dir: ttb,
spacing: ten-frame-spacing,
// Top ten-frame (carry to next place value) - wrapped with outer border
box(
stroke: ten-frame-outer-stroke + black,
inset: 0pt
)[
#grid(
columns: 5,
rows: 2,
gutter: 0pt,
stroke: none,
..for i in range(0, 10) {
(box(width: cell-w, height: cell-h, fill: top-color, stroke: ten-frame-cell-stroke + ten-frame-cell-color)[],)
}
)
],
// Bottom ten-frame (current place value overflow) - wrapped with outer border
box(
stroke: ten-frame-outer-stroke + black,
inset: 0pt
)[
#grid(
columns: 5,
rows: 2,
gutter: 0pt,
stroke: none,
..for i in range(0, 10) {
(box(width: cell-w, height: cell-h, fill: bottom-color, stroke: ten-frame-cell-stroke + ten-frame-cell-color)[],)
}
)
]
)
}
#let problem-box(problem, index) = {
let a = problem.a
@@ -77,47 +128,92 @@ function generatePageTypst(
let bO = calc.rem(b, 10)
box(
stroke: cell-outline,
inset: 0.05in,
inset: 0pt,
width: ${problemBoxWidth}in,
height: ${problemBoxHeight}in
)[
#align(center + horizon)[
#block[
#align(top + left)[
#text(size: 0.5em, weight: "bold")[\##(index + 1).]
]
#grid(
#stack(
dir: ttb,
spacing: 0pt,
if show-numbers {
align(top + left)[
#box(inset: (left: 0.08in, top: 0.05in))[
#text(size: ${(cellSize * 0.6 * 72).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index + 1).]
]
]
},
grid(
columns: (0.5em, ${cellSize}in, ${cellSize}in, ${cellSize}in),
gutter: 0pt,
[],
if show-carries { box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt)[] } else { v(${cellSize}in) },
if show-carries { box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt)[] } else { v(${cellSize}in) },
if show-carries { box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt, fill: if show-colors { color-tens } else { color-none })[] } else { v(${cellSize}in) },
if show-carries { box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt, fill: if show-colors { color-ones } else { color-none })[] } else { v(${cellSize}in) },
[],
[],
[],
box(width: ${cellSize}in, height: ${cellSize}in)[#align(center + horizon)[#text(size: 1em)[#str(aT)]]],
box(width: ${cellSize}in, height: ${cellSize}in)[#align(center + horizon)[#text(size: 1em)[#str(aO)]]],
box(width: ${cellSize}in, height: ${cellSize}in, fill: if show-colors { color-tens } else { color-none })[#align(center + horizon)[#text(size: ${(cellSize * 0.8 * 72).toFixed(1)}pt)[#str(aT)]]],
box(width: ${cellSize}in, height: ${cellSize}in, fill: if show-colors { color-ones } else { color-none })[#align(center + horizon)[#text(size: ${(cellSize * 0.8 * 72).toFixed(1)}pt)[#str(aO)]]],
box(width: ${cellSize}in, height: ${cellSize}in)[#align(center + horizon)[#text(size: 1em)[+]]],
box(width: ${cellSize}in, height: ${cellSize}in)[#align(center + horizon)[#text(size: ${(cellSize * 0.8 * 72).toFixed(1)}pt)[+]]],
[],
box(width: ${cellSize}in, height: ${cellSize}in)[#align(center + horizon)[#text(size: 1em)[#str(bT)]]],
box(width: ${cellSize}in, height: ${cellSize}in)[#align(center + horizon)[#text(size: 1em)[#str(bO)]]],
box(width: ${cellSize}in, height: ${cellSize}in, fill: if show-colors { color-tens } else { color-none })[#align(center + horizon)[#text(size: ${(cellSize * 0.8 * 72).toFixed(1)}pt)[#str(bT)]]],
box(width: ${cellSize}in, height: ${cellSize}in, fill: if show-colors { color-ones } else { color-none })[#align(center + horizon)[#text(size: ${(cellSize * 0.8 * 72).toFixed(1)}pt)[#str(bO)]]],
// Line row
[],
line(length: ${cellSize}in, stroke: heavy-stroke),
line(length: ${cellSize}in, stroke: heavy-stroke),
line(length: ${cellSize}in, stroke: heavy-stroke),
// Ten-frames row with overlaid line on top
// Height calculation: each frame has 2 rows, cell-h = (cell-width/5) [square cells]
// Total: 4 * cell-h + spacing = 4 * (cell-width/5) = cell-width * 0.8
[],
box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt)[],
box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt)[],
box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt)[],
[], // Empty cell for hundreds column
if show-ten-frames {
let carry = if (aO + bO) >= 10 { 1 } else { 0 }
let tens-regroup = (aT + bT + carry) >= 10
if show-ten-frames-for-all or tens-regroup {
// Top frame (carry to hundreds) = color-hundreds, Bottom frame (tens) = color-tens
// Use place() to overlay the line on top
// Add small right margin to create gap between tens and ones ten-frames
box(width: ${cellSize}in, height: ${cellSize}in * 0.8)[
#align(center + top)[#ten-frames-stacked(${cellSize}in * 0.90, if show-colors { color-hundreds } else { color-none }, if show-colors { color-tens } else { color-none })]
#place(top, line(length: ${cellSize}in * 0.90, stroke: heavy-stroke))
]
h(2.5pt) // Small horizontal gap between tens and ones ten-frames
} else {
v(${cellSize}in * 0.8)
}
} else {
v(${cellSize}in * 0.8)
},
if show-ten-frames {
let ones-regroup = (aO + bO) >= 10
if show-ten-frames-for-all or ones-regroup {
// Top frame (carry to tens) = color-tens, Bottom frame (ones) = color-ones
// Use place() to overlay the line on top
box(width: ${cellSize}in, height: ${cellSize}in * 0.8)[
#align(center + top)[#ten-frames-stacked(${cellSize}in * 0.90, if show-colors { color-tens } else { color-none }, if show-colors { color-ones } else { color-none })]
#place(top, line(length: ${cellSize}in * 0.90, stroke: heavy-stroke))
]
} else {
v(${cellSize}in * 0.8)
}
} else {
v(${cellSize}in * 0.8)
},
// Answer boxes
[],
if show-answers { box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt, fill: if show-colors { color-hundreds } else { color-none })[] } else { v(${cellSize}in) },
if show-answers { box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt, fill: if show-colors { color-tens } else { color-none })[] } else { v(${cellSize}in) },
if show-answers { box(width: ${cellSize}in, height: ${cellSize}in, stroke: 0.5pt, fill: if show-colors { color-ones } else { color-none })[] } else { v(${cellSize}in) },
)
]
)
]
]
}
@@ -138,8 +234,9 @@ ${problemsTypst}
// Problem grid - exactly ${actualRows} rows × ${config.cols} columns
#grid(
columns: ${config.cols},
column-gutter: ${gutterSize}in,
row-gutter: ${gutterSize}in,
column-gutter: 0pt,
row-gutter: 0pt,
stroke: grid-stroke,
..for r in range(0, ${actualRows}) {
for c in range(0, ${config.cols}) {
let idx = r * ${config.cols} + c
@@ -159,17 +256,19 @@ ${problemsTypst}
/**
* Generate Typst source code for the worksheet (returns array of page sources)
*/
export function generateTypstSource(config: WorksheetConfig, problems: AdditionProblem[]): string[] {
// Determine rows per page based on orientation (portrait = tall, landscape = wide)
const isPortrait = config.page.hIn > config.page.wIn
const rowsPerPage = isPortrait ? 5 : 2
const problemsPerPage = config.cols * rowsPerPage
export function generateTypstSource(
config: WorksheetConfig,
problems: AdditionProblem[]
): string[] {
// Use the problemsPerPage directly from config (primary state)
const problemsPerPage = config.problemsPerPage
const rowsPerPage = problemsPerPage / config.cols
// Chunk problems into discrete pages
const pages = chunkProblems(problems, problemsPerPage)
// Generate separate Typst source for each page
return pages.map((pageProblems, pageIndex) =>
generatePageTypst(config, pageProblems, pageIndex * problemsPerPage, rowsPerPage),
generatePageTypst(config, pageProblems, pageIndex * problemsPerPage, rowsPerPage)
)
}

View File

@@ -67,11 +67,20 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
// Determine orientation based on columns (portrait = 2-3 cols, landscape = 4-5 cols)
const orientation = formState.orientation || (cols <= 3 ? 'portrait' : 'landscape')
// Get primary state values
const problemsPerPage = formState.problemsPerPage ?? total
const pages = formState.pages ?? 1
// Build complete config with defaults
const config: WorksheetConfig = {
total,
// Primary state
problemsPerPage,
cols,
pages,
// Derived state
total,
rows,
// Other fields
name: formState.name?.trim() || 'Student',
date: formState.date?.trim() || getDefaultDate(),
pAnyStart,
@@ -88,7 +97,12 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
bottom: 0.7,
},
showCarryBoxes: formState.showCarryBoxes ?? true,
showAnswerBoxes: formState.showAnswerBoxes ?? true,
showPlaceValueColors: formState.showPlaceValueColors ?? true,
showProblemNumbers: formState.showProblemNumbers ?? true,
showCellBorder: formState.showCellBorder ?? true,
showTenFrames: formState.showTenFrames ?? false,
showTenFramesForAll: formState.showTenFramesForAll ?? false,
fontSize,
seed,
}