diff --git a/apps/web/src/app/api/create/worksheets/addition/preview/route.ts b/apps/web/src/app/api/create/worksheets/addition/preview/route.ts index 83c09618..12d8e335 100644 --- a/apps/web/src/app/api/create/worksheets/addition/preview/route.ts +++ b/apps/web/src/app/api/create/worksheets/addition/preview/route.ts @@ -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 } ) } } diff --git a/apps/web/src/app/api/create/worksheets/addition/route.ts b/apps/web/src/app/api/create/worksheets/addition/route.ts index f6f4077f..61fcaf14 100644 --- a/apps/web/src/app/api/create/worksheets/addition/route.ts +++ b/apps/web/src/app/api/create/worksheets/addition/route.ts @@ -61,7 +61,7 @@ export async function POST(request: NextRequest) { typstSource: typstSource.split('\n').slice(0, 20).join('\n') + '\n...', }), }, - { status: 500 }, + { status: 500 } ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/ConfigPanel.tsx b/apps/web/src/app/create/worksheets/addition/components/ConfigPanel.tsx index dfb0deec..940b18f9 100644 --- a/apps/web/src/app/create/worksheets/addition/components/ConfigPanel.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/ConfigPanel.tsx @@ -1,5 +1,6 @@ 'use client' +import * as Slider from '@radix-ui/react-slider' import { useTranslations } from 'next-intl' import { css } from '../../../../../../styled-system/css' import { stack } from '../../../../../../styled-system/patterns' @@ -13,517 +14,734 @@ interface ConfigPanelProps { export function ConfigPanel({ formState, onChange }: ConfigPanelProps) { const t = useTranslations('create.worksheets.addition') + // Helper to get default column count for a given problemsPerPage (user can override) + const getDefaultColsForProblemsPerPage = ( + problemsPerPage: number, + orientation: 'portrait' | 'landscape' + ): number => { + if (orientation === 'portrait') { + // Portrait: prefer 2-3 columns + if (problemsPerPage === 6) return 2 + if (problemsPerPage === 8) return 2 + if (problemsPerPage === 10) return 2 + if (problemsPerPage === 12) return 3 + if (problemsPerPage === 15) return 3 + return 2 // default + } else { + // Landscape: prefer 4-5 columns + if (problemsPerPage === 8) return 4 + if (problemsPerPage === 10) return 5 + if (problemsPerPage === 12) return 4 + if (problemsPerPage === 15) return 5 + if (problemsPerPage === 16) return 4 + if (problemsPerPage === 20) return 5 + return 4 // default + } + } + + // Helper to calculate derived state (rows, total) from primary state (problemsPerPage, cols, pages) + const calculateDerivedState = (problemsPerPage: number, cols: number, pages: number) => { + const rowsPerPage = problemsPerPage / cols + const rows = rowsPerPage * pages + const total = problemsPerPage * pages + return { rows, total } + } + + // Get current primary state with defaults + const currentOrientation = formState.orientation || 'portrait' + const currentProblemsPerPage = + formState.problemsPerPage || (currentOrientation === 'portrait' ? 15 : 20) + const currentCols = + formState.cols || getDefaultColsForProblemsPerPage(currentProblemsPerPage, currentOrientation) + const currentPages = formState.pages || 1 + + console.log('=== ConfigPanel Render ===') + console.log('Primary state:', { + problemsPerPage: currentProblemsPerPage, + cols: currentCols, + pages: currentPages, + orientation: currentOrientation, + }) + console.log( + 'Derived state:', + calculateDerivedState(currentProblemsPerPage, currentCols, currentPages) + ) + return ( -
-
-

- {t('config.title')} -

-

- {t('config.subtitle')} -

-
+
+ {/* Student Name */} + onChange({ name: e.target.value })} + placeholder="Student Name" + className={css({ + w: 'full', + px: '3', + py: '2', + border: '1px solid', + borderColor: 'gray.300', + rounded: 'lg', + fontSize: 'sm', + _focus: { + outline: 'none', + borderColor: 'brand.500', + ring: '2px', + ringColor: 'brand.200', + }, + _placeholder: { color: 'gray.400' }, + })} + /> - {/* Personalization Section */} -
-

- {t('config.personalization.title')} -

+ {/* Worksheet Layout Card */} +
+
+ {/* Orientation - Inline */} +
+
+ Orientation +
+
+ + +
+
-
- - onChange({ name: e.target.value })} - placeholder="Student name" - className={css({ - px: '3', - py: '2', - border: '1px solid', - borderColor: 'gray.300', - rounded: 'lg', - fontSize: 'sm', - _focus: { - outline: 'none', - borderColor: 'brand.500', - ring: '2px', - ringColor: 'brand.200', - }, - })} - /> + {/* Problems per page */} +
+
+ Problems per Page +
+
+ {(currentOrientation === 'portrait' + ? [6, 8, 10, 12, 15] + : [8, 10, 12, 15, 16, 20] + ).map((count) => { + const isSelected = currentProblemsPerPage === count + return ( + + ) + })} +
+
+ + {/* Number of pages */} +
+
+ Pages ({currentProblemsPerPage * currentPages} total problems) +
+
+ {[1, 2, 3, 4].map((pageCount) => { + const isSelected = currentPages === pageCount + return ( + + ) + })} +
+
- {/* Problem Set Section */} -
-

- {t('config.problemSet.title')} -

