feat: Introduce new worksheet generators for counting, coloring, maze, and writing activities.

This commit is contained in:
francy 2026-02-07 10:54:15 +01:00
parent c35de02a8f
commit ed9c118fef
9 changed files with 1199 additions and 145 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,216 @@
/**
* Coloring Page Generator
* Creates themed coloring pages with pre-defined SVG shapes
*/
// Library of coloring illustrations (outlines only)
const illustrations = {
// Animals
cat: `<g transform='translate(150, 100) scale(3)'>
<ellipse cx='25' cy='35' rx='20' ry='25' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='25' cy='15' rx='18' ry='15' fill='none' stroke='#000' stroke-width='2'/>
<polygon points='8,5 12,18 3,15' fill='none' stroke='#000' stroke-width='2'/>
<polygon points='42,5 38,18 47,15' fill='none' stroke='#000' stroke-width='2'/>
<circle cx='18' cy='12' r='3' fill='none' stroke='#000' stroke-width='1.5'/>
<circle cx='32' cy='12' r='3' fill='none' stroke='#000' stroke-width='1.5'/>
<ellipse cx='25' cy='20' rx='4' ry='3' fill='none' stroke='#000' stroke-width='1.5'/>
<path d='M25,23 L25,28' stroke='#000' stroke-width='1.5'/>
<path d='M21,26 Q25,30 29,26' fill='none' stroke='#000' stroke-width='1.5'/>
<path d='M8,15 L-5,12' stroke='#000' stroke-width='1'/>
<path d='M8,18 L-5,18' stroke='#000' stroke-width='1'/>
<path d='M42,15 L55,12' stroke='#000' stroke-width='1'/>
<path d='M42,18 L55,18' stroke='#000' stroke-width='1'/>
</g>`,
dog: `<g transform='translate(150, 100) scale(3)'>
<ellipse cx='25' cy='40' rx='22' ry='28' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='25' cy='12' rx='18' ry='14' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='8' cy='8' rx='8' ry='12' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='42' cy='8' rx='8' ry='12' fill='none' stroke='#000' stroke-width='2'/>
<circle cx='18' cy='10' r='3' fill='none' stroke='#000' stroke-width='1.5'/>
<circle cx='32' cy='10' r='3' fill='none' stroke='#000' stroke-width='1.5'/>
<ellipse cx='25' cy='18' rx='6' ry='4' fill='none' stroke='#000' stroke-width='1.5'/>
<path d='M22,24 Q25,28 28,24' fill='none' stroke='#000' stroke-width='1.5'/>
<path d='M47,55 Q55,65 48,70' fill='none' stroke='#000' stroke-width='2'/>
</g>`,
butterfly: `<g transform='translate(120, 80) scale(3.5)'>
<ellipse cx='25' cy='25' rx='20' ry='18' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='55' cy='25' rx='20' ry='18' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='25' cy='50' rx='15' ry='12' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='55' cy='50' rx='15' ry='12' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='40' cy='35' rx='4' ry='25' fill='none' stroke='#000' stroke-width='2'/>
<path d='M38,10 Q30,0 25,5' fill='none' stroke='#000' stroke-width='1.5'/>
<path d='M42,10 Q50,0 55,5' fill='none' stroke='#000' stroke-width='1.5'/>
<circle cx='25' cy='25' r='5' fill='none' stroke='#000' stroke-width='1'/>
<circle cx='55' cy='25' r='5' fill='none' stroke='#000' stroke-width='1'/>
</g>`,
fish: `<g transform='translate(100, 120) scale(4)'>
<ellipse cx='40' cy='25' rx='35' ry='20' fill='none' stroke='#000' stroke-width='2'/>
<polygon points='5,25 -15,10 -15,40' fill='none' stroke='#000' stroke-width='2'/>
<circle cx='55' cy='20' r='4' fill='none' stroke='#000' stroke-width='1.5'/>
<path d='M40,5 Q50,0 55,8' fill='none' stroke='#000' stroke-width='2'/>
<path d='M40,45 Q50,50 55,42' fill='none' stroke='#000' stroke-width='2'/>
<path d='M25,15 Q30,25 25,35' fill='none' stroke='#000' stroke-width='1'/>
<path d='M35,13 Q40,25 35,37' fill='none' stroke='#000' stroke-width='1'/>
</g>`,
flower: `<g transform='translate(150, 80) scale(3.5)'>
<circle cx='25' cy='30' r='12' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='25' cy='10' rx='10' ry='12' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='45' cy='22' rx='10' ry='12' transform='rotate(60, 45, 22)' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='40' cy='45' rx='10' ry='12' transform='rotate(120, 40, 45)' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='10' cy='45' rx='10' ry='12' transform='rotate(-120, 10, 45)' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='5' cy='22' rx='10' ry='12' transform='rotate(-60, 5, 22)' fill='none' stroke='#000' stroke-width='2'/>
<path d='M25,42 L25,80' stroke='#000' stroke-width='3'/>
<path d='M25,55 Q35,50 40,55' fill='none' stroke='#000' stroke-width='2'/>
<path d='M25,65 Q15,60 10,65' fill='none' stroke='#000' stroke-width='2'/>
</g>`,
star: `<g transform='translate(125, 80) scale(5)'>
<polygon points='25,0 31,18 50,18 35,30 41,50 25,38 9,50 15,30 0,18 19,18' fill='none' stroke='#000' stroke-width='2'/>
<circle cx='20' cy='22' r='2' fill='none' stroke='#000' stroke-width='1'/>
<circle cx='30' cy='22' r='2' fill='none' stroke='#000' stroke-width='1'/>
<path d='M22,28 Q25,32 28,28' fill='none' stroke='#000' stroke-width='1'/>
</g>`,
rocket: `<g transform='translate(150, 60) scale(3)'>
<path d='M25,0 Q35,20 35,50 L35,70 L15,70 L15,50 Q15,20 25,0' fill='none' stroke='#000' stroke-width='2'/>
<circle cx='25' cy='30' r='8' fill='none' stroke='#000' stroke-width='2'/>
<path d='M15,55 L5,70 L15,65' fill='none' stroke='#000' stroke-width='2'/>
<path d='M35,55 L45,70 L35,65' fill='none' stroke='#000' stroke-width='2'/>
<path d='M18,70 Q25,90 32,70' fill='none' stroke='#000' stroke-width='2'/>
<path d='M20,75 Q25,85 30,75' fill='none' stroke='#000' stroke-width='1.5'/>
</g>`,
sun: `<g transform='translate(125, 80) scale(4)'>
<circle cx='30' cy='30' r='20' fill='none' stroke='#000' stroke-width='2'/>
<line x1='30' y1='0' x2='30' y2='-10' stroke='#000' stroke-width='2'/>
<line x1='30' y1='60' x2='30' y2='70' stroke='#000' stroke-width='2'/>
<line x1='0' y1='30' x2='-10' y2='30' stroke='#000' stroke-width='2'/>
<line x1='60' y1='30' x2='70' y2='30' stroke='#000' stroke-width='2'/>
<line x1='9' y1='9' x2='2' y2='2' stroke='#000' stroke-width='2'/>
<line x1='51' y1='9' x2='58' y2='2' stroke='#000' stroke-width='2'/>
<line x1='9' y1='51' x2='2' y2='58' stroke='#000' stroke-width='2'/>
<line x1='51' y1='51' x2='58' y2='58' stroke='#000' stroke-width='2'/>
<circle cx='22' cy='25' r='3' fill='none' stroke='#000' stroke-width='1'/>
<circle cx='38' cy='25' r='3' fill='none' stroke='#000' stroke-width='1'/>
<path d='M22,38 Q30,45 38,38' fill='none' stroke='#000' stroke-width='1.5'/>
</g>`,
house: `<g transform='translate(100, 100) scale(3)'>
<rect x='15' y='40' width='70' height='55' fill='none' stroke='#000' stroke-width='2'/>
<polygon points='50,5 10,40 90,40' fill='none' stroke='#000' stroke-width='2'/>
<rect x='40' y='65' width='20' height='30' fill='none' stroke='#000' stroke-width='2'/>
<circle cx='55' cy='80' r='2' fill='none' stroke='#000' stroke-width='1'/>
<rect x='22' y='50' width='15' height='15' fill='none' stroke='#000' stroke-width='2'/>
<line x1='29.5' y1='50' x2='29.5' y2='65' stroke='#000' stroke-width='1'/>
<line x1='22' y1='57.5' x2='37' y2='57.5' stroke='#000' stroke-width='1'/>
<rect x='63' y='50' width='15' height='15' fill='none' stroke='#000' stroke-width='2'/>
<line x1='70.5' y1='50' x2='70.5' y2='65' stroke='#000' stroke-width='1'/>
<line x1='63' y1='57.5' x2='78' y2='57.5' stroke='#000' stroke-width='1'/>
</g>`,
unicorn: `<g transform='translate(100, 80) scale(3)'>
<ellipse cx='50' cy='50' rx='35' ry='25' fill='none' stroke='#000' stroke-width='2'/>
<ellipse cx='20' cy='35' rx='15' ry='20' fill='none' stroke='#000' stroke-width='2'/>
<polygon points='20,15 25,0 15,0' fill='none' stroke='#000' stroke-width='2'/>
<path d='M8,25 Q0,20 5,30' fill='none' stroke='#000' stroke-width='2'/>
<circle cx='15' cy='30' r='2' fill='none' stroke='#000' stroke-width='1'/>
<path d='M10,40 Q15,45 20,42' fill='none' stroke='#000' stroke-width='1'/>
<path d='M25,75 L25,95' stroke='#000' stroke-width='3'/>
<path d='M40' y1='75' L40,95' stroke='#000' stroke-width='3'/>
<path d='M60,75 L60,95' stroke='#000' stroke-width='3'/>
<path d='M75,75 L75,95' stroke='#000' stroke-width='3'/>
<path d='M85,50 Q100,45 95,55 Q105,60 90,65' fill='none' stroke='#000' stroke-width='2'/>
<path d='M5,20 Q-5,15 0,25' fill='none' stroke='#000' stroke-width='1.5'/>
<path d='M35,25 Q45,20 38,30' fill='none' stroke='#000' stroke-width='1.5'/>
</g>`
};
const illustrationNames = Object.keys(illustrations);
/**
* Pick an illustration based on theme keywords with randomization
*/
function pickIllustration(theme, llmSuggestion, age) {
// Try LLM suggestion first if valid
if (llmSuggestion && illustrations[llmSuggestion]) {
return llmSuggestion;
}
if (!theme) {
return illustrationNames[Math.floor(Math.random() * illustrationNames.length)];
}
const themeLower = theme.toLowerCase();
// Direct matches
for (const name of illustrationNames) {
if (themeLower.includes(name)) {
return name;
}
}
// Keyword associations
const associations = {
cat: ['kitten', 'kitty', 'meow', 'feline', 'pet', 'animal'],
dog: ['puppy', 'doggy', 'woof', 'canine', 'pet', 'animal'],
butterfly: ['insect', 'bug', 'garden', 'spring', 'animal'],
fish: ['ocean', 'sea', 'water', 'aquarium', 'swim', 'animal'],
flower: ['garden', 'plant', 'nature', 'spring', 'rose'],
star: ['night', 'sky', 'space', 'twinkle', 'magic'],
rocket: ['space', 'astronaut', 'moon', 'planet', 'fly'],
sun: ['summer', 'sunny', 'bright', 'happy', 'day'],
house: ['home', 'building', 'family', 'cozy'],
unicorn: ['magic', 'fairy', 'princess', 'rainbow', 'fantasy', 'horse', 'animal']
};
// Collect ALL matching illustrations
const matchingIllustrations = [];
for (const [illustration, keywords] of Object.entries(associations)) {
if (keywords.some(keyword => themeLower.includes(keyword))) {
matchingIllustrations.push(illustration);
}
}
// If we found matches, pick one randomly for variety
if (matchingIllustrations.length > 0) {
return matchingIllustrations[Math.floor(Math.random() * matchingIllustrations.length)];
}
// Random fallback
return illustrationNames[Math.floor(Math.random() * illustrationNames.length)];
}
/**
* Generate complete coloring page
* @param {Object} params
* @param {string} params.theme - Theme for the coloring page
* @param {string} params.illustration - LLM-suggested illustration (optional)
* @param {number} params.age - Child's age (optional)
*/
export function generateColoringPage({ theme, illustration, age }) {
const selectedIllustration = pickIllustration(theme, illustration, age);
const illustrationSvg = illustrations[selectedIllustration] || illustrations.cat;
const displayName = selectedIllustration.charAt(0).toUpperCase() + selectedIllustration.slice(1);
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
<!-- Main illustration -->
${illustrationSvg}
</svg>`;
return {
title: `${displayName} Coloring Page`,
subtitle: theme ? `${theme} themed coloring fun!` : 'Color and have fun!',
sections: [{
id: 'coloring-main',
type: 'coloring',
content_svg: svg
}]
};
}
export default { generateColoringPage };

View File

@ -0,0 +1,204 @@
/**
* Counting Worksheet Generator
* Generates randomized counting exercises with themed objects
*/
// Simple themed object SVG shapes
const themedObjects = {
star: `<polygon points='12,0 15,8 24,9 17,15 19,24 12,19 5,24 7,15 0,9 9,8' fill='none' stroke='#000000' stroke-width='2'/>`,
heart: `<path d='M12,21 L2,11 C-2,7 2,0 8,0 C10,0 12,2 12,4 C12,2 14,0 16,0 C22,0 26,7 22,11 Z' fill='none' stroke='#000000' stroke-width='2'/>`,
circle: `<circle cx='12' cy='12' r='10' fill='none' stroke='#000000' stroke-width='2'/>`,
apple: `<path d='M12,4 Q12,0 16,0 M8,6 Q0,6 0,14 Q0,24 12,24 Q24,24 24,14 Q24,6 16,6 Q12,2 8,6 Z' fill='none' stroke='#000000' stroke-width='2'/>`,
fish: `<path d='M0,12 Q6,0 18,6 L24,0 L24,24 L18,18 Q6,24 0,12 Z M16,10 A1,1 0 1,1 16,11' fill='none' stroke='#000000' stroke-width='2'/>`,
flower: `<circle cx='12' cy='12' r='4' fill='none' stroke='#000000' stroke-width='2'/><circle cx='12' cy='4' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='19' cy='8' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='19' cy='16' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='12' cy='20' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='5' cy='16' r='4' fill='none' stroke='#000000' stroke-width='1.5'/><circle cx='5' cy='8' r='4' fill='none' stroke='#000000' stroke-width='1.5'/>`,
butterfly: `<ellipse cx='6' cy='8' rx='6' ry='8' fill='none' stroke='#000000' stroke-width='2'/><ellipse cx='18' cy='8' rx='6' ry='8' fill='none' stroke='#000000' stroke-width='2'/><ellipse cx='6' cy='20' rx='5' ry='6' fill='none' stroke='#000000' stroke-width='2'/><ellipse cx='18' cy='20' rx='5' ry='6' fill='none' stroke='#000000' stroke-width='2'/><line x1='12' y1='2' x2='12' y2='26' stroke='#000000' stroke-width='2'/>`,
ball: `<circle cx='12' cy='12' r='11' fill='none' stroke='#000000' stroke-width='2'/><path d='M1,12 Q12,8 23,12' fill='none' stroke='#000000' stroke-width='1'/><path d='M1,12 Q12,16 23,12' fill='none' stroke='#000000' stroke-width='1'/>`,
moon: `<path d='M18,2 A14,14 0 1,0 18,22 A10,10 0 1,1 18,2' fill='none' stroke='#000000' stroke-width='2'/>`,
cloud: `<path d='M8,18 A6,6 0 0,1 8,6 A8,8 0 0,1 20,8 A5,5 0 0,1 20,18 Z' fill='none' stroke='#000000' stroke-width='2'/>`
};
const objectNames = Object.keys(themedObjects);
/**
* Generate random quantities that are all different
*/
function generateRandomQuantities(count, maxValue) {
const quantities = [];
const used = new Set();
while (quantities.length < count) {
const num = Math.floor(Math.random() * maxValue) + 1;
if (!used.has(num)) {
used.add(num);
quantities.push(num);
}
}
// Shuffle the array
return quantities.sort(() => Math.random() - 0.5);
}
/**
* Pick object type based on theme keywords with randomization
*/
function pickObjectForTheme(theme, llmSuggestion) {
// Try to use LLM suggestion first if valid
if (llmSuggestion && themedObjects[llmSuggestion]) {
return llmSuggestion;
}
if (!theme) {
return objectNames[Math.floor(Math.random() * objectNames.length)];
}
const themeLower = theme.toLowerCase();
// Direct matches
for (const name of objectNames) {
if (themeLower.includes(name)) {
return name;
}
}
// Build list of ALL matching shapes, then pick randomly
const associations = {
star: ['space', 'night', 'sky', 'twinkle', 'magic', 'galaxy', 'astronaut', 'king', 'crown', 'royal'],
heart: ['love', 'valentine', 'romance', 'caring', 'friendship', 'kind', 'help'],
circle: ['round', 'bubble', 'dot'],
apple: ['fruit', 'food', 'tree', 'garden', 'healthy', 'snack', 'farm', 'autumn', 'fall'],
fish: ['ocean', 'sea', 'water', 'aquarium', 'swim', 'underwater', 'beach', 'marine', 'animal'],
flower: ['garden', 'plant', 'nature', 'spring', 'rose', 'bloom', 'petal', 'beautiful'],
butterfly: ['insect', 'bug', 'garden', 'spring', 'colorful', 'wings', 'animal', 'fly'],
ball: ['sport', 'play', 'game', 'soccer', 'football', 'basketball', 'tennis', 'cricket', 'kick'],
moon: ['night', 'sleep', 'bedtime', 'dream', 'space', 'lunar', 'dark'],
cloud: ['sky', 'weather', 'rain', 'sunny', 'day', 'fluffy', 'soft']
};
// Collect ALL matching shapes
const matchingShapes = [];
for (const [objectType, keywords] of Object.entries(associations)) {
if (keywords.some(keyword => themeLower.includes(keyword))) {
matchingShapes.push(objectType);
}
}
// If we found matches, pick one randomly for variety
if (matchingShapes.length > 0) {
return matchingShapes[Math.floor(Math.random() * matchingShapes.length)];
}
// Random fallback
return objectNames[Math.floor(Math.random() * objectNames.length)];
}
/**
* Generate a group of objects at a position
*/
function generateObjectGroup(objectType, count, x, y, groupWidth) {
const objectSvg = themedObjects[objectType] || themedObjects.star;
const objectSize = 22;
const maxPerRow = 5;
let svg = '';
if (count <= maxPerRow) {
// Single row
const spacing = Math.min(28, (groupWidth - count * objectSize) / (count + 1));
let startX = x + spacing;
for (let i = 0; i < count; i++) {
svg += `<g transform='translate(${startX + i * (objectSize + spacing)}, ${y})'>${objectSvg}</g>`;
}
} else {
// Two rows for larger counts
const topRowCount = Math.ceil(count / 2);
const bottomRowCount = count - topRowCount;
const spacing = Math.min(26, (groupWidth - topRowCount * objectSize) / (topRowCount + 1));
// Top row
let startX = x + spacing;
for (let i = 0; i < topRowCount; i++) {
svg += `<g transform='translate(${startX + i * (objectSize + spacing)}, ${y - 12})'>${objectSvg}</g>`;
}
// Bottom row
startX = x + spacing + (topRowCount - bottomRowCount) * (objectSize + spacing) / 2;
for (let i = 0; i < bottomRowCount; i++) {
svg += `<g transform='translate(${startX + i * (objectSize + spacing)}, ${y + 14})'>${objectSvg}</g>`;
}
}
return svg;
}
/**
* Generate an empty answer box
*/
function generateAnswerBox(x, y, size = 40) {
return `<rect x='${x}' y='${y}' width='${size}' height='${size}' fill='#FFFFFF' stroke='#333333' stroke-width='2' rx='5'/>`;
}
/**
* Generate complete counting worksheet
* @param {Object} params
* @param {string} params.theme - Theme name (optional)
* @param {string} params.shape - Shape chosen by LLM (e.g. 'fish', 'star')
* @param {number[]} params.quantities - Array of quantities from LLM
* @param {number} params.age - Child's age
*/
export function generateCountingWorksheet({ theme, shape, quantities, age }) {
// Use hybrid approach: LLM suggestion + programmatic randomization from matching shapes
const objectType = pickObjectForTheme(theme, shape);
// ALWAYS generate random quantities for variety (LLM is deterministic)
const maxValue = age <= 4 ? 5 : 10;
const rowCount = 8;
const finalQuantities = generateRandomQuantities(rowCount, maxValue);
const rowHeight = 62; // 500 / 8 = 62.5, fill entire height
const startY = 0; // Start at absolute top
const objectAreaWidth = 320;
const answerBoxX = 440;
const answerBoxSize = 42;
let objectRows = '';
for (let i = 0; i < rowCount; i++) {
const rowY = startY + i * rowHeight;
const quantity = finalQuantities[i];
// Dotted separator line between rows (except first row)
if (i > 0) {
objectRows += `<line x1='15' y1='${rowY}' x2='485' y2='${rowY}' stroke='#CCCCCC' stroke-width='1' stroke-dasharray='5,5'/>`;
}
// Row number indicator - vertically centered in row
objectRows += `<text x='30' y='${rowY + 38}' font-family='Arial, sans-serif' font-size='14' font-weight='bold' fill='#AAAAAA'>${i + 1}.</text>`;
// Objects to count - positioned in middle of row
objectRows += generateObjectGroup(objectType, quantity, 50, rowY + 20, objectAreaWidth);
// Equals sign
objectRows += `<text x='405' y='${rowY + 40}' font-family='Arial, sans-serif' font-size='20' font-weight='bold' fill='#666666'>=</text>`;
// Answer box
objectRows += generateAnswerBox(answerBoxX, rowY + 10, answerBoxSize);
}
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
<!-- Counting rows -->
${objectRows}
</svg>`;
return {
title: `Count the ${objectType.charAt(0).toUpperCase() + objectType.slice(1)}s!`,
subtitle: 'Count each group and write the number',
sections: [{
id: 'counting-main',
type: 'counting',
content_svg: svg
}]
};
}
export default { generateCountingWorksheet };

