refactor: begin modularizing typstHelpers.ts - extract shared components

Phase 1 of typstHelpers.ts refactoring (793 lines → modular structure)

Created new directory structure:
- typstHelpers/shared/ - Colors, helpers, types
- typstHelpers/addition/ - Addition-specific components (future)
- typstHelpers/subtraction/ - Subtraction-specific components

Extracted components:
- shared/colors.ts - Place value color definitions
- shared/types.ts - TypeScript types and constants
- shared/helpers.ts - Reusable Typst helpers (ten-frames, diagonal-split-box)
- subtraction/borrowBoxes.ts - Borrow boxes row rendering with hints/arrows

Benefits:
- Smaller, focused files (50-100 lines each vs 350+ line functions)
- Centralized constants (arrow positioning, stroke widths)
- Better separation of concerns
- Easier to locate and edit specific features

Next steps:
- Extract remaining subtraction rows (minuend, subtrahend, answer)
- Refactor main function to compose extracted components
- Maintain backward compatibility via facade pattern
- Validate output matches current worksheets byte-for-byte

See TYPST_HELPERS_REFACTOR_PLAN.md for complete plan.

🤖 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-08 08:04:50 -06:00
parent b4586bac8e
commit b42daf9a4b
5 changed files with 530 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
# typstHelpers.ts Refactoring Plan
## Problem Statement
The `typstHelpers.ts` file has grown to **793 lines** and contains massive template literal strings that make it difficult to:
1. **Edit accurately** - String matching for edits is error-prone due to nested template expressions
2. **Navigate** - Hard to find specific sections within large Typst functions
3. **Test** - Cannot easily unit test individual Typst rendering logic
4. **Maintain** - Changes require careful attention to bracket matching and string escaping
## Current Structure
```typescript
typstHelpers.ts (793 lines)
generateTypstHelpers(cellSize) ~75 lines
generateProblemStackFunction(cellSize) ~235 lines LARGE
generateSubtractionProblemStackFunction() ~360 lines VERY LARGE
generateProblemTypst() [DEPRECATED] ~90 lines
```
### Pain Points
1. **`generateSubtractionProblemStackFunction` is 360 lines** of dense Typst template
- Contains nested conditionals for: borrowing hints, place value colors, ten-frames, etc.
- Difficult to locate specific features (e.g., arrow rendering at line ~467)
- Hard to edit without breaking bracket matching
2. **Template literal hell** - Mixing TypeScript and Typst syntax:
```typescript
`#if show-colors {
box[#diagonal-split-box(${cellSize}, ...)] // TypeScript interpolation
} else {
box[...]
}`
```
3. **Duplicate code** between addition and subtraction functions
- Both have similar grid structures, cell rendering, answer boxes
- Borrow boxes (subtraction) vs. Carry boxes (addition) have parallel logic
## Refactoring Strategy
### Phase 1: Extract Typst Components to Separate Files (High Priority)
**Goal**: Break large functions into smaller, composable Typst templates.
Create new files:
```
typstHelpers/
├── index.ts # Re-exports everything
├── shared/
│ ├── colors.ts # Color constants
│ ├── helpers.ts # ten-frames, diagonal-split-box
│ └── types.ts # TypeScript types
├── addition/
│ ├── carryBoxes.ts # Carry box row rendering
│ ├── addendRows.ts # Addend row rendering
│ ├── answerRow.ts # Answer box row rendering
│ └── problemStack.ts # Main problem-stack function
└── subtraction/
├── borrowBoxes.ts # Borrow box row rendering (with hints/arrows)
├── minuendRow.ts # Minuend row rendering
├── subtrahendRow.ts # Subtrahend row rendering
├── answerRow.ts # Answer box row rendering (shared?)
└── problemStack.ts # Main subtraction-problem-stack function
```
**Benefits**:
- Each file is 50-150 lines instead of 350+
- Easier to locate and edit specific features
- Can share common components (answer rows, cell rendering)
- Better separation of concerns
### Phase 2: Extract Reusable Typst Snippets (Medium Priority)
**Goal**: Create helper functions for frequently duplicated Typst patterns.
```typescript
// Example: Render a cell with optional color and stroke
export function generateCellBox(
cellSize: number,
options: { showColor: boolean; showStroke: boolean }
): string {
const stroke = options.showStroke ? ', stroke: 0.5pt' : ''
return `box(width: ${cellSize}in, height: ${cellSize}in${stroke})`
}
// Example: Render place() helper
export function generatePlaceBlock(
position: string,
dx: number,
dy: number,
content: string
): string {
return `#place(
${position},
dx: ${dx}in,
dy: ${dy}in,
${content}
)`
}
```
**Benefits**:
- Reduces duplication
- Consistent formatting across all Typst generation
- Easier to change common patterns (e.g., cell styling)
### Phase 3: Add Unit Tests (Medium Priority)
**Goal**: Test individual Typst generators in isolation.
```typescript
// tests/typstHelpers/borrowBoxes.test.ts
describe('generateBorrowBoxes', () => {
it('renders arrow when showBorrowingHints is true', () => {
const typst = generateBorrowBoxes({ showBorrowingHints: true })
expect(typst).toContain('path(')
expect(typst).toContain('[▼]')
})
it('does not use place value colors (fixed per design)', () => {
const typst = generateBorrowBoxes({ showPlaceValueColors: true })
expect(typst).not.toContain('diagonal-split-box')
expect(typst).toContain('stroke: 0.5pt')
})
})
```
**Benefits**:
- Catch regressions when editing Typst templates
- Document expected behavior
- Faster feedback than manual worksheet generation
### Phase 4: Centralize Magic Numbers (Low Priority)
**Goal**: Extract hardcoded values to named constants.
```typescript
// typstHelpers/constants.ts
export const TYPST_CONSTANTS = {
CELL_STROKE_WIDTH: 0.5,
TEN_FRAME_STROKE_WIDTH: 0.8,
TEN_FRAME_CELL_STROKE_WIDTH: 0.4,
ARROW_STROKE_WIDTH: 1.5,
// Positioning offsets
ARROW_START_DX: 0.9,
ARROW_START_DY: 0.15,
ARROWHEAD_DX: 0.96,
ARROWHEAD_DY: 0.62,
// Sizing
HINT_TEXT_SIZE_FACTOR: 0.25,
ARROWHEAD_SIZE_FACTOR: 0.35,
} as const
```
**Benefits**:
- Easier to adjust visual parameters
- Self-documenting code
- Reduces magic number proliferation
## Implementation Plan
### Step 1: Create Directory Structure
```bash
mkdir -p src/app/create/worksheets/addition/typstHelpers/{shared,addition,subtraction}
touch src/app/create/worksheets/addition/typstHelpers/index.ts
```
### Step 2: Extract Shared Components (Week 1)
1. Move `color-*` definitions to `shared/colors.ts`
2. Move `ten-frames-stacked`, `diagonal-split-box` to `shared/helpers.ts`
3. Create `shared/types.ts` for TypeScript interfaces
### Step 3: Split Subtraction Function (Week 1-2)
1. Extract borrow box rendering to `subtraction/borrowBoxes.ts`
- Keep arrow rendering self-contained
- Document the "no place value colors" decision
2. Extract minuend row to `subtraction/minuendRow.ts`
3. Extract subtrahend row to `subtraction/subtrahendRow.ts`
4. Extract answer row to `subtraction/answerRow.ts` (or share with addition?)
5. Refactor `generateSubtractionProblemStackFunction` to compose these pieces
### Step 4: Split Addition Function (Week 2)
1. Extract carry box rendering to `addition/carryBoxes.ts`
2. Extract addend rows to `addition/addendRows.ts`
3. Extract answer row to `addition/answerRow.ts`
4. Refactor `generateProblemStackFunction` to compose these pieces
### Step 5: Update Imports (Week 2)
1. Update `typstGenerator.ts` to import from new structure
2. Update `example/route.ts` to import from new structure
3. Ensure all existing consumers work unchanged (backward compatibility)
### Step 6: Add Tests (Week 3)
1. Add unit tests for each extracted component
2. Add integration tests for full problem stack generation
3. Verify output matches current worksheets byte-for-byte
## Migration Strategy
### Backward Compatibility
**Option A: Maintain Old Exports**
- Keep `typstHelpers.ts` as a facade that re-exports from new structure
- Consumers don't need to change imports immediately
- Can gradually migrate to new imports
**Option B: Update All Consumers**
- Change all imports to use new structure immediately
- More disruptive but cleaner
**Recommendation**: Option A - maintain backward compatibility during migration.
```typescript
// typstHelpers.ts (old file becomes facade)
export * from './typstHelpers/index'
// typstHelpers/index.ts (new entry point)
export { generateTypstHelpers } from './shared/helpers'
export { generateProblemStackFunction } from './addition/problemStack'
export { generateSubtractionProblemStackFunction } from './subtraction/problemStack'
```
### Validation
After each refactoring step:
1. ✅ Run `npm run type-check` - no new errors
2. ✅ Run `npm run lint` - no new warnings
3. ✅ Generate test worksheet - byte-for-byte match with current output
4. ✅ Visual inspection - worksheets look identical
## Success Criteria
- [ ] No file exceeds 200 lines
- [ ] Each component has a single, clear responsibility
- [ ] Adding new features (e.g., new display option) only requires editing 1-2 files
- [ ] All tests pass
- [ ] Generated worksheets are byte-for-byte identical to current output
## Timeline
- **Week 1**: Extract shared components + split subtraction function
- **Week 2**: Split addition function + update imports
- **Week 3**: Add tests + documentation
Total estimated effort: **3 weeks** (can be done incrementally)
## Risk Mitigation
1. **Breaking changes**: Maintain backward compatibility via facade pattern
2. **Output differences**: Validate byte-for-byte match after each step
3. **Merge conflicts**: Work in feature branch, frequent small PRs
4. **Lost context**: Document decisions (e.g., "no place value colors in borrow boxes")
## Future Enhancements
After initial refactoring:
1. **Template system**: Consider using a proper Typst template engine
2. **Visual regression testing**: Screenshot comparison for worksheets
3. **Configuration builder**: Type-safe builders for worksheet generation
4. **Shared answer row**: Unify addition/subtraction answer box rendering

