diff --git a/server/gallery.json b/server/gallery.json index 4108b5b7..16a7d3c1 100644 --- a/server/gallery.json +++ b/server/gallery.json @@ -1,77 +1,92 @@ [ { - "title": "Rocket to the Planet", - "subtitle": "Help the rocket find its way through the asteroids to the planet!", - "sections": [ - { - "id": "find-the-way-main", - "type": "find-the-way", - "content_svg": "" - } - ], - "age": 7, - "activityType": "find-way", - "id": 1770334258054, - "createdAt": "2026-02-05T23:30:58.054Z" - }, - { - "title": "Unicorn Adventure Maze", - "subtitle": "Help the little unicorn find the magical star!", - "sections": [ - { - "id": "find-the-way-main", - "type": "find-the-way", - "content_svg": "" - } - ], - "age": 5, - "activityType": "find-way", - "id": 1770334088798, - "createdAt": "2026-02-05T23:28:08.798Z" - }, - { - "title": "Space Number Blast Off!", - "subtitle": "Color the number and see how many space objects match!", - "sections": [ - { - "id": "numbers-main", - "type": "numbers", - "content_svg": "" - } - ], - "age": 5, - "activityType": "numbers", - "id": 1770333755198, - "createdAt": "2026-02-05T23:22:35.198Z" - }, - { - "title": "Space Word Writing", - "subtitle": "Trace the letters to write the word SUN.", - "sections": [ - { - "id": "writing-main", - "type": "writing", - "content_svg": "Trace the Word: SUN" - } - ], - "age": 5, - "activityType": "writing", - "id": 1770333626839, - "createdAt": "2026-02-05T23:20:26.839Z" - }, - { - "title": "Octopus's Garden", - "subtitle": "Color this friendly octopus and its underwater friends!", + "title": "Pirate Parrot's Treasure!", + "subtitle": "Color the parrot and his hidden treasure!", "sections": [ { "id": "coloring-main", "type": "coloring", - "content_svg": "" + "content_svg": "" } ], "age": 5, "activityType": "coloring", - "id": 1770333549787, - "createdAt": "2026-02-05T23:19:09.787Z" + "id": 1770458040593, + "createdAt": "2026-02-07T09:54:00.593Z" + }, + { + "title": "Parrot's Treasure!", + "subtitle": "Color the parrot and his treasure hunt!", + "sections": [ + { + "id": "coloring-main", + "type": "coloring", + "content_svg": "" + } + ], + "age": 5, + "activityType": "coloring", + "id": 1770458028752, + "createdAt": "2026-02-07T09:53:48.752Z" + }, + { + "title": "Pirate Parrot's Treasure!", + "subtitle": "Color the parrot and his treasure hunt!", + "sections": [ + { + "id": "coloring-main", + "type": "coloring", + "content_svg": "" + } + ], + "age": 5, + "activityType": "coloring", + "id": 1770457963325, + "createdAt": "2026-02-07T09:52:43.325Z" + }, + { + "title": "Count the Stars!", + "subtitle": "Count each group and write the number", + "sections": [ + { + "id": "counting-main", + "type": "counting", + "content_svg": "\n \n 1.=2.=3.=4.=5.=6.=7.=8.=\n" + } + ], + "age": 5, + "activityType": "counting", + "id": 1770457944132, + "createdAt": "2026-02-07T09:52:24.132Z" + }, + { + "title": "Count the Fishs!", + "subtitle": "Count each group and write the number", + "sections": [ + { + "id": "counting-main", + "type": "counting", + "content_svg": "\n \n 1.=2.=3.=4.=5.=6.=7.=8.=\n" + } + ], + "age": 5, + "activityType": "counting", + "id": 1770457941396, + "createdAt": "2026-02-07T09:52:21.396Z" + }, + { + "title": "Pirate Unicorn Treasure Hunt Maze Adventure", + "subtitle": "Find the path from start to finish!", + "sections": [ + { + "id": "find-the-way-main", + "type": "find-the-way", + "content_svg": "\n \n \n \n \n \n \n \n \n \n \n START\n \n \n \n GOAL\n \n" + } + ], + "age": 5, + "activityType": "find-way", + "id": 1770457938911, + "createdAt": "2026-02-07T09:52:18.911Z" } ] \ No newline at end of file diff --git a/server/generators/coloring.js b/server/generators/coloring.js new file mode 100644 index 00000000..f09c0729 --- /dev/null +++ b/server/generators/coloring.js @@ -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: ` + + + + + + + + + + + + + + `, + + dog: ` + + + + + + + + + + `, + + butterfly: ` + + + + + + + + + + `, + + fish: ` + + + + + + + + `, + + flower: ` + + + + + + + + + + `, + + star: ` + + + + + `, + + rocket: ` + + + + + + + `, + + sun: ` + + + + + + + + + + + + + `, + + house: ` + + + + + + + + + + + `, + + unicorn: ` + + + + + + + + + + + + + + ` +}; + +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 = ` + + ${illustrationSvg} +`; + + 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 }; diff --git a/server/generators/counting.js b/server/generators/counting.js new file mode 100644 index 00000000..ab5d7bab --- /dev/null +++ b/server/generators/counting.js @@ -0,0 +1,204 @@ +/** + * Counting Worksheet Generator + * Generates randomized counting exercises with themed objects + */ + +// Simple themed object SVG shapes +const themedObjects = { + star: ``, + heart: ``, + circle: ``, + apple: ``, + fish: ``, + flower: ``, + butterfly: ``, + ball: ``, + moon: ``, + cloud: `` +}; + +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 += `${objectSvg}`; + } + } 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 += `${objectSvg}`; + } + + // Bottom row + startX = x + spacing + (topRowCount - bottomRowCount) * (objectSize + spacing) / 2; + for (let i = 0; i < bottomRowCount; i++) { + svg += `${objectSvg}`; + } + } + + return svg; +} + +/** + * Generate an empty answer box + */ +function generateAnswerBox(x, y, size = 40) { + return ``; +} + +/** + * 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 += ``; + } + + // Row number indicator - vertically centered in row + objectRows += `${i + 1}.`; + + // Objects to count - positioned in middle of row + objectRows += generateObjectGroup(objectType, quantity, 50, rowY + 20, objectAreaWidth); + + // Equals sign + objectRows += `=`; + + // Answer box + objectRows += generateAnswerBox(answerBoxX, rowY + 10, answerBoxSize); + } + + const svg = ` + + ${objectRows} +`; + + 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 }; diff --git a/server/generators/index.js b/server/generators/index.js new file mode 100644 index 00000000..25402d7d --- /dev/null +++ b/server/generators/index.js @@ -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'; diff --git a/server/generators/maze.js b/server/generators/maze.js new file mode 100644 index 00000000..1da3ffa7 --- /dev/null +++ b/server/generators/maze.js @@ -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 += ``; + } + } + } + + 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 + + + + GOAL + `; +} + +/** + * 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 = ` + + + + + ${mazeGridToSVG(grid, startX, startY, cellSize)} + + + ${generateMarkers(mazeStartX, mazeStartY, mazeEndX, mazeEndY, theme, cellSize)} +`; + + 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 }; diff --git a/server/generators/writing.js b/server/generators/writing.js new file mode 100644 index 00000000..36251a5c --- /dev/null +++ b/server/generators/writing.js @@ -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 ``; +} + +/** + * 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 ``; +} + +/** + * 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 = ` + + ${generateBaseline(130)} + ${generateCharacterRow(content, 50, 1, '#000000')} + + + ${generateBaseline(280)} + ${generateCharacterRow(content, 200, 1, '#888888')} + + + ${generateBaseline(430)} + ${generateCharacterRow(content, 350, 1, '#CCCCCC')} + + + ${generateBaseline(495)} +`; + + return { + title: title, + subtitle: isNumbers ? 'Practice writing your numbers!' : 'Practice writing letters!', + sections: [{ + id: 'writing-main', + type: 'writing', + content_svg: svg + }] + }; +} + +export default { generateWritingWorksheet }; diff --git a/server/index.js b/server/index.js index f9f1837c..40314526 100644 --- a/server/index.js +++ b/server/index.js @@ -5,7 +5,14 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; 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(); @@ -27,7 +34,6 @@ const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || ''); const GALLERY_FILE = path.join(__dirname, 'gallery.json'); const MAX_GALLERY_SIZE = 6; -// Load gallery from file function loadGallery() { try { if (fs.existsSync(GALLERY_FILE)) { @@ -40,7 +46,6 @@ function loadGallery() { return []; } -// Save gallery to file function saveGallery(gallery) { try { 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) { const gallery = loadGallery(); - - // Add new item at the beginning gallery.unshift({ ...item, id: Date.now(), createdAt: new Date().toISOString() }); - - // Keep only the most recent 6 while (gallery.length > MAX_GALLERY_SIZE) { gallery.pop(); } - saveGallery(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 // ============================================ -// Get recent gallery items app.get('/api/gallery', (req, res) => { try { const gallery = loadGallery(); @@ -86,14 +173,29 @@ app.get('/api/gallery', (req, res) => { app.post('/api/surprise', async (req, res) => { try { - if (!process.env.GEMINI_API_KEY) { - return res.status(500).json({ error: 'Missing API Key' }); - } - const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" }); + const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" }); + + // Add timestamp to force different responses + const timestamp = Date.now(); + + // Always randomize theme, age, AND activity type 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"). - Pick a random age between 3 and 7. - Pick a random activity type from: coloring, numbers, writing, find-way, counting. + Pick a RANDOM age between 3 and 7. + 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: { @@ -105,8 +207,18 @@ app.post('/api/surprise', async (req, res) => { const result = await model.generateContent(prompt); const text = result.response.text(); + console.log('Surprise LLM response:', text); 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) { console.error('Surprise error:', error); res.status(500).json({ error: 'Failed' }); @@ -116,43 +228,84 @@ app.post('/api/surprise', async (req, res) => { app.post('/api/generate', async (req, res) => { try { 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) { - console.warn('Missing GEMINI_API_KEY'); - return res.status(500).json({ error: 'Server configuration error: Missing API Key' }); + return res.status(500).json({ 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 - const activityType = sections[0] || 'coloring'; - const prompt = getPromptForActivity(activityType, theme, age); - - 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 + console.log('Getting parameters from LLM...'); + const result = await model.generateContent(paramPrompt(theme, age)); + const text = result.response.text(); 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)) { - throw new Error('Invalid generation format'); + console.log('LLM returned params:', params); + + // 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 - const galleryItem = { - ...data, - age, - activityType - }; - addToGallery(galleryItem); - console.log('Added to gallery, current size:', loadGallery().length); + addToGallery(worksheet); + console.log('Generated worksheet:', worksheet.title); - res.json(data); + res.json(worksheet); } catch (error) { console.error('Generation error:', error); res.status(500).json({ error: 'Failed to generate content' }); @@ -161,4 +314,5 @@ app.post('/api/generate', async (req, res) => { app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); + console.log('Using hybrid generation: LLM for parameters, code for SVGs'); }); diff --git a/server/prompts.js b/server/prompts.js index 3ed7b8c0..4c81d146 100644 --- a/server/prompts.js +++ b/server/prompts.js @@ -43,37 +43,103 @@ CRITICAL: content_svg MUST be a string with single quotes in SVG attributes. Pat export const activityPrompts = { 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} 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: -- DO NOT include any numbers, digits, or numerals of any kind -- DO NOT include any letters, words, text, or alphabet characters -- DO NOT include any counting exercises or number recognition -- DO NOT include any mazes, paths to follow, or puzzles -- DO NOT include any tracing lines, dotted lines, or writing practice -- DO NOT include any educational exercises — ONLY pure illustration for coloring -- The ONLY activity is coloring inside the outlined shapes +🚨 ABSOLUTE SVG REQUIREMENTS (CRITICAL - NON-NEGOTIABLE): +- EVERY single path MUST have fill="none" +- EVERY single path MUST have stroke="black" or stroke="#000000" +- Use stroke-width="2" or "3" for all outlines +- NO filled shapes allowed - children need empty white areas to color +- Example: -COLORING PAGE SPECIFIC RULES: -- 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 +📐 STEP-BY-STEP SVG CONSTRUCTION METHOD: -COMPOSITION TIPS: -- Center the main subject or use rule-of-thirds for dynamic layouts -- Vary the sizes of colorable regions for visual interest -- Add 2-4 small decorative elements (stars, hearts, flowers) around the main subject -- Ensure all shapes are CLOSED and ready to be filled with color +For ANIMALS (cat, dog, panda, unicorn, etc.): +1. HEAD: Draw a large circle or oval for head (100-150px diameter), centered around x=250, y=200 +2. EARS: Add 2 rounded ears on top of head (circles, triangles, or ovals) +3. EYES: 2 circles (30-40px diameter) positioned symmetrically +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} ${ageRules(age)} diff --git a/src/components/Hero.jsx b/src/components/Hero.jsx index 5ff85504..c5aa1b31 100644 --- a/src/components/Hero.jsx +++ b/src/components/Hero.jsx @@ -15,7 +15,6 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => { // Sync state when a sheet is restored React.useEffect(() => { if (generatedSheet) { - setTheme(generatedSheet.title || ''); if (generatedSheet.age) setAge(generatedSheet.age); // If sections exist, restore the first one's type as selected 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. // 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'); const idea = await res.json(); + console.log('Surprise response from backend:', idea); + // 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); setAge(idea.age); setSelectedSection(validSection); - // Auto-generate for surprise + // Auto-generate for surprise - use validSection directly, not state if (onGenerate) { onGenerate({ theme: idea.theme, age: idea.age, - sections: [validSection] + sections: [validSection] // Use validSection, not selectedSection from state }); } } catch (err) { @@ -263,7 +270,7 @@ export const Hero = ({ onGenerate, generatedSheet, 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" initial={{ width: "0%" }} animate={{ width: "95%" }} - transition={{ duration: 30, ease: "easeOut" }} + transition={{ duration: selectedSection === 'coloring' ? 15 : 5, ease: "easeOut" }} />
Generating... - ~30 seconds + ~{selectedSection === 'coloring' ? '15' : '5'} seconds
@@ -308,7 +315,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => { key="msg1" initial={{ opacity: 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" > ✨ Adding some magic... @@ -317,7 +324,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => { key="msg2" initial={{ opacity: 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" > 🎨 Drawing cute illustrations... @@ -326,7 +333,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => { key="msg3" initial={{ opacity: 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" > 📝 Almost ready... @@ -335,7 +342,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => { key="msg4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} - transition={{ delay: 21 }} + transition={{ delay: 4 }} className="text-sm text-slate-500 absolute left-0 right-0 text-center" > 🚀 Finishing up... @@ -347,7 +354,7 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {

{previewTheme}

-

{previewSubtitle}

+

{previewSubtitle}

{generatedSheet ? (