View File

@ -0,0 +1,9 @@
/**
* Worksheet Generators Index
* Exports all programmatic SVG generators
*/
export { generateWritingWorksheet } from './writing.js';
export { generateCountingWorksheet } from './counting.js';
export { generateMazeWorksheet } from './maze.js';
export { generateColoringPage } from './coloring.js';

241
server/generators/maze.js Normal file
View File

@ -0,0 +1,241 @@
/**
* Maze Generator
* Creates algorithmically generated, guaranteed-solvable mazes
*/
/**
* Generate a maze using recursive backtracking algorithm
* Returns a 2D grid where true = wall, false = path
*/
function generateMazeGrid(rows, cols, startEdge, endEdge) {
// Initialize grid with all walls
const grid = Array(rows * 2 + 1).fill(null).map(() =>
Array(cols * 2 + 1).fill(true)
);
// Recursive backtracking to carve paths
const visited = Array(rows).fill(null).map(() => Array(cols).fill(false));
function carve(row, col) {
visited[row][col] = true;
grid[row * 2 + 1][col * 2 + 1] = false; // Carve cell
// Get neighbors in random order
const directions = [
[-1, 0], [1, 0], [0, -1], [0, 1]
].sort(() => Math.random() - 0.5);
for (const [dr, dc] of directions) {
const newRow = row + dr;
const newCol = col + dc;
if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols && !visited[newRow][newCol]) {
// Carve wall between cells
grid[row * 2 + 1 + dr][col * 2 + 1 + dc] = false;
carve(newRow, newCol);
}
}
}
// Determine starting cell for carving based on entrance position
let startRow = 0, startCol = 0;
switch (startEdge) {
case 'top':
startRow = 0; startCol = 0;
break;
case 'bottom':
startRow = rows - 1; startCol = rows - 1;
break;
case 'left':
startRow = 0; startCol = 0;
break;
case 'right':
startRow = 0; startCol = cols - 1;
break;
}
// Start carving from position near entrance
carve(startRow, startCol);
// Track entrance and exit positions for marker placement
let entranceGridPos = { row: 0, col: 0 };
let exitGridPos = { row: 0, col: 0 };
// Create entrance based on startEdge - opening in the outer wall
// Place in first third of edge for variety
switch (startEdge) {
case 'top':
const topEntranceCol = 1 + Math.floor(Math.random() * Math.max(1, cols / 3)) * 2;
grid[0][topEntranceCol] = false;
entranceGridPos = { row: 0, col: topEntranceCol };
break;
case 'bottom':
const bottomEntranceCol = 1 + Math.floor(Math.random() * Math.max(1, cols / 3)) * 2;
grid[rows * 2][bottomEntranceCol] = false;
entranceGridPos = { row: rows * 2, col: bottomEntranceCol };
break;
case 'left':
const leftEntranceRow = 1 + Math.floor(Math.random() * Math.max(1, rows / 3)) * 2;
grid[leftEntranceRow][0] = false;
entranceGridPos = { row: leftEntranceRow, col: 0 };
break;
case 'right':
const rightEntranceRow = 1 + Math.floor(Math.random() * Math.max(1, rows / 3)) * 2;
grid[rightEntranceRow][cols * 2] = false;
entranceGridPos = { row: rightEntranceRow, col: cols * 2 };
break;
}
// Create exit based on endEdge - opening in the outer wall
// Place in last third of edge to be far from entrance
switch (endEdge) {
case 'top':
const topExitCol = cols * 2 - 1 - Math.floor(Math.random() * Math.max(1, cols / 3)) * 2;
grid[0][topExitCol] = false;
exitGridPos = { row: 0, col: topExitCol };
break;
case 'bottom':
const bottomExitCol = cols * 2 - 1 - Math.floor(Math.random() * Math.max(1, cols / 3)) * 2;
grid[rows * 2][bottomExitCol] = false;
exitGridPos = { row: rows * 2, col: bottomExitCol };
break;
case 'left':
const leftExitRow = rows * 2 - 1 - Math.floor(Math.random() * Math.max(1, rows / 3)) * 2;
grid[leftExitRow][0] = false;
exitGridPos = { row: leftExitRow, col: 0 };
break;
case 'right':
const rightExitRow = rows * 2 - 1 - Math.floor(Math.random() * Math.max(1, rows / 3)) * 2;
grid[rightExitRow][cols * 2] = false;
exitGridPos = { row: rightExitRow, col: cols * 2 };
break;
}
return { grid, entranceGridPos, exitGridPos };
}
/**
* Convert maze grid to SVG
*/
function mazeGridToSVG(grid, startX, startY, cellSize) {
let svg = '';
const wallColor = '#333333';
for (let row = 0; row < grid.length; row++) {
for (let col = 0; col < grid[row].length; col++) {
if (grid[row][col]) {
const x = startX + col * cellSize;
const y = startY + row * cellSize;
svg += `<rect x='${x}' y='${y}' width='${cellSize}' height='${cellSize}' fill='${wallColor}'/>`;
}
}
}
return svg;
}
/**
* Generate START and GOAL markers
*/
function generateMarkers(startX, startY, endX, endY, theme, cellSize) {
// Smaller icon size to fit in path cells
const iconSize = Math.min(cellSize * 0.6, 20);
const radius = iconSize / 2;
return `
<!-- START marker: Black circle in white path -->
<circle cx="${startX}" cy="${startY}" r="${radius}" fill="#000000" stroke="#FFFFFF" stroke-width="2"/>
<text x="${startX}" y="${startY + radius + 12}" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="#000000">START</text>
<!-- GOAL marker: White circle with flag in white path -->
<circle cx="${endX}" cy="${endY}" r="${radius}" fill="#FFFFFF" stroke="#000000" stroke-width="2"/>
<text x="${endX}" y="${endY + radius + 12}" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="#000000">GOAL</text>
`;
}
/**
* Generate complete maze worksheet
* @param {Object} params
* @param {string} params.theme - Theme for decorations
* @param {number} params.difficulty - 1-3 (affects grid size)
* @param {number} params.age - Child's age
*/
export function generateMazeWorksheet({ theme, difficulty = 1, age }) {
// Grid size based on difficulty and age
let rows, cols;
if (age <= 4 || difficulty === 1) {
rows = 4;
cols = 4;
} else if (age <= 5 || difficulty === 2) {
rows = 5;
cols = 5;
} else {
rows = 6;
cols = 6;
}
// Randomly select start and end edges - favor opposite edges for better gameplay
const edges = ['top', 'bottom', 'left', 'right'];
const startEdge = edges[Math.floor(Math.random() * edges.length)];
// 80% chance to pick opposite edge, 20% chance for any different edge
let endEdge;
const oppositeEdges = {
'top': 'bottom',
'bottom': 'top',
'left': 'right',
'right': 'left'
};
if (Math.random() < 0.8) {
// Pick opposite edge
endEdge = oppositeEdges[startEdge];
} else {
// Pick any different edge
const remainingEdges = edges.filter(e => e !== startEdge);
endEdge = remainingEdges[Math.floor(Math.random() * remainingEdges.length)];
}
console.log(`Maze edges: START=${startEdge}, END=${endEdge}`);
const { grid, entranceGridPos, exitGridPos } = generateMazeGrid(rows, cols, startEdge, endEdge);
// Calculate positioning to fill the space
const gridWidth = cols * 2 + 1;
const gridHeight = rows * 2 + 1;
const cellSize = Math.min(480 / gridWidth, 480 / gridHeight);
const mazeWidth = gridWidth * cellSize;
const mazeHeight = gridHeight * cellSize;
const startX = (500 - mazeWidth) / 2;
const startY = (500 - mazeHeight) / 2;
// Calculate marker positions based on actual entrance/exit grid positions
// Add half cellSize to center in the path cell (not on the edge)
const mazeStartX = startX + entranceGridPos.col * cellSize + cellSize / 2;
const mazeStartY = startY + entranceGridPos.row * cellSize + cellSize / 2;
const mazeEndX = startX + exitGridPos.col * cellSize + cellSize / 2;
const mazeEndY = startY + exitGridPos.row * cellSize + cellSize / 2;
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
<!-- Maze background -->
<rect x='${startX - 3}' y='${startY - 3}' width='${mazeWidth + 6}' height='${mazeHeight + 6}' fill='#FFFFFF' stroke='#333333' stroke-width='2' rx='3'/>
<!-- Maze walls -->
${mazeGridToSVG(grid, startX, startY, cellSize)}
<!-- Start/End markers -->
${generateMarkers(mazeStartX, mazeStartY, mazeEndX, mazeEndY, theme, cellSize)}
</svg>`;
return {
title: theme ? `${theme} Maze Adventure` : 'Maze Adventure',
subtitle: 'Find the path from start to finish!',
sections: [{
id: 'find-the-way-main',
type: 'find-the-way',
content_svg: svg
}]
};
}
export default { generateMazeWorksheet };

View File

@ -0,0 +1,142 @@
/**
* Writing/Tracing Worksheet Generator
* Generates consistent dotted letters and numbers for tracing practice
*/
// Letter path definitions (school-style uppercase)
const letterPaths = {
'A': 'M 0 80 L 25 0 L 50 80 M 12 50 L 38 50',
'B': 'M 0 0 L 0 80 L 35 80 Q 50 80 50 60 Q 50 40 35 40 L 0 40 M 0 0 L 35 0 Q 50 0 50 20 Q 50 40 35 40',
'C': 'M 50 15 Q 50 0 25 0 Q 0 0 0 40 Q 0 80 25 80 Q 50 80 50 65',
'D': 'M 0 0 L 0 80 L 25 80 Q 50 80 50 40 Q 50 0 25 0 L 0 0',
'E': 'M 50 0 L 0 0 L 0 80 L 50 80 M 0 40 L 35 40',
'F': 'M 50 0 L 0 0 L 0 80 M 0 40 L 35 40',
'G': 'M 50 15 Q 50 0 25 0 Q 0 0 0 40 Q 0 80 25 80 Q 50 80 50 50 L 30 50',
'H': 'M 0 0 L 0 80 M 50 0 L 50 80 M 0 40 L 50 40',
'I': 'M 10 0 L 40 0 M 25 0 L 25 80 M 10 80 L 40 80',
'J': 'M 10 0 L 40 0 M 30 0 L 30 60 Q 30 80 15 80 Q 0 80 0 65',
'K': 'M 0 0 L 0 80 M 50 0 L 0 45 L 50 80',
'L': 'M 0 0 L 0 80 L 50 80',
'M': 'M 0 80 L 0 0 L 25 40 L 50 0 L 50 80',
'N': 'M 0 80 L 0 0 L 50 80 L 50 0',
'O': 'M 25 0 Q 0 0 0 40 Q 0 80 25 80 Q 50 80 50 40 Q 50 0 25 0',
'P': 'M 0 80 L 0 0 L 35 0 Q 50 0 50 20 Q 50 40 35 40 L 0 40',
'Q': 'M 25 0 Q 0 0 0 40 Q 0 80 25 80 Q 50 80 50 40 Q 50 0 25 0 M 35 60 L 55 85',
'R': 'M 0 80 L 0 0 L 35 0 Q 50 0 50 20 Q 50 40 35 40 L 0 40 M 30 40 L 50 80',
'S': 'M 50 15 Q 50 0 25 0 Q 0 0 0 20 Q 0 40 25 40 Q 50 40 50 60 Q 50 80 25 80 Q 0 80 0 65',
'T': 'M 0 0 L 50 0 M 25 0 L 25 80',
'U': 'M 0 0 L 0 60 Q 0 80 25 80 Q 50 80 50 60 L 50 0',
'V': 'M 0 0 L 25 80 L 50 0',
'W': 'M 0 0 L 12 80 L 25 30 L 38 80 L 50 0',
'X': 'M 0 0 L 50 80 M 50 0 L 0 80',
'Y': 'M 0 0 L 25 40 L 50 0 M 25 40 L 25 80',
'Z': 'M 0 0 L 50 0 L 0 80 L 50 80'
};
// Number path definitions
const numberPaths = {
'1': 'M 15 15 L 25 0 L 25 80 M 10 80 L 40 80',
'2': 'M 0 20 Q 0 0 25 0 Q 50 0 50 20 Q 50 40 0 80 L 50 80',
'3': 'M 0 10 Q 0 0 25 0 Q 50 0 50 20 Q 50 40 25 40 Q 50 40 50 60 Q 50 80 25 80 Q 0 80 0 70',
'4': 'M 40 80 L 40 0 L 0 55 L 50 55',
'5': 'M 50 0 L 0 0 L 0 35 Q 0 35 25 35 Q 50 35 50 57 Q 50 80 25 80 Q 0 80 0 65',
'6': 'M 45 10 Q 45 0 25 0 Q 0 0 0 40 L 0 55 Q 0 80 25 80 Q 50 80 50 57 Q 50 35 25 35 Q 0 35 0 55',
'7': 'M 0 0 L 50 0 L 20 80',
'8': 'M 25 40 Q 0 40 0 20 Q 0 0 25 0 Q 50 0 50 20 Q 50 40 25 40 Q 0 40 0 60 Q 0 80 25 80 Q 50 80 50 60 Q 50 40 25 40',
'9': 'M 5 70 Q 5 80 25 80 Q 50 80 50 40 L 50 25 Q 50 0 25 0 Q 0 0 0 23 Q 0 45 25 45 Q 50 45 50 25'
};
/**
* Generate a dotted path for a single character
*/
function generateDottedChar(char, x, y, scale = 1, color = '#000000') {
const path = letterPaths[char.toUpperCase()] || numberPaths[char];
if (!path) return '';
// Transform the path to the correct position and scale
const transformedPath = path.replace(/(\d+)/g, (match) => {
return match; // Keep original values, we'll use transform
});
return `<path d='${path}' transform='translate(${x}, ${y}) scale(${scale})' fill='none' stroke='${color}' stroke-width='4' stroke-dasharray='8,8' stroke-linecap='round'/>`;
}
/**
* Generate a row of dotted characters centered on the page with dynamic scaling
*/
function generateCharacterRow(text, y, baseScale = 1, color = '#000000') {
const baseCharWidth = 50;
const baseSpacing = 20;
const maxWidth = 460; // Leave 20px margin on each side
// Calculate required width with base scale
const requiredWidth = text.length * (baseCharWidth + baseSpacing) * baseScale - baseSpacing * baseScale;
// Scale down if content is too wide
let finalScale = baseScale;
if (requiredWidth > maxWidth) {
finalScale = maxWidth / (text.length * (baseCharWidth + baseSpacing) - baseSpacing);
}
const charWidth = baseCharWidth * finalScale;
const spacing = baseSpacing * finalScale;
const totalWidth = text.length * charWidth + (text.length - 1) * spacing;
let startX = (500 - totalWidth) / 2;
let svg = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
svg += generateDottedChar(char, startX + i * (charWidth + spacing), y, finalScale, color);
}
return svg;
}
/**
* Generate baseline guide
*/
function generateBaseline(y, width = 400) {
const startX = (500 - width) / 2;
return `<line x1='${startX}' y1='${y}' x2='${startX + width}' y2='${y}' stroke='#CCCCCC' stroke-width='1'/>`;
}
/**
* Generate complete writing worksheet
* @param {Object} params
* @param {string} params.word - Word to trace (if tracing letters)
* @param {number[]} params.numbers - Numbers to trace (if tracing numbers)
* @param {boolean} params.isNumbers - Whether to trace numbers
* @param {number} params.age - Child's age (affects complexity)
*/
export function generateWritingWorksheet({ word, numbers, isNumbers, age }) {
const content = isNumbers ? numbers.join('') : word.toUpperCase();
const title = isNumbers ? 'Trace the Numbers' : `Trace: ${word.toUpperCase()}`;
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>
<!-- Row 1: Full dotted characters -->
${generateBaseline(130)}
${generateCharacterRow(content, 50, 1, '#000000')}
<!-- Row 2: Lighter dotted characters -->
${generateBaseline(280)}
${generateCharacterRow(content, 200, 1, '#888888')}
<!-- Row 3: Very light guide -->
${generateBaseline(430)}
${generateCharacterRow(content, 350, 1, '#CCCCCC')}
<!-- Row 4: Empty practice line -->
${generateBaseline(495)}
</svg>`;
return {
title: title,
subtitle: isNumbers ? 'Practice writing your numbers!' : 'Practice writing letters!',
sections: [{
id: 'writing-main',
type: 'writing',
content_svg: svg
}]
};
}
export default { generateWritingWorksheet };