View File

@@ -0,0 +1,32 @@
// Place value color definitions for Typst
// Light pastels - unique color per place value
/**
* Generate Typst color variable definitions
* These are used throughout the worksheet for place value visualization
*/
export function generatePlaceValueColors(): string {
return `// Place value colors (light pastels) - unique color per place value
#let color-ones = rgb(227, 242, 253) // Light blue (ones)
#let color-tens = rgb(232, 245, 233) // Light green (tens)
#let color-hundreds = rgb(255, 249, 196) // Light yellow (hundreds)
#let color-thousands = rgb(255, 228, 225) // Light pink/rose (thousands)
#let color-ten-thousands = rgb(243, 229, 245) // Light purple/lavender (ten-thousands)
#let color-hundred-thousands = rgb(255, 239, 213) // Light peach/orange (hundred-thousands)
#let color-none = white // No color
`
}
/**
* Get ordered array of place value color names for use in Typst arrays
*/
export function getPlaceValueColorNames(): string[] {
return [
'color-ones',
'color-tens',
'color-hundreds',
'color-thousands',
'color-ten-thousands',
'color-hundred-thousands',
]
}

View File

@@ -0,0 +1,75 @@
// Shared Typst helper functions and components
// Reusable across addition and subtraction worksheets
import { TYPST_CONSTANTS } from './types'
/**
* Generate Typst helper functions (ten-frames, diagonal boxes, etc.)
* These are shared between addition and subtraction problems
*/
export function generateTypstHelpers(cellSize: number): string {
return String.raw`
// Ten-frame helper - stacked 2 frames vertically, sized to fit cell width
#let ten-frame-spacing = 0pt
#let ten-frame-cell-stroke = ${TYPST_CONSTANTS.TEN_FRAME_CELL_STROKE_WIDTH}pt
#let ten-frame-cell-color = rgb(0, 0, 0, 30%)
#let ten-frame-outer-stroke = ${TYPST_CONSTANTS.TEN_FRAME_STROKE_WIDTH}pt
#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)
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)
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)[],)
}
)
]
)
}
// Diagonal-split box for carry cells
// Shows the transition from one place value to another
// source-color: color of the place value where the carry comes FROM (right side)
// dest-color: color of the place value where the carry goes TO (left side)
#let diagonal-split-box(cell-size, source-color, dest-color) = {
box(width: cell-size, height: cell-size, stroke: ${TYPST_CONSTANTS.CELL_STROKE_WIDTH}pt)[
// Bottom-right triangle (source place value)
#place(
bottom + right,
polygon(
fill: source-color,
stroke: none,
(0pt, 0pt), // bottom-left corner of triangle
(cell-size, 0pt), // bottom-right corner
(cell-size, cell-size) // top-right corner
)
)
// Top-left triangle (destination place value)
#place(
top + left,
polygon(
fill: dest-color,
stroke: none,
(0pt, 0pt), // top-left corner
(cell-size, cell-size), // bottom-right corner of triangle
(0pt, cell-size) // bottom-left corner
)
)
]
}
`
}