- -
-
+ + {/* Display Options Card */} +
+
+
+
+ Display Options +
+
+ + +
+
+ + {/* Checkboxes - 2 columns */}
- - - -
-
- -
- -
- {(formState.orientation || 'portrait') === 'portrait' - ? // Portrait options (2-3 columns) - // Portrait can fit ~5 rows per page - [ - { cols: 2, rows: 3 }, - { cols: 2, rows: 4 }, - { cols: 2, rows: 5 }, - { cols: 3, rows: 4 }, - { cols: 3, rows: 5 }, - { cols: 3, rows: 10 }, - ].map(({ cols, rows }) => { - const maxRowsPerPage = 5 - const pages = Math.ceil(rows / maxRowsPerPage) - const total = cols * rows - const isSelected = formState.cols === cols && formState.rows === rows - return ( - - ) - }) - : // Landscape options (4-5 columns) - // Landscape can fit ~2 rows per page - [ - { cols: 4, rows: 3 }, - { cols: 5, rows: 3 }, - { cols: 4, rows: 4 }, - { cols: 5, rows: 4 }, - { cols: 4, rows: 5 }, - { cols: 5, rows: 5 }, - { cols: 5, rows: 6 }, - ].map(({ cols, rows }) => { - const maxRowsPerPage = 2 - const pages = Math.ceil(rows / maxRowsPerPage) - const total = cols * rows - const isSelected = formState.cols === cols && formState.rows === rows - return ( - - ) +
+ onChange({ showCarryBoxes: e.target.checked })} + className={css({ w: '3.5', h: '3.5', cursor: 'pointer', flexShrink: 0 })} + /> + +
+ +
+ onChange({ showAnswerBoxes: e.target.checked })} + className={css({ w: '3.5', h: '3.5', cursor: 'pointer', flexShrink: 0 })} + /> + +
+ +
+ onChange({ showPlaceValueColors: e.target.checked })} + className={css({ w: '3.5', h: '3.5', cursor: 'pointer', flexShrink: 0 })} + /> + +
+ +
+ onChange({ showProblemNumbers: e.target.checked })} + className={css({ w: '3.5', h: '3.5', cursor: 'pointer', flexShrink: 0 })} + /> + +
+ +
+ onChange({ showCellBorder: e.target.checked })} + className={css({ w: '3.5', h: '3.5', cursor: 'pointer', flexShrink: 0 })} + /> + +
+ +
+
+ onChange({ showTenFrames: e.target.checked })} + className={css({ w: '3.5', h: '3.5', cursor: 'pointer', flexShrink: 0 })} + /> + +
+ + {/* Sub-option: Show for all place values */} + {formState.showTenFrames && ( +
+ onChange({ showTenFramesForAll: e.target.checked })} + className={css({ w: '3', h: '3', cursor: 'pointer', flexShrink: 0 })} + /> + +
+ )} +
- - {/* Difficulty Section */} -
-

- {t('config.difficulty.title')} -

- -
- - onChange({ pAnyStart: Number(e.target.value) })} - className={css({ w: 'full' })} - /> -
- % requiring any regrouping (ones or both) at sheet start -
-
- -
- - onChange({ pAllStart: Number(e.target.value) })} - className={css({ w: 'full' })} - /> -
- % requiring regrouping in both ones and tens at start -
-
- -
- onChange({ interpolate: e.target.checked })} - className={css({ - w: '4', - h: '4', - cursor: 'pointer', - })} - /> - -
-
- Start easy, progressively get harder toward target percentages -
-
- - {/* Display Options Section */} -
-

- {t('config.display.title')} -

- -
- onChange({ showCarryBoxes: e.target.checked })} - className={css({ - w: '4', - h: '4', - cursor: 'pointer', - })} - /> - -
- -
- onChange({ showCellBorder: e.target.checked })} - className={css({ - w: '4', - h: '4', - cursor: 'pointer', - })} - /> - -
-
) } diff --git a/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx b/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx index 6b59d02c..270a5fb3 100644 --- a/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx @@ -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), }) diff --git a/apps/web/src/app/create/worksheets/addition/page.tsx b/apps/web/src/app/create/worksheets/addition/page.tsx index 152d984e..f56e6b99 100644 --- a/apps/web/src/app/create/worksheets/addition/page.tsx +++ b/apps/web/src/app/create/worksheets/addition/page.tsx @@ -30,20 +30,32 @@ export default function AdditionWorksheetPage() { const [error, setError] = useState(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({ - 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 || diff --git a/apps/web/src/app/create/worksheets/addition/types.ts b/apps/web/src/app/create/worksheets/addition/types.ts index 85ac5ef2..670cdf02 100644 --- a/apps/web/src/app/create/worksheets/addition/types.ts +++ b/apps/web/src/app/create/worksheets/addition/types.ts @@ -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' } /** diff --git a/apps/web/src/app/create/worksheets/addition/typstGenerator.ts b/apps/web/src/app/create/worksheets/addition/typstGenerator.ts index 50345f0e..9329a9e1 100644 --- a/apps/web/src/app/create/worksheets/addition/typstGenerator.ts +++ b/apps/web/src/app/create/worksheets/addition/typstGenerator.ts @@ -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) ) } diff --git a/apps/web/src/app/create/worksheets/addition/validation.ts b/apps/web/src/app/create/worksheets/addition/validation.ts index c27d83be..d44f1e7c 100644 --- a/apps/web/src/app/create/worksheets/addition/validation.ts +++ b/apps/web/src/app/create/worksheets/addition/validation.ts @@ -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, }