View File

@ -5,7 +5,14 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { GoogleGenerativeAI } from '@google/generative-ai'; import { GoogleGenerativeAI } from '@google/generative-ai';
import { getPromptForActivity } from './prompts.js';
// Import programmatic generators
import {
generateWritingWorksheet,
generateCountingWorksheet,
generateMazeWorksheet,
generateColoringPage
} from './generators/index.js';
dotenv.config(); dotenv.config();
@ -27,7 +34,6 @@ const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || '');
const GALLERY_FILE = path.join(__dirname, 'gallery.json'); const GALLERY_FILE = path.join(__dirname, 'gallery.json');
const MAX_GALLERY_SIZE = 6; const MAX_GALLERY_SIZE = 6;
// Load gallery from file
function loadGallery() { function loadGallery() {
try { try {
if (fs.existsSync(GALLERY_FILE)) { if (fs.existsSync(GALLERY_FILE)) {
@ -40,7 +46,6 @@ function loadGallery() {
return []; return [];
} }
// Save gallery to file
function saveGallery(gallery) { function saveGallery(gallery) {
try { try {
fs.writeFileSync(GALLERY_FILE, JSON.stringify(gallery, null, 2)); fs.writeFileSync(GALLERY_FILE, JSON.stringify(gallery, null, 2));
@ -49,31 +54,113 @@ function saveGallery(gallery) {
} }
} }
// Add item to gallery (keeps only recent 6)
function addToGallery(item) { function addToGallery(item) {
const gallery = loadGallery(); const gallery = loadGallery();
// Add new item at the beginning
gallery.unshift({ gallery.unshift({
...item, ...item,
id: Date.now(), id: Date.now(),
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
}); });
// Keep only the most recent 6
while (gallery.length > MAX_GALLERY_SIZE) { while (gallery.length > MAX_GALLERY_SIZE) {
gallery.pop(); gallery.pop();
} }
saveGallery(gallery); saveGallery(gallery);
return gallery; return gallery;
} }
// ============================================
// LLM PROMPTS - Now just returns parameters
// ============================================
const parameterPrompts = {
writing: (theme, age) => {
const randomSeed = Math.floor(Math.random() * 1000);
const wordLength = `${age} letters`;
const numberRange = age <= 4 ? '1-5' : '1-9';
const numberCount = age <= 4 ? '3-4' : '4-5';
return `
Create a children's tracing worksheet for theme "${theme}".
Age: ${age} years old
Random seed: ${randomSeed}
Return ONLY a JSON object with ONE of these options:
Option A - Trace a word: {"word": "CAT", "isNumbers": false}
Option B - Trace numbers: {"numbers": [1,2,3,4,5], "isNumbers": true}
Rules:
- For words: Pick a simple ${wordLength} word related to "${theme}" (UPPERCASE). Use seed ${randomSeed} to vary your choice.
- For numbers: Pick ${numberCount} numbers from ${numberRange}
- Randomly choose between words (50%) or numbers (50%)
Return ONLY valid JSON, no explanation.
`;
},
counting: (theme, age) => {
const randomSeed = Math.floor(Math.random() * 1000);
return `
Create a children's counting worksheet for theme "${theme}".
Random seed: ${randomSeed}
AVAILABLE SHAPES: star, heart, circle, apple, fish, flower, butterfly, ball, moon, cloud
CRITICAL: You MUST return JSON with BOTH "shape" AND "quantities":
{"shape": "star", "quantities": [3, 1, 5, 2, 7, 4, 8, 6]}
- "shape": Choose ONE shape that matches "${theme}". Use the random seed ${randomSeed} to vary your choice - don't always pick the same shape for this theme.
- "quantities": Array of exactly 8 different numbers (1-${age <= 4 ? 5 : 10})
Return ONLY valid JSON. No explanation.
`;
},
'find-way': (theme, age) => `
You are helping create a children's maze worksheet.
Theme: ${theme}
Age: ${age} years old
Return ONLY a JSON object:
{ "difficulty": 1 }
Rules:
- difficulty 1 = easy (4x4 grid) for age 3-4
- difficulty 2 = medium (5x5 grid) for age 5-6
- difficulty 3 = hard (6x6 grid) for age 7+
Return ONLY valid JSON, no explanation.
`,
coloring: (theme, age) => {
const randomSeed = Math.floor(Math.random() * 1000);
const simpleIllustrations = 'star, sun, flower, ball (circle)';
const complexIllustrations = 'unicorn, butterfly, rocket, house, cat, dog, fish';
const ageGuidance = age <= 4
? `Prefer SIMPLER illustrations (${simpleIllustrations}) for young children.`
: `You can choose from ALL illustrations including complex ones (${complexIllustrations}).`;
return `
You are helping create a children's coloring page.
Theme: ${theme}
Age: ${age} years old
Random seed: ${randomSeed}
Return ONLY a JSON object:
{ "illustration": "cat" }
Available illustrations: cat, dog, butterfly, fish, flower, star, rocket, sun, house, unicorn
${ageGuidance}
Pick the ONE illustration that best matches the theme. Use seed ${randomSeed} to vary your choice.
Return ONLY valid JSON, no explanation.
`;
}
};
// ============================================ // ============================================
// API ENDPOINTS // API ENDPOINTS
// ============================================ // ============================================
// Get recent gallery items
app.get('/api/gallery', (req, res) => { app.get('/api/gallery', (req, res) => {
try { try {
const gallery = loadGallery(); const gallery = loadGallery();
@ -86,14 +173,29 @@ app.get('/api/gallery', (req, res) => {
app.post('/api/surprise', async (req, res) => { app.post('/api/surprise', async (req, res) => {
try { try {
if (!process.env.GEMINI_API_KEY) { const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
return res.status(500).json({ error: 'Missing API Key' });
} // Add timestamp to force different responses
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" }); const timestamp = Date.now();
// Always randomize theme, age, AND activity type
const prompt = ` const prompt = `
Current timestamp: ${timestamp}
Generate ONE creative, fun, specific theme for a children's worksheet (e.g. "Space-Rex Coding", "Underwater Hamster Tea Party"). Generate ONE creative, fun, specific theme for a children's worksheet (e.g. "Space-Rex Coding", "Underwater Hamster Tea Party").
Pick a random age between 3 and 7. Pick a RANDOM age between 3 and 7.
Pick a random activity type from: coloring, numbers, writing, find-way, counting. Pick a RANDOM activity type with EQUAL PROBABILITY from these 4 options:
1. "coloring" - coloring pages
2. "writing" - tracing letters or numbers
3. "find-way" - maze puzzles
4. "counting" - counting exercises
IMPORTANT: Select EXACTLY one of these values for "section": coloring, writing, find-way, counting
ALL FOUR OPTIONS MUST HAVE EQUAL 25% CHANCE. Do not favor any particular activity type.
USE THE TIMESTAMP ${timestamp} as a random seed to ensure variation.
DO NOT always pick the same age or activity - vary your choices each time.
Return JSON ONLY: Return JSON ONLY:
{ {
@ -105,8 +207,18 @@ app.post('/api/surprise', async (req, res) => {
const result = await model.generateContent(prompt); const result = await model.generateContent(prompt);
const text = result.response.text(); const text = result.response.text();
console.log('Surprise LLM response:', text);
const jsonStr = text.replace(/```json/g, '').replace(/```/g, '').trim(); const jsonStr = text.replace(/```json/g, '').replace(/```/g, '').trim();
res.json(JSON.parse(jsonStr)); const data = JSON.parse(jsonStr);
console.log('Surprise parsed data:', data);
// Ensure sections is an array
res.json({
theme: data.theme,
age: data.age,
sections: [data.section]
});
} catch (error) { } catch (error) {
console.error('Surprise error:', error); console.error('Surprise error:', error);
res.status(500).json({ error: 'Failed' }); res.status(500).json({ error: 'Failed' });
@ -116,43 +228,84 @@ app.post('/api/surprise', async (req, res) => {
app.post('/api/generate', async (req, res) => { app.post('/api/generate', async (req, res) => {
try { try {
const { theme, age, sections } = req.body; const { theme, age, sections } = req.body;
console.log('Received generation request:', { theme, age, sections }); const activityType = sections[0] || 'writing';
console.log('Generating:', { theme, age, activityType });
if (!process.env.GEMINI_API_KEY) { if (!process.env.GEMINI_API_KEY) {
console.warn('Missing GEMINI_API_KEY'); return res.status(500).json({ error: 'Missing API Key' });
return res.status(500).json({ error: 'Server configuration error: Missing API Key' });
} }
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" }); // Step 1: Get parameters from LLM (fast, small response)
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
const paramPrompt = parameterPrompts[activityType] || parameterPrompts.writing;
// Get the activity-specific prompt for the first section console.log('Getting parameters from LLM...');
const activityType = sections[0] || 'coloring'; const result = await model.generateContent(paramPrompt(theme, age));
const prompt = getPromptForActivity(activityType, theme, age); const text = result.response.text();
console.log(`Using specialized prompt for activity: ${activityType}`);
const result = await model.generateContent(prompt);
const response = await result.response;
const text = response.text();
// Cleanup markdown code blocks if present
const jsonStr = text.replace(/```json/g, '').replace(/```/g, '').trim(); const jsonStr = text.replace(/```json/g, '').replace(/```/g, '').trim();
const data = JSON.parse(jsonStr); const params = JSON.parse(jsonStr);
if (!data.title || !data.sections || !Array.isArray(data.sections)) { console.log('LLM returned params:', params);
throw new Error('Invalid generation format');
// Step 2: Generate worksheet using programmatic generator
let worksheet;
switch (activityType) {
case 'writing':
worksheet = generateWritingWorksheet({
word: params.word || 'CAT',
numbers: params.numbers || [1, 2, 3, 4, 5],
isNumbers: params.isNumbers || false,
age
});
break;
case 'counting':
worksheet = generateCountingWorksheet({
theme,
shape: params.shape,
quantities: params.quantities || [2, 4, 5, 7, 3, 6, 8, 1],
age
});
break;
case 'find-way':
worksheet = generateMazeWorksheet({
theme,
difficulty: params.difficulty || 1,
age
});
break;
case 'coloring':
// Coloring uses direct LLM SVG generation, not programmatic
console.log('Using LLM for full coloring SVG generation...');
const { getPromptForActivity: getPrompt } = await import('./prompts.js');
const coloringPrompt = getPrompt('coloring', theme, age);
const coloringResult = await model.generateContent(coloringPrompt);
const coloringText = coloringResult.response.text();
const coloringJsonStr = coloringText.replace(/```json/g, '').replace(/```/g, '').trim();
worksheet = JSON.parse(coloringJsonStr);
break;
default:
worksheet = generateWritingWorksheet({
word: 'FUN',
isNumbers: false,
age
});
} }
// Add metadata
worksheet.age = age;
worksheet.activityType = activityType;
// Save to gallery // Save to gallery
const galleryItem = { addToGallery(worksheet);
...data, console.log('Generated worksheet:', worksheet.title);
age,
activityType
};
addToGallery(galleryItem);
console.log('Added to gallery, current size:', loadGallery().length);
res.json(data); res.json(worksheet);
} catch (error) { } catch (error) {
console.error('Generation error:', error); console.error('Generation error:', error);
res.status(500).json({ error: 'Failed to generate content' }); res.status(500).json({ error: 'Failed to generate content' });
@ -161,4 +314,5 @@ app.post('/api/generate', async (req, res) => {
app.listen(port, () => { app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`); console.log(`Server running at http://localhost:${port}`);
console.log('Using hybrid generation: LLM for parameters, code for SVGs');
}); });

View File

@ -43,37 +43,103 @@ CRITICAL: content_svg MUST be a string with single quotes in SVG attributes. Pat
export const activityPrompts = { export const activityPrompts = {
coloring: (theme, age) => ` coloring: (theme, age) => `
You are a professional illustrator of high-quality children's coloring books. Create a BEAUTIFUL, ENGAGING coloring page. You are a professional SVG illustrator creating REALISTIC, RECOGNIZABLE children's coloring pages.
Theme: ${theme} Theme: ${theme}
Age: ${age} years old Age: ${age} years old
YOUR MISSION: Create ONE stunning coloring page featuring the theme in a way that makes children excited to color it. 🎯 YOUR GOAL: Create a coloring page with ONE clear subject that a 3-year-old can immediately identify as "${theme}".
STRICT ACTIVITY RESTRICTION THIS IS A COLORING PAGE ONLY: 🚨 ABSOLUTE SVG REQUIREMENTS (CRITICAL - NON-NEGOTIABLE):
- DO NOT include any numbers, digits, or numerals of any kind - EVERY single path MUST have fill="none"
- DO NOT include any letters, words, text, or alphabet characters - EVERY single path MUST have stroke="black" or stroke="#000000"
- DO NOT include any counting exercises or number recognition - Use stroke-width="2" or "3" for all outlines
- DO NOT include any mazes, paths to follow, or puzzles - NO filled shapes allowed - children need empty white areas to color
- DO NOT include any tracing lines, dotted lines, or writing practice - Example: <path d="M 100 100 L 200 100" fill="none" stroke="black" stroke-width="2"/>
- DO NOT include any educational exercises ONLY pure illustration for coloring
- The ONLY activity is coloring inside the outlined shapes
COLORING PAGE SPECIFIC RULES: 📐 STEP-BY-STEP SVG CONSTRUCTION METHOD:
- Create ONE main cute subject (or 2-3 closely related elements) as the centerpiece
- Design 8-20 large, clearly defined CLOSED areas perfect for coloring
- Each colorable region should be satisfyingly large kids love filling big spaces
- Add cute details that enhance the theme: accessories, background elements, decorations
- Characters should have friendly expressions: big round eyes, gentle smiles
- Include a simple ground line or setting context (grass, clouds, water) if appropriate
- Balance white space not too crowded, not too empty
- Make outlines bold and confident so young hands can color inside easily
COMPOSITION TIPS: For ANIMALS (cat, dog, panda, unicorn, etc.):
- Center the main subject or use rule-of-thirds for dynamic layouts 1. HEAD: Draw a large circle or oval for head (100-150px diameter), centered around x=250, y=200
- Vary the sizes of colorable regions for visual interest 2. EARS: Add 2 rounded ears on top of head (circles, triangles, or ovals)
- Add 2-4 small decorative elements (stars, hearts, flowers) around the main subject 3. EYES: 2 circles (30-40px diameter) positioned symmetrically
- Ensure all shapes are CLOSED and ready to be filled with color 4. NOSE: Small circle or triangle in center below eyes
5. MOUTH: Simple curved line (smile) below nose
6. BODY: Large oval or rounded rectangle below head (150-200px wide, 180-220px tall)
7. LEGS: 4 rounded rectangles or ovals extending down from body
8. TAIL: Curved path on one side
9. DETAILS: Add characteristic features (whiskers, spots, horn, etc.)
For OBJECTS (car, rocket, house, etc.):
1. MAIN BODY: Start with basic shape (rectangle for car/house, elongated triangle for rocket)
2. MAJOR COMPONENTS: Add 2-4 large recognizable parts (wheels, windows, door, roof)
3. DETAILS: Add smaller features that make it identifiable
For PLANTS (flower, tree, etc.):
1. CENTER/TRUNK: Main central element
2. PETALS/LEAVES: 5-8 symmetrical rounded shapes around center
3. STEM: Vertical line or path
4. BASE: Ground line or pot
🎨 SPECIFIC THEME EXAMPLES:
UNICORN:
- Circle head (cx="250" cy="180", r="70")
- 2 triangle ears
- HORN: Triangle pointing up from forehead (30-40px tall)
- 2 circle eyes
- Small nose circle
- Curved smile
- Oval body below head
- 4 rounded rectangle legs
- Flowing curved tail on right side
- Mane: 3-4 curved strokes on neck
PANDA:
- Large circle head (r="80")
- 2 small circle ears on top
- 2 LARGE circle eye patches (r="25", OUTLINED not filled)
- 2 smaller circle eyes inside patches
- Small circle nose
- Smile below nose
- Large oval body
- 4 thick rounded legs
- Optional: bamboo stick to the side (2 parallel lines)
CAT:
- Circle head (r="60")
- 2 triangle ears pointing up
- 2 circle eyes
- Small triangle nose
- Whiskers (3 lines each side)
- Curved smile
- Oval body
- 4 legs
- Curved S-shaped tail
FLOWER:
- Center circle (r="40")
- 6-8 rounded petal paths around center (use ellipses or bezier curves)
- Straight stem line down from center
- 2 leaf shapes on stem (ellipses at angle)
- Simple ground line
🔍 QUALITY CHECKLIST - YOUR SVG MUST:
Have 8-15 large closed shapes ready for coloring
Be IMMEDIATELY recognizable as "${theme}"
Use ONLY stroke outlines (fill="none" on ALL paths)
Have bold stroke-width="2" or "3"
Be 500x500 viewBox with subject centered
Include characteristic features (horn for unicorn, eye patches for panda, etc.)
Have simple, rounded, child-friendly shapes
Look like it came from a store-bought coloring book
AVOID:
- Abstract or geometric art
- Filled/colored areas
- Tiny details (too hard for kids)
- Complex overlapping shapes
- Unrecognizable forms
${baseRules} ${baseRules}
${ageRules(age)} ${ageRules(age)}

View File

@ -15,7 +15,6 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
// Sync state when a sheet is restored // Sync state when a sheet is restored
React.useEffect(() => { React.useEffect(() => {
if (generatedSheet) { if (generatedSheet) {
setTheme(generatedSheet.title || '');
if (generatedSheet.age) setAge(generatedSheet.age); if (generatedSheet.age) setAge(generatedSheet.age);
// If sections exist, restore the first one's type as selected // If sections exist, restore the first one's type as selected
if (generatedSheet.sections && generatedSheet.sections.length > 0) { if (generatedSheet.sections && generatedSheet.sections.length > 0) {
@ -68,24 +67,32 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
// since we don't have a separate loading state, we can reuse isGenerating logic or add one. // since we don't have a separate loading state, we can reuse isGenerating logic or add one.
// For now, we'll just set the values when they arrive. // For now, we'll just set the values when they arrive.
const res = await fetch('/api/surprise', { method: 'POST' }); const res = await fetch('/api/surprise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error('Surprise failed'); if (!res.ok) throw new Error('Surprise failed');
const idea = await res.json(); const idea = await res.json();
console.log('Surprise response from backend:', idea);
// Validate section ID exists in our map, else fallback // Validate section ID exists in our map, else fallback
const validSection = sections.find(s => s.id === idea.section) ? idea.section : 'coloring'; const validSections = ['coloring', 'writing', 'find-way', 'counting'];
const validSection = validSections.includes(idea.sections[0]) ? idea.sections[0] : 'coloring';
console.log('Section from backend:', idea.sections[0], '-> Valid section:', validSection);
setTheme(idea.theme); setTheme(idea.theme);
setAge(idea.age); setAge(idea.age);
setSelectedSection(validSection); setSelectedSection(validSection);
// Auto-generate for surprise // Auto-generate for surprise - use validSection directly, not state
if (onGenerate) { if (onGenerate) {
onGenerate({ onGenerate({
theme: idea.theme, theme: idea.theme,
age: idea.age, age: idea.age,
sections: [validSection] sections: [validSection] // Use validSection, not selectedSection from state
}); });
} }
} catch (err) { } catch (err) {
@ -263,7 +270,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
<div className="relative hidden lg:block"> <div className="relative hidden lg:block">
<div className="absolute inset-0 bg-gradient-to-tr from-primary-100 to-indigo-50 rounded-full blur-3xl opacity-60 transform -translate-y-12 translate-x-12" /> <div className="absolute inset-0 bg-gradient-to-tr from-primary-100 to-indigo-50 rounded-full blur-3xl opacity-60 transform -translate-y-12 translate-x-12" />
<div id="sheet-wrapper" className="relative bg-white rounded-[2rem] shadow-2xl shadow-slate-200/50 border-8 border-white p-4 rotate-3 hover:rotate-0 transition-transform duration-500 ease-out z-10"> <div id="sheet-wrapper" className="relative bg-white rounded-[2rem] shadow-2xl shadow-slate-200/50 border-8 border-white p-4 z-10">
<div id="sheet-preview" className={`aspect-[1/1.4] bg-white rounded-xl overflow-hidden relative border flex flex-col ${generatedSheet?.sections?.length === 1 ? 'p-4' : 'p-6'}`}> <div id="sheet-preview" className={`aspect-[1/1.4] bg-white rounded-xl overflow-hidden relative border flex flex-col ${generatedSheet?.sections?.length === 1 ? 'p-4' : 'p-6'}`}>
<AnimatePresence> <AnimatePresence>
{isGenerating && ( {isGenerating && (
@ -280,12 +287,12 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
className="h-full bg-gradient-to-r from-violet-500 via-primary-500 to-indigo-500 rounded-full" className="h-full bg-gradient-to-r from-violet-500 via-primary-500 to-indigo-500 rounded-full"
initial={{ width: "0%" }} initial={{ width: "0%" }}
animate={{ width: "95%" }} animate={{ width: "95%" }}
transition={{ duration: 30, ease: "easeOut" }} transition={{ duration: selectedSection === 'coloring' ? 15 : 5, ease: "easeOut" }}
/> />
</div> </div>
<div className="flex justify-between mt-2 text-xs text-slate-400"> <div className="flex justify-between mt-2 text-xs text-slate-400">
<span>Generating...</span> <span>Generating...</span>
<span>~30 seconds</span> <span>~{selectedSection === 'coloring' ? '15' : '5'} seconds</span>
</div> </div>
</div> </div>
@ -308,7 +315,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
key="msg1" key="msg1"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 1, 0] }} animate={{ opacity: [0, 1, 1, 0] }}
transition={{ duration: 7, times: [0, 0.1, 0.9, 1], delay: 0 }} transition={{ duration: 2, times: [0, 0.1, 0.9, 1], delay: 0 }}
className="text-sm text-slate-500 absolute left-0 right-0 text-center" className="text-sm text-slate-500 absolute left-0 right-0 text-center"
> >
Adding some magic... Adding some magic...
@ -317,7 +324,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
key="msg2" key="msg2"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 1, 0] }} animate={{ opacity: [0, 1, 1, 0] }}
transition={{ duration: 7, times: [0, 0.1, 0.9, 1], delay: 7 }} transition={{ duration: 2, times: [0, 0.1, 0.9, 1], delay: 2 }}
className="text-sm text-slate-500 absolute left-0 right-0 text-center" className="text-sm text-slate-500 absolute left-0 right-0 text-center"
> >
🎨 Drawing cute illustrations... 🎨 Drawing cute illustrations...
@ -326,7 +333,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
key="msg3" key="msg3"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 1, 0] }} animate={{ opacity: [0, 1, 1, 0] }}
transition={{ duration: 7, times: [0, 0.1, 0.9, 1], delay: 14 }} transition={{ duration: 2, times: [0, 0.1, 0.9, 1], delay: 4 }}
className="text-sm text-slate-500 absolute left-0 right-0 text-center" className="text-sm text-slate-500 absolute left-0 right-0 text-center"
> >
📝 Almost ready... 📝 Almost ready...
@ -335,7 +342,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
key="msg4" key="msg4"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 21 }} transition={{ delay: 4 }}
className="text-sm text-slate-500 absolute left-0 right-0 text-center" className="text-sm text-slate-500 absolute left-0 right-0 text-center"
> >
🚀 Finishing up... 🚀 Finishing up...
@ -347,7 +354,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
<div className="flex-1 flex flex-col items-center text-center bg-white h-full"> <div className="flex-1 flex flex-col items-center text-center bg-white h-full">
<h3 className="font-outfit font-bold text-2xl mb-1 text-slate-800">{previewTheme}</h3> <h3 className="font-outfit font-bold text-2xl mb-1 text-slate-800">{previewTheme}</h3>
<p className="text-slate-500 uppercase tracking-widest text-[10px] font-bold mb-2">{previewSubtitle}</p> <p className="text-slate-500 uppercase tracking-widest text-[10px] font-bold mb-2 text-center w-full">{previewSubtitle}</p>
{generatedSheet ? ( {generatedSheet ? (
<div className={`w-full flex-1 flex flex-col gap-2 ${generatedSheet.sections?.length === 1 ? 'justify-center' : ''}`}> <div className={`w-full flex-1 flex flex-col gap-2 ${generatedSheet.sections?.length === 1 ? 'justify-center' : ''}`}>