View File

@@ -0,0 +1,50 @@
// Shared TypeScript types for Typst generation
export interface DisplayOptions {
showCarryBoxes: boolean
showAnswerBoxes: boolean
showPlaceValueColors: boolean
showProblemNumbers: boolean
showCellBorder: boolean
showTenFrames: boolean
showTenFramesForAll: boolean
fontSize: number
}
export interface CellDimensions {
cellSize: number // in inches
cellSizeIn: string // formatted for Typst (e.g., "0.55in")
cellSizePt: number // in points (for font sizing)
}
export interface TypstConstants {
CELL_STROKE_WIDTH: number
TEN_FRAME_STROKE_WIDTH: number
TEN_FRAME_CELL_STROKE_WIDTH: number
ARROW_STROKE_WIDTH: number
// Positioning offsets for borrowing hint arrows
ARROW_START_DX: number
ARROW_START_DY: number
ARROWHEAD_DX: number
ARROWHEAD_DY: number
// Sizing factors
HINT_TEXT_SIZE_FACTOR: number
ARROWHEAD_SIZE_FACTOR: number
}
export const TYPST_CONSTANTS: TypstConstants = {
CELL_STROKE_WIDTH: 0.5,
TEN_FRAME_STROKE_WIDTH: 0.8,
TEN_FRAME_CELL_STROKE_WIDTH: 0.4,
ARROW_STROKE_WIDTH: 1.5,
ARROW_START_DX: 0.9,
ARROW_START_DY: 0.15,
ARROWHEAD_DX: 0.96,
ARROWHEAD_DY: 0.62,
HINT_TEXT_SIZE_FACTOR: 0.25,
ARROWHEAD_SIZE_FACTOR: 0.35,
} as const

View File

@@ -0,0 +1,105 @@
// Borrow boxes row rendering for subtraction problems
// This row shows where borrows occur (FROM higher place TO lower place)
import type { CellDimensions } from '../shared/types'
import { TYPST_CONSTANTS } from '../shared/types'
/**
* Generate Typst code for the borrow boxes row
*
* Borrow boxes indicate where a borrow operation occurs:
* - Source: The place value we're borrowing FROM (giving)
* - Destination: The place value we're borrowing TO (receiving)
*
* Design decision: Borrow boxes NEVER use place value colors (always stroke-only)
* to avoid arrow layering issues where arrows get covered by adjacent cell backgrounds.
*
* @param cellDimensions - Cell sizing information
* @returns Typst code for borrow boxes row
*/
export function generateBorrowBoxesRow(cellDimensions: CellDimensions): string {
const { cellSize, cellSizeIn, cellSizePt } = cellDimensions
const hintTextSize = (cellSizePt * TYPST_CONSTANTS.HINT_TEXT_SIZE_FACTOR).toFixed(1)
const arrowheadSize = (cellSizePt * TYPST_CONSTANTS.ARROWHEAD_SIZE_FACTOR).toFixed(1)
const arrowStartDx = (cellSize * TYPST_CONSTANTS.ARROW_START_DX).toFixed(2)
const arrowStartDy = (cellSize * TYPST_CONSTANTS.ARROW_START_DY).toFixed(2)
const arrowEndX = (cellSize * 0.24).toFixed(2)
const arrowEndY = (cellSize * 0.7).toFixed(2)
const arrowControlX = (cellSize * 0.11).toFixed(2)
const arrowControlY = (cellSize * -0.5).toFixed(2)
const arrowheadDx = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DX).toFixed(2)
const arrowheadDy = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DY).toFixed(2)
return String.raw`
// Borrow boxes row (shows borrows FROM higher place TO lower place)
[], // Empty cell for operator column
..for i in range(0, grid-digits).rev() {
// Check if we need to borrow FROM this place (to give to i-1)
// We borrow when m-digit < s-digit at position i-1
let shows-borrow = show-borrows and i > 0 and (m-digits.at(i - 1) < s-digits.at(i - 1))
if shows-borrow {
// This place borrowed FROM to give to lower place
let source-color = place-colors.at(i) // This place (giving)
let dest-color = place-colors.at(i - 1) // Lower place (receiving)
// When showing hints, determine what to display based on cascading
if show-borrowing-hints and i <= m-highest {
// Determine the actual value to show in the hint
// For cascading: if this digit is 0, it received 10 from left and gives 1 to right
// So it shows "10 - 1". Otherwise it shows "original - 1"
let original-digit = m-digits.at(i)
// Check if this is part of a cascade (is it 0 and needs to borrow?)
let is-cascade = original-digit == 0
// The display value is either the original digit or 10 (if cascading)
let display-value = if is-cascade { 10 } else { original-digit }
// Borrow boxes never use place value colors (always stroke-only)
// to avoid arrow layering issues
(box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: ${TYPST_CONSTANTS.CELL_STROKE_WIDTH}pt)[
#place(
top + center,
dy: 2pt,
box[
#text(size: ${hintTextSize}pt, fill: gray.darken(30%), weight: "bold")[#str(display-value) ]
#text(size: ${hintTextSize}pt, fill: gray.darken(30%), weight: "bold")[1]
]
)
// Draw curved line using Typst bezier with control point
#place(
top + left,
dx: ${arrowStartDx}in,
dy: ${arrowStartDy}in,
path(
stroke: (paint: gray.darken(30%), thickness: ${TYPST_CONSTANTS.ARROW_STROKE_WIDTH}pt),
// Start vertex (near the "1" in borrow box)
(0pt, 0pt),
// End vertex adjusted up and left to align with arrowhead (vertex, relative-control-point)
((${arrowEndX}in, ${arrowEndY}in), (${arrowControlX}in, ${arrowControlY}in)),
)
)
// Arrowhead pointing down at the top edge of borrowed 10s box
#place(
top + left,
dx: ${arrowheadDx}in,
dy: ${arrowheadDy}in,
text(size: ${arrowheadSize}pt, fill: gray.darken(30%))[▼]
)
],)
} else {
// No hints - just show stroke box
(box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: ${TYPST_CONSTANTS.CELL_STROKE_WIDTH}pt)[],)
}
} else {
// No borrow from this position
(box(width: ${cellSizeIn}, height: ${cellSizeIn})[
#v(${cellSizeIn})
],)
}
},
`
}