From 667d5d83cc41f62fc71c39684856310f962aea44 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:08:42 +0000 Subject: [PATCH 01/29] Add creator role to the user Role union --- src/lib/types.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index 1fd86ac..93f856f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,5 @@ -export type Role = "admin" | "learner"; +// creator = learner permissions + the ability to create AI-generated lessons. +export type Role = "admin" | "learner" | "creator"; export type Status = "pending" | "active" | "suspended"; export interface User { @@ -51,8 +52,4 @@ export function toPublicUser(u: User): PublicUser { level: u.level, status: u.status, email_verified: !!u.email_verified, - display_name: u.display_name ?? null, - avatar: u.avatar ?? null, - created_at: u.created_at, - }; -} + display_name: u.di \ No newline at end of file -- 2.34.1 From d2fe11ea6e72b98c6d99d1045154618b9aab0a4f Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:09:05 +0000 Subject: [PATCH 02/29] Add created_lessons table for AI-generated lessons --- src/lib/db.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/lib/db.ts b/src/lib/db.ts index 6047425..6cb07c2 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -133,16 +133,9 @@ function migrate(db: Database.Database) { CREATE INDEX IF NOT EXISTS idx_drawings_user ON drawings(user_id); CREATE INDEX IF NOT EXISTS idx_drawings_progress ON drawings(user_id, level, sublevel, status); CREATE INDEX IF NOT EXISTS idx_completions_user ON completions(user_id); - `); - // Profile fields (added later — safe for existing databases) - addColumn(db, "users", "display_name", "TEXT"); - addColumn(db, "users", "avatar", "TEXT"); -} - -export function getDb(): Database.Database { - if (!global.__drawitDb) { - global.__drawitDb = createConnection(); - } - return global.__drawitDb; -} + -- AI "Create" feature: a Creator types a subject; ComfyUI makes line art; we vectorize it into a lesson. + CREATE TABLE IF NOT EXISTS created_lessons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, -- null once promoted to global + level \ No newline at end of file -- 2.34.1 From fb55c3b7882a199b112af4ab3b8fed80b0118cdd Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:37 +0000 Subject: [PATCH 03/29] Add subject moderation: sanitize, allowlist, blocklist, review queue --- src/lib/moderation.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/lib/moderation.ts diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts new file mode 100644 index 0000000..7b2dce1 --- /dev/null +++ b/src/lib/moderation.ts @@ -0,0 +1,64 @@ +/** + * Subject moderation for the "Create" feature. This is a children's app and the subject text feeds an + * image generator, so we are deliberately conservative: sanitize → allowlist (auto-approve) → + * blocklist (reject) → otherwise hold for admin review. + */ + +export type ModerationDecision = + | { decision: "allowlist"; subject: string } + | { decision: "review"; subject: string } + | { decision: "blocked"; subject: string; reason: string }; + +/** Clean raw input to a single short, plain phrase. Returns null if nothing usable remains. */ +export function sanitizeSubject(raw: string): string | null { + if (typeof raw !== "string") return null; + const cleaned = raw + .replace(/[\r\n\t]+/g, " ") + .replace(/[^a-zA-Z\- ]+/g, "") // letters, spaces, hyphens only — strips digits/punctuation/prompt tokens + .replace(/\s+/g, " ") + .trim() + .slice(0, 30); + if (cleaned.length < 2) return null; + // Title-case for display ("zebra" -> "Zebra"). + return cleaned.replace(/\b\w/g, (c) => c.toUpperCase()); +} + +// Curated safe subjects (lowercase). Auto-approved. Extend freely. +const ALLOWLIST = new Set([ + // animals + "cat", "dog", "puppy", "kitten", "rabbit", "bunny", "fox", "bear", "panda", "lion", "tiger", + "elephant", "giraffe", "zebra", "monkey", "deer", "horse", "pony", "cow", "pig", "sheep", "goat", + "duck", "chicken", "owl", "bird", "penguin", "fish", "shark", "whale", "dolphin", "octopus", "crab", + "turtle", "frog", "snail", "bee", "butterfly", "ladybug", "snake", "dinosaur", "dragon", "unicorn", + "hedgehog", "squirrel", "mouse", "koala", "kangaroo", "crocodile", "leopard", "snail", "starfish", + // nature + "tree", "flower", "rose", "sunflower", "leaf", "mushroom", "cactus", "cloud", "sun", "moon", "star", + "rainbow", "mountain", "apple", "banana", "strawberry", "cherry", "grapes", "carrot", "pumpkin", + // objects / vehicles / fun + "house", "castle", "boat", "sailboat", "car", "truck", "train", "bus", "plane", "rocket", "robot", + "balloon", "kite", "umbrella", "cup", "mug", "hat", "shirt", "dress", "sock", "shoe", "bowtie", + "bow tie", "gift", "present", "ball", "football", "basketball", "trophy", "crown", "heart", "star", + "square", "triangle", "circle", "cake", "cupcake", "cookie", "ice cream", "lollipop", "snowman", + "teddy bear", "ghost", "pumpkin", "guitar", "drum", "book", "pencil", "key", "clock", "lamp", +]); + +// Obvious unsafe terms (substring match, after sanitize). Backstop, not exhaustive. +const BLOCKLIST: string[] = [ + "gun", "knife", "weapon", "sword", "blood", "gore", "kill", "dead", "death", "corpse", "drug", + "beer", "wine", "alcohol", "cigarette", "smoke", "nude", "naked", "sex", "sexy", "porn", "kiss", + "hate", "nazi", "racist", "suicide", "hang", "noose", "bomb", "grenade", "war", "shoot", "knive", +]; + +export function moderateSubject(raw: string): ModerationDecision { + const subject = sanitizeSubject(raw); + if (!subject) return { decision: "blocked", subject: "", reason: "empty" }; + const lower = subject.toLowerCase(); + + const bad = BLOCKLIST.find((w) => lower.includes(w)); + if (bad) return { decision: "blocked", subject, reason: `term "${bad}"` }; + + if (ALLOWLIST.has(lower)) return { decision: "allowlist", subject }; + + // Not obviously safe and not obviously bad → let an admin decide. + return { decision: "review", subject }; +} -- 2.34.1 From 99af05b8d041f39d7d2c9170ad9d66381967c4b0 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:50 +0000 Subject: [PATCH 04/29] Add ComfyUI client: queue prompt, poll, fetch line art --- src/lib/comfyui.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/lib/comfyui.ts diff --git a/src/lib/comfyui.ts b/src/lib/comfyui.ts new file mode 100644 index 0000000..2f0a5fe --- /dev/null +++ b/src/lib/comfyui.ts @@ -0,0 +1,90 @@ +import "server-only"; +import fs from "node:fs"; +import path from "node:path"; + +/** + * Minimal ComfyUI HTTP client. Self-hosted endpoint only (privacy-first): the subject text and the + * generated image never leave the operator's own machine. Disabled unless COMFYUI_URL is set. + * + * Flow: POST /prompt (workflow JSON) -> poll /history/{id} -> GET /view (PNG bytes). + */ + +const URL_BASE = (process.env.COMFYUI_URL || "").replace(/\/$/, ""); +const API_KEY = process.env.COMFYUI_API_KEY || ""; +const WORKFLOW_PATH = process.env.COMFYUI_WORKFLOW || "./comfyui/coloring-book.workflow_api.json"; +const TIMEOUT_MS = Number(process.env.COMFYUI_TIMEOUT_MS || 120000); +const PROMPT_NODE = process.env.COMFYUI_PROMPT_NODE || "6"; // positive CLIPTextEncode in the default workflow +const SEED_NODE = process.env.COMFYUI_SEED_NODE || "3"; // KSampler in the default workflow + +export function isCreateEnabled(): boolean { + return URL_BASE.length > 0; +} + +/** The fixed coloring-book prompt with the subject interpolated. */ +export function buildPrompt(subject: string): string { + return `A coloring book page of a ${subject}, clean black-and-white line art, bold crisp outlines, simple composition, large open areas to color, white background, no shading, no grayscale, no color, no gradients, no heavy background detail, printable page.`; +} + +function authHeaders(): Record { + return API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}; +} + +function loadWorkflow(): Record; class_type?: string }> { + const p = path.isAbsolute(WORKFLOW_PATH) ? WORKFLOW_PATH : path.join(process.cwd(), WORKFLOW_PATH); + if (!fs.existsSync(p)) throw new Error(`ComfyUI workflow not found at ${p} (set COMFYUI_WORKFLOW).`); + return JSON.parse(fs.readFileSync(p, "utf-8")); +} + +async function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Generate coloring-book line art for the given prompt. Returns the PNG bytes. + * Throws on timeout / ComfyUI errors (caller marks the lesson 'failed'). + */ +export async function generateLineArt(prompt: string): Promise { + if (!isCreateEnabled()) throw new Error("Create is disabled (COMFYUI_URL not set)."); + + const workflow = loadWorkflow(); + // Inject our prompt + a fresh seed into the configured nodes. + if (workflow[PROMPT_NODE]?.inputs) workflow[PROMPT_NODE].inputs.text = prompt; + if (workflow[SEED_NODE]?.inputs) workflow[SEED_NODE].inputs.seed = Math.floor(Math.random() * 1e15); + + const clientId = `drawit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // 1) Queue the workflow. + const queueRes = await fetch(`${URL_BASE}/prompt`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ prompt: workflow, client_id: clientId }), + }); + if (!queueRes.ok) throw new Error(`ComfyUI /prompt failed: ${queueRes.status} ${await queueRes.text()}`); + const queued = (await queueRes.json()) as { prompt_id?: string; error?: unknown }; + const promptId = queued.prompt_id; + if (!promptId) throw new Error(`ComfyUI did not return a prompt_id: ${JSON.stringify(queued)}`); + + // 2) Poll history until the outputs appear (or timeout). + const deadline = Date.now() + TIMEOUT_MS; + let image: { filename: string; subfolder: string; type: string } | undefined; + while (Date.now() < deadline) { + await sleep(1500); + const hRes = await fetch(`${URL_BASE}/history/${promptId}`, { headers: authHeaders() }); + if (!hRes.ok) continue; + const hist = (await hRes.json()) as Record }>; + const entry = hist[promptId]; + if (!entry?.outputs) continue; + for (const node of Object.values(entry.outputs)) { + const img = node.images?.[0]; + if (img) { image = img; break; } + } + if (image) break; + } + if (!image) throw new Error("ComfyUI timed out before producing an image."); + + // 3) Fetch the image bytes. + const viewUrl = `${URL_BASE}/view?filename=${encodeURIComponent(image.filename)}&subfolder=${encodeURIComponent(image.subfolder || "")}&type=${encodeURIComponent(image.type || "output")}`; + const vRes = await fetch(viewUrl, { headers: authHeaders() }); + if (!vRes.ok) throw new Error(`ComfyUI /view failed: ${vRes.status}`); + return Buffer.from(await vRes.arrayBuffer()); +} -- 2.34.1 From ddb536205051738653b274f0bc10858df6c0126e Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:51 +0000 Subject: [PATCH 05/29] Add potrace vectorizer: PNG to fitted SVG reveal steps --- src/lib/vectorize.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/lib/vectorize.ts diff --git a/src/lib/vectorize.ts b/src/lib/vectorize.ts new file mode 100644 index 0000000..5fce46b --- /dev/null +++ b/src/lib/vectorize.ts @@ -0,0 +1,76 @@ +import "server-only"; +import { trace } from "potrace"; + +/** + * Turn a raster coloring-book PNG (black lines on white) into SVG line art fitted to the lessons' + * 0 0 400 300 viewBox. potrace traces the dark pixels into filled paths; we recolor them with our ink + * and wrap with a transform so they sit in the standard lesson coordinate space. + * + * - `pngToTemplateSvg` returns one fitted (holes preserved via fill-rule) — used as the Color + * phase `baseSvg` and the final overlay. + * - `svgToRevealSteps` splits that path into chunks for the Trace phase's stroke-by-stroke reveal. + */ + +const STROKE = "#2b2440"; +const VW = 400; +const VH = 300; + +function traceToSvg(png: Buffer): Promise { + return new Promise((resolve, reject) => { + trace( + png, + { color: STROKE, background: "transparent", threshold: 170, turdSize: 80, optTolerance: 0.4 }, + (err: Error | null, svg: string) => (err ? reject(err) : resolve(svg)), + ); + }); +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +export async function pngToTemplateSvg(png: Buffer): Promise { + const svg = await traceToSvg(png); + + // Source dimensions (potrace emits width/height + viewBox on the ). + const wM = svg.match(/width="(\d+(?:\.\d+)?)"/); + const hM = svg.match(/height="(\d+(?:\.\d+)?)"/); + const srcW = wM ? parseFloat(wM[1]) : VW; + const srcH = hM ? parseFloat(hM[1]) : VH; + + // All path data, combined into one path (keeps fill-rule holes correct). + const ds = [...svg.matchAll(/\bd="([^"]+)"/g)].map((m) => m[1]); + if (ds.length === 0) throw new Error("Vectorize produced no paths (image may be blank)."); + const combined = ds.join(" "); + + // Fit srcW×srcH into 400×300, centered. + const s = Math.min(VW / srcW, VH / srcH); + const tx = round((VW - srcW * s) / 2); + const ty = round((VH - srcH * s) / 2); + const transform = `translate(${tx} ${ty}) scale(${round(s)})`; + + return ``; +} + +/** Split the template path into up to `maxSteps` chunks of subpaths for the trace reveal. */ +export function svgToRevealSteps(templateSvg: string, maxSteps = 6): string[][] { + const dM = templateSvg.match(/\bd="([^"]+)"/); + const tM = templateSvg.match(/transform="([^"]+)"/); + if (!dM) return [[templateSvg]]; + const transform = tM ? ` transform="${tM[1]}"` : ""; + + // Split into subpaths at each move command. + const subs = dM[1] + .split(/(?=[Mm])/) + .map((s) => s.trim()) + .filter(Boolean); + const elements = subs.map((d) => ``); + if (elements.length === 0) return [[templateSvg]]; + + // Chunk into steps so the reveal has a few stages. + const steps = Math.min(maxSteps, elements.length); + const per = Math.ceil(elements.length / steps); + const out: string[][] = []; + for (let i = 0; i < elements.length; i += per) out.push(elements.slice(i, i + per)); + return out; +} -- 2.34.1 From cb80cdfc3588a3c2f77587cf0fbbd1e52d7dab6b Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:51 +0000 Subject: [PATCH 06/29] Add created-lesson data layer, generation pipeline, lesson builder --- src/lib/createdLessons.ts | 188 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/lib/createdLessons.ts diff --git a/src/lib/createdLessons.ts b/src/lib/createdLessons.ts new file mode 100644 index 0000000..fc1ea59 --- /dev/null +++ b/src/lib/createdLessons.ts @@ -0,0 +1,188 @@ +import "server-only"; +import { getDb } from "./db"; +import { svgToRevealSteps } from "./vectorize"; +import type { Lesson } from "./curriculum"; + +/** + * Data + lifecycle for AI-created lessons. A row is generated asynchronously (ComfyUI), vectorized + * (potrace), and then rendered through the normal runners by building Lesson objects on the fly. + */ + +export type CreatedStatus = "pending" | "generating" | "needs_review" | "ready" | "failed" | "blocked"; + +export interface CreatedLessonRow { + id: number; + user_id: number | null; + level: string; + subject: string; + slug: string; + status: CreatedStatus; + template_svg: string | null; + image: string | null; + prompt: string; + moderation: string | null; + emoji: string; + error: string | null; + promoted: number; + created_at: string; + updated_at: string; +} + +function kebab(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +/** Insert a created-lesson row and return it (slug includes the row id for uniqueness). */ +export function createCreatedLesson(opts: { + userId: number; + level: string; + subject: string; + prompt: string; + moderation: string; + status: CreatedStatus; + emoji?: string; +}): CreatedLessonRow { + const db = getDb(); + const info = db + .prepare( + `INSERT INTO created_lessons (user_id, level, subject, slug, status, prompt, moderation, emoji) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(opts.userId, opts.level, opts.subject, `pending-${Date.now()}`, opts.status, opts.prompt, opts.moderation, opts.emoji || "🎨"); + const id = Number(info.lastInsertRowid); + const slug = `created-${id}-${kebab(opts.subject)}`; + db.prepare("UPDATE created_lessons SET slug = ? WHERE id = ?").run(slug, id); + return getCreatedById(id)!; +} + +export function getCreatedById(id: number): CreatedLessonRow | undefined { + return getDb().prepare("SELECT * FROM created_lessons WHERE id = ?").get(id) as CreatedLessonRow | undefined; +} + +/** Look up by the base slug (e.g. 'created-42-zebra'), ignoring any '-trace'/'-color' phase suffix. */ +export function getCreatedBySlug(baseSlug: string): CreatedLessonRow | undefined { + return getDb().prepare("SELECT * FROM created_lessons WHERE slug = ?").get(baseSlug) as CreatedLessonRow | undefined; +} + +export function listCreatedForUser(userId: number, level: string): CreatedLessonRow[] { + return getDb() + .prepare("SELECT * FROM created_lessons WHERE user_id = ? AND level = ? ORDER BY created_at DESC") + .all(userId, level) as CreatedLessonRow[]; +} + +/** Promoted (global) creations, visible to everyone on a level. */ +export function listPromoted(level: string): CreatedLessonRow[] { + return getDb() + .prepare("SELECT * FROM created_lessons WHERE promoted = 1 AND status = 'ready' AND level = ? ORDER BY created_at DESC") + .all(level) as CreatedLessonRow[]; +} + +/** Admin queue: things awaiting review. */ +export function listNeedsReview(): CreatedLessonRow[] { + return getDb() + .prepare("SELECT * FROM created_lessons WHERE status = 'needs_review' ORDER BY created_at") + .all() as CreatedLessonRow[]; +} + +/** Admin queue: ready creations not yet promoted to the global curriculum. */ +export function listReadyUnpromoted(): CreatedLessonRow[] { + return getDb() + .prepare("SELECT * FROM created_lessons WHERE status = 'ready' AND promoted = 0 ORDER BY created_at DESC") + .all() as CreatedLessonRow[]; +} + +export function updateCreatedStatus(id: number, status: CreatedStatus, error?: string) { + getDb() + .prepare("UPDATE created_lessons SET status = ?, error = ?, updated_at = datetime('now') WHERE id = ?") + .run(status, error ?? null, id); +} + +function setReady(id: number, templateSvg: string, image: string) { + getDb() + .prepare("UPDATE created_lessons SET status = 'ready', template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?") + .run(templateSvg, image, id); +} + +export function promoteCreated(id: number) { + getDb().prepare("UPDATE created_lessons SET promoted = 1, updated_at = datetime('now') WHERE id = ?").run(id); +} + +/** + * Run generation for a row: ComfyUI -> vectorize -> ready (or failed). Fire-and-forget from the API + * route; safe on a long-lived self-hosted node. Heavy deps are imported lazily so the data layer stays + * light for normal page renders. + */ +export async function processCreation(id: number): Promise { + const row = getCreatedById(id); + if (!row) return; + try { + updateCreatedStatus(id, "generating"); + const { generateLineArt } = await import("./comfyui"); + const { pngToTemplateSvg } = await import("./vectorize"); + const png = await generateLineArt(row.prompt); + const templateSvg = await pngToTemplateSvg(png); + setReady(id, templateSvg, `data:image/png;base64,${png.toString("base64")}`); + } catch (e) { + updateCreatedStatus(id, "failed", e instanceof Error ? e.message : String(e)); + } +} + +/** Build the playable lessons (Trace + Color) from a ready row. */ +export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] { + const base = 900000 + row.id * 10; + const tpl = row.template_svg || ""; + const lower = row.subject.toLowerCase(); + const steps = svgToRevealSteps(tpl).map((els, i) => ({ + n: i + 1, + title: `Lines ${i + 1}`, + instruction: "Trace the lines, one part at a time.", + tip: "", + lines: els, + })); + const common = { + level: row.level, + emoji: row.emoji, + subjectKey: row.slug, + subjectName: row.subject, + subjectEmoji: row.emoji, + order: 0, + badgeName: `${row.subject} Artist`, + }; + const trace: Lesson = { + ...common, + sublevel: base + 1, + slug: `${row.slug}-trace`, + title: `${row.subject} · Trace`, + subject: `${lower} trace`, + intro: `Watch your ${lower} appear, then trace it!`, + badgeKey: "", + phase: "outline", + baseSvg: "", + steps, + }; + const color: Lesson = { + ...common, + sublevel: base + 2, + slug: `${row.slug}-color`, + title: `${row.subject} · Color`, + subject: `color the ${lower}`, + intro: `Bring your ${lower} to life! Color it in.`, + badgeKey: "", + phase: "color", + baseSvg: tpl, + steps: [], + }; + return [trace, color]; +} + +/** Resolve a lesson-page slug (…-trace / …-color) to its created lesson + its phase siblings. */ +export function resolveCreatedLesson(slug: string): { lessons: Lesson[]; index: number; row: CreatedLessonRow } | null { + const m = slug.match(/^(.*)-(trace|color)$/); + if (!m) return null; + const row = getCreatedBySlug(m[1]); + if (!row || row.status !== "ready") return null; + const lessons = buildCreatedLessons(row); + const index = lessons.findIndex((l) => l.slug === slug); + if (index < 0) return null; + return { lessons, index, row }; +} -- 2.34.1 From 5ec87c4fd5bcd042d5be6547276811a85cfccad6 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:52 +0000 Subject: [PATCH 07/29] Add ambient type declarations for the potrace module --- src/types/potrace.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/types/potrace.d.ts diff --git a/src/types/potrace.d.ts b/src/types/potrace.d.ts new file mode 100644 index 0000000..2ee1ef9 --- /dev/null +++ b/src/types/potrace.d.ts @@ -0,0 +1,8 @@ +// Minimal types for the `potrace` package (no official @types). +declare module "potrace" { + type TraceCallback = (err: Error | null, svg: string) => void; + export function trace(file: Buffer | string, options: Record, cb: TraceCallback): void; + export function trace(file: Buffer | string, cb: TraceCallback): void; + export function posterize(file: Buffer | string, options: Record, cb: TraceCallback): void; + export function posterize(file: Buffer | string, cb: TraceCallback): void; +} -- 2.34.1 From e7759405ce277e7c7bb5ac2b395fb98225d2bc75 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:52 +0000 Subject: [PATCH 08/29] Add create endpoint: moderate subject then queue generation --- src/app/api/create/route.ts | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/app/api/create/route.ts diff --git a/src/app/api/create/route.ts b/src/app/api/create/route.ts new file mode 100644 index 0000000..5087e55 --- /dev/null +++ b/src/app/api/create/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { requireCreator } from "@/lib/session"; +import { isCreateEnabled, buildPrompt } from "@/lib/comfyui"; +import { moderateSubject } from "@/lib/moderation"; +import { createCreatedLesson, processCreation, listCreatedForUser } from "@/lib/createdLessons"; + +const LEVELS = new Set(["early-beginner", "beginner"]); +const DAILY_LIMIT = 20; + +export async function POST(req: Request) { + const user = await requireCreator(); + if (!user) return NextResponse.json({ error: "Not authorized." }, { status: 403 }); + if (!isCreateEnabled()) return NextResponse.json({ error: "Create is not available right now." }, { status: 503 }); + + let body: { level?: string; subject?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid request." }, { status: 400 }); + } + + const level = body.level || ""; + if (!LEVELS.has(level)) return NextResponse.json({ error: "Pick a level." }, { status: 400 }); + + // Simple per-user daily cap (bounds abuse + GPU cost). + const todays = listCreatedForUser(user.id, level).filter((r) => r.created_at.slice(0, 10) === new Date().toISOString().slice(0, 10)); + if (todays.length >= DAILY_LIMIT) return NextResponse.json({ error: "You've created a lot today — try again tomorrow!" }, { status: 429 }); + + const mod = moderateSubject(body.subject || ""); + if (mod.decision === "blocked") { + return NextResponse.json({ error: "Let's pick a different, friendly thing to draw!" }, { status: 422 }); + } + + const prompt = buildPrompt(mod.subject); + const status = mod.decision === "allowlist" ? "pending" : "needs_review"; + const row = createCreatedLesson({ userId: user.id, level, subject: mod.subject, prompt, moderation: mod.decision, status }); + + // Allowlisted → generate now (async). Review → wait for an admin to approve. + if (mod.decision === "allowlist") void processCreation(row.id); + + return NextResponse.json({ + id: row.id, + slug: row.slug, + status: mod.decision === "allowlist" ? "generating" : "needs_review", + }); +} -- 2.34.1 From 9a7fd01eae1248e7a500a8be763a201df1cec03d Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:53 +0000 Subject: [PATCH 09/29] Add endpoint to poll a creation's status --- src/app/api/create/[id]/route.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/app/api/create/[id]/route.ts diff --git a/src/app/api/create/[id]/route.ts b/src/app/api/create/[id]/route.ts new file mode 100644 index 0000000..88b588b --- /dev/null +++ b/src/app/api/create/[id]/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/session"; +import { getCreatedById } from "@/lib/createdLessons"; + +// Poll the status of a creation (owner or admin). +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Log in." }, { status: 401 }); + + const { id } = await ctx.params; + const row = getCreatedById(Number(id)); + if (!row) return NextResponse.json({ error: "Not found." }, { status: 404 }); + if (row.user_id !== user.id && user.role !== "admin") + return NextResponse.json({ error: "Not authorized." }, { status: 403 }); + + return NextResponse.json({ id: row.id, slug: row.slug, status: row.status, error: row.error, subject: row.subject }); +} -- 2.34.1 From c9aed9f2836723259c0a13e30f47fcc9334867a5 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:54 +0000 Subject: [PATCH 10/29] Add admin endpoint to approve, block, or promote creations --- src/app/api/admin/create/[id]/route.ts | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/app/api/admin/create/[id]/route.ts diff --git a/src/app/api/admin/create/[id]/route.ts b/src/app/api/admin/create/[id]/route.ts new file mode 100644 index 0000000..9efbc06 --- /dev/null +++ b/src/app/api/admin/create/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/session"; +import { getCreatedById, updateCreatedStatus, promoteCreated, processCreation } from "@/lib/createdLessons"; + +// Admin review queue: approve (generate), block, or promote a creation to the global curriculum. +export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) { + const admin = await requireAdmin(); + if (!admin) return NextResponse.json({ error: "Not authorized." }, { status: 403 }); + + const { id } = await ctx.params; + const row = getCreatedById(Number(id)); + if (!row) return NextResponse.json({ error: "Not found." }, { status: 404 }); + + let body: { action?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid request." }, { status: 400 }); + } + + switch (body.action) { + case "approve": + if (row.status !== "needs_review") return NextResponse.json({ error: "Not awaiting review." }, { status: 400 }); + updateCreatedStatus(row.id, "pending"); + void processCreation(row.id); + return NextResponse.json({ ok: true, status: "generating" }); + case "block": + updateCreatedStatus(row.id, "blocked"); + return NextResponse.json({ ok: true, status: "blocked" }); + case "promote": + if (row.status !== "ready") return NextResponse.json({ error: "Only ready lessons can be promoted." }, { status: 400 }); + promoteCreated(row.id); + return NextResponse.json({ ok: true }); + default: + return NextResponse.json({ error: "Unknown action." }, { status: 400 }); + } +} -- 2.34.1 From ed30020b6ced4ce350a09b24a93508098318b0bc Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:54 +0000 Subject: [PATCH 11/29] Allow admins to assign the creator role --- src/app/api/admin/users/[id]/route.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 5c22bd3..342f4b1 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -39,7 +39,7 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }> } case "role": { const r = body.value as Role; - if (!["admin", "learner"].includes(r)) + if (!["admin", "learner", "creator"].includes(r)) return NextResponse.json({ error: "Invalid role." }, { status: 400 }); // Guard: don't let an admin demote the last remaining admin (themselves included). if (target.role === "admin" && r !== "admin") { @@ -49,6 +49,4 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }> return NextResponse.json({ ok: true }); } default: - return NextResponse.json({ error: "Unknown action." }, { status: 400 }); - } -} + return NextResponse.json({ error: "Unknown action." }, { status: 400 \ No newline at end of file -- 2.34.1 From a772c235597dd44707cad401a44660f4272466bb Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:55 +0000 Subject: [PATCH 12/29] Add creator role buttons to admin user management --- src/app/admin/AdminUsers.tsx | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/app/admin/AdminUsers.tsx b/src/app/admin/AdminUsers.tsx index 7a39873..be3dc6e 100644 --- a/src/app/admin/AdminUsers.tsx +++ b/src/app/admin/AdminUsers.tsx @@ -90,30 +90,25 @@ export default function AdminUsers({ Unsuspend ) : null} - {u.role === "learner" ? ( + {u.role !== "admin" && ( - ) : ( + )} + {u.role !== "creator" && ( )} - - - - ); - })} - - - - ); -} - -const btn: React.CSSProperties = { minHeight: 36, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" }; + {u.role !== "learner" && ( +

No creations awaiting review.

; + } + + return ( +
+ {review.length > 0 && ( + <> + Awaiting review +
+ {review.map((r) => ( +
+ {r.emoji} {r.subject} · {r.level} + + + + +
+ ))} +
+ + )} + {ready.length > 0 && ( + <> + Ready — promote to everyone? +
+ {ready.map((r) => ( +
+ {r.emoji} {r.subject} · {r.level} + +
+ ))} +
+ + )} +
+ ); +} + +const mini: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" }; -- 2.34.1 From e918955cdaa4c6f91090780b7f5cd49f6abbf50a Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:56 +0000 Subject: [PATCH 14/29] Show created-lessons review section on the admin page --- src/app/admin/page.tsx | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 88c7b8c..2252bcf 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -7,6 +7,8 @@ import { toPublicUser } from "@/lib/types"; import AddUserForm from "./AddUserForm"; import AdminUsers from "./AdminUsers"; import AdminReports from "./AdminReports"; +import AdminCreations from "./AdminCreations"; +import { listNeedsReview, listReadyUnpromoted } from "@/lib/createdLessons"; export const metadata = { title: "Admin · DrawIt" }; export const dynamic = "force-dynamic"; @@ -19,6 +21,9 @@ export default async function AdminPage() { const pending = users.filter((u) => u.status === "pending"); const reports = listReports(); const openReports = reports.filter((r) => r.status === "open").length; + const pick = (r: { id: number; subject: string; level: string; status: string; slug: string; emoji: string }) => ({ id: r.id, subject: r.subject, level: r.level, status: r.status, slug: r.slug, emoji: r.emoji }); + const reviewCreations = listNeedsReview().map(pick); + const readyCreations = listReadyUnpromoted().map(pick); return ( <> @@ -37,20 +42,4 @@ export default async function AdminPage() { -
-

Users

-
- -
-
- -
-

Issue reports

-
- -
-
- - - ); -} +
Date: Mon, 29 Jun 2026 14:10:56 +0000 Subject: [PATCH 15/29] Add Create page, gated on creator role and ComfyUI --- src/app/create/page.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/app/create/page.tsx diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx new file mode 100644 index 0000000..a962420 --- /dev/null +++ b/src/app/create/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from "next/navigation"; +import SiteNav from "@/components/SiteNav"; +import CreateForm from "./CreateForm"; +import { getCurrentUser, canCreate } from "@/lib/session"; +import { isCreateEnabled } from "@/lib/comfyui"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Create · DrawIt" }; + +export default async function CreatePage({ searchParams }: { searchParams: Promise<{ level?: string }> }) { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + if (!canCreate(user)) redirect("/learn"); + // Hide the Create page entirely when ComfyUI isn't configured (Creator role alone isn't enough). + if (!isCreateEnabled()) redirect("/learn"); + + const { level } = await searchParams; + const lvl = level === "beginner" ? "beginner" : "early-beginner"; + + return ( + <> + +
+

✨ Create your own lesson

+

+ Type something you'd love to draw and DrawIt will make a brand-new coloring-book lesson for it — + \ No newline at end of file -- 2.34.1 From 21376fd1b48389a05a8e6a0977c1afd7eae8eea3 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:57 +0000 Subject: [PATCH 16/29] Add Create form with subject input and status polling --- src/app/create/CreateForm.tsx | 131 ++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/app/create/CreateForm.tsx diff --git a/src/app/create/CreateForm.tsx b/src/app/create/CreateForm.tsx new file mode 100644 index 0000000..e883039 --- /dev/null +++ b/src/app/create/CreateForm.tsx @@ -0,0 +1,131 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; + +type Phase = "idle" | "submitting" | "generating" | "review" | "ready" | "failed"; + +export default function CreateForm({ level: initialLevel, enabled }: { level: string; enabled: boolean }) { + const [level, setLevel] = useState(initialLevel); + const [subject, setSubject] = useState(""); + const [phase, setPhase] = useState("idle"); + const [msg, setMsg] = useState(""); + const [slug, setSlug] = useState(""); + const poll = useRef | undefined>(undefined); + + useEffect(() => () => clearInterval(poll.current), []); + + async function start(e: React.FormEvent) { + e.preventDefault(); + setMsg(""); + setSlug(""); + setPhase("submitting"); + try { + const res = await fetch("/api/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level, subject }), + }); + const data = await res.json(); + if (!res.ok) { + setPhase("failed"); + setMsg(data.error || "Something went wrong."); + return; + } + if (data.status === "needs_review") { + setPhase("review"); + return; + } + setSlug(data.slug); + setPhase("generating"); + watch(data.id, data.slug); + } catch { + setPhase("failed"); + setMsg("Couldn't reach the server."); + } + } + + function watch(id: number, baseSlug: string) { + clearInterval(poll.current); + poll.current = setInterval(async () => { + try { + const r = await fetch(`/api/create/${id}`); + const d = await r.json(); + if (d.status === "ready") { + clearInterval(poll.current); + setSlug(baseSlug); + setPhase("ready"); + } else if (d.status === "failed" || d.status === "blocked") { + clearInterval(poll.current); + setPhase("failed"); + setMsg(d.error || "The drawing couldn't be made. Try a different subject."); + } + } catch { + /* keep polling */ + } + }, 2500); + } + + if (!enabled) { + return ( +

+

+ The Create feature isn't turned on for this DrawIt instance yet. Ask your administrator to configure a + drawing server (ComfyUI). +

+
+ ); + } + + const busy = phase === "submitting" || phase === "generating"; + + return ( +
+
+ + + +
+ + {phase === "generating" && ( +
+

🎨 Drawing your {subject || "picture"}… this can take a little while.

+

You can stay on this page.

+
+ )} + {phase === "review" && ( +
+

📨 Thanks! "{subject}" was sent to a grown-up to approve. Check back soon.

+
+ )} + {phase === "ready" && ( +
+

🎉 Your lesson is ready!

+ Start drawing +
+ )} + {phase === "failed" && ( +
+

{msg || "Something went wrong. Try a different subject."}

+
+ )} +
+ ); +} -- 2.34.1 From d89227c2593b934ed5087763ff84ecf705e32d97 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:58 +0000 Subject: [PATCH 17/29] Surface Create entry and creations, hidden when unconfigured --- src/app/learn/page.tsx | 95 +++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index 6f2684e..860aa40 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -4,6 +4,9 @@ import { LEVELS, getSubjects, getGroupedSubjects, BEGINNER_PACKS, lessonStepCoun import { getCurrentUser } from "@/lib/session"; import { getCompletedSteps, hasBadge } from "@/lib/progress"; import { isLevelUnlocked, countCompletedLessons, isPackComplete, BEGINNER_UNLOCK_THRESHOLD } from "@/lib/gating"; +import { canCreate } from "@/lib/session"; +import { isCreateEnabled } from "@/lib/comfyui"; +import { listCreatedForUser, listPromoted, buildCreatedLessons, type CreatedLessonRow } from "@/lib/createdLessons"; export const metadata = { title: "Learn · DrawIt" }; export const dynamic = "force-dynamic"; @@ -70,6 +73,47 @@ export default async function LearnPage() { ); }; + const creator = canCreate(user); + const createEnabled = isCreateEnabled(); + const toCreatedSubject = (row: CreatedLessonRow) => ({ key: row.slug, name: row.subject, emoji: row.emoji, order: 0, lessons: buildCreatedLessons(row) }); + + // "Create your own" entry + the user's creations + any promoted (global) ones, for EB/Beginner. + const creationsSection = (lvlKey: string) => { + if (lvlKey !== "early-beginner" && lvlKey !== "beginner") return null; + // Create UI is hidden entirely unless ComfyUI is configured (being a Creator alone isn't enough). + const canCreateNow = creator && createEnabled; + const mine: CreatedLessonRow[] = user && creator ? listCreatedForUser(user.id, lvlKey) : []; + const featured: CreatedLessonRow[] = listPromoted(lvlKey); + const ready = mine.filter((r) => r.status === "ready"); + const pending = mine.filter((r) => ["pending", "generating", "needs_review"].includes(r.status)); + // Nothing to show: Create disabled and no existing or featured creations. + if (!canCreateNow && ready.length === 0 && pending.length === 0 && featured.length === 0) return null; + return ( +
+
+ {canCreateNow ? "✨ Create your own" : "✨ My Creations"} + {canCreateNow && + New lesson} +
+
+ {ready.map((r) => subjectAccordion(toCreatedSubject(r), false))} + {pending.map((r) => ( +
+ {r.emoji} {r.subject}{" "} + {r.status === "needs_review" ? "⏳ Waiting for approval" : "🎨 Drawing…"} +
+ ))} + {canCreateNow && ready.length === 0 && pending.length === 0 &&

No creations yet — tap “+ New lesson”.

} + {featured.length > 0 && ( + <> + 🌟 Featured creations + {featured.map((r) => subjectAccordion(toCreatedSubject(r), false))} + + )} +
+
+ ); + }; + return ( <> @@ -116,6 +160,7 @@ export default async function LearnPage() { ) : lvl.key === "beginner" ? ( // Beginner: group subjects into packs, each with a per-pack badge.
+ {creationsSection(lvl.key)} {BEGINNER_PACKS.map((pack) => { const packSubjects = subjects.filter((s) => pack.subjectKeys.includes(s.key)); const packDone = !!user && isPackComplete(user.id, pack); @@ -136,51 +181,5 @@ export default async function LearnPage() { ) : lvl.key === "early-beginner" ? ( // Early Beginner: themed groups, each holding its subjects.
- {getGroupedSubjects(lvl.key).map((group) => { - const groupDone = group.subjects.every((s) => subjectComplete(s)); - return ( -
-
- {group.emoji} {group.name} - {group.subjects.filter((s) => subjectComplete(s)).length}/{group.subjects.length} done -
-
- {group.subjects.map((subj) => subjectAccordion(subj, subj.key === firstIncomplete))} -
-
- ); - })} -
- ) : ( - // Any future flat level -
- {subjects.map((subj) => { - const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase; - if (!phased) { - const l = subj.lessons[0]; - const done = isDone(l); - const inProg = !done && doneCount(l) > 0; - return ( - -
- {subj.emoji} {l.title} - {done ? ✓ Done : inProg ? Step {Math.min(doneCount(l) + 1, lessonStepCount(l))}/{lessonStepCount(l)} : {lessonStepCount(l)} steps} -
- - ); - } - return subjectAccordion(subj, subj.key === firstIncomplete); - })} -
- )} -
- ); - })} - -
- - ); -} - -const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 }); -const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" }; + {creationsSection(lvl.key)} + {getGroupedSubjects(lvl.key).map((group) = \ No newline at end of file -- 2.34.1 From 2efdc54a16a1952939696ee9fa355f62c5dfa10a Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:58 +0000 Subject: [PATCH 18/29] Resolve and render AI-created Trace and Color lessons --- src/app/learn/[level]/[slug]/page.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/app/learn/[level]/[slug]/page.tsx b/src/app/learn/[level]/[slug]/page.tsx index fc0ef18..148c721 100644 --- a/src/app/learn/[level]/[slug]/page.tsx +++ b/src/app/learn/[level]/[slug]/page.tsx @@ -9,6 +9,7 @@ import { getCurrentUser } from "@/lib/session"; import { getCompletedSteps, hasBadge } from "@/lib/progress"; import { isLevelUnlocked } from "@/lib/gating"; import { getLatestDrawing } from "@/lib/drawings"; +import { resolveCreatedLesson } from "@/lib/createdLessons"; export const dynamic = "force-dynamic"; @@ -28,7 +29,8 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str export default async function LessonPage({ params }: { params: Promise<{ level: string; slug: string }> }) { const { level, slug } = await params; const lesson = getLessonBySlug(slug); - if (!lesson || lesson.level !== level) notFound(); + // Not a static lesson? It may be an AI-created lesson (…-trace / …-color). + if (!lesson || lesson.level !== level) return renderCreated(level, slug); const user = await getCurrentUser(); if (!user && lesson.slug !== FREE_SLUG) redirect("/signup"); @@ -113,9 +115,4 @@ export default async function LessonPage({ params }: { params: Promise<{ level: {isShade ? ( ) : ( - - )} - - - ); -} + Date: Mon, 29 Jun 2026 14:10:59 +0000 Subject: [PATCH 19/29] Add potrace dependency for line-art vectorization --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index fc0bea8..02ff391 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "next": "15.5.19", "nodemailer": "^9.0.1", "pdf-lib": "^1.17.1", + "potrace": "^2.1.8", "react": "19.2.7", "react-dom": "19.2.7" }, @@ -29,6 +30,4 @@ }, "allowScripts": { "better-sqlite3@12.11.1": true, - "sharp@0.34.5": true - } -} + "sha \ No newline at end of file -- 2.34.1 From 917991c29068f895a26358365bce4cb59e417d43 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:59 +0000 Subject: [PATCH 20/29] Add stock ComfyUI workflow template for coloring-book art --- comfyui/coloring-book.workflow_api.json | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 comfyui/coloring-book.workflow_api.json diff --git a/comfyui/coloring-book.workflow_api.json b/comfyui/coloring-book.workflow_api.json new file mode 100644 index 0000000..7195e02 --- /dev/null +++ b/comfyui/coloring-book.workflow_api.json @@ -0,0 +1,41 @@ +{ + "3": { + "inputs": { + "seed": 0, + "steps": 24, + "cfg": 7, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0] + }, + "class_type": "KSampler" + }, + "4": { + "inputs": { "ckpt_name": "v1-5-pruned-emaonly.safetensors" }, + "class_type": "CheckpointLoaderSimple" + }, + "5": { + "inputs": { "width": 768, "height": 576, "batch_size": 1 }, + "class_type": "EmptyLatentImage" + }, + "6": { + "inputs": { "text": "placeholder prompt — replaced by DrawIt at runtime", "clip": ["4", 1] }, + "class_type": "CLIPTextEncode" + }, + "7": { + "inputs": { "text": "color, colored, shading, grayscale, gradient, photo, realistic, background clutter, watermark, text, signature", "clip": ["4", 1] }, + "class_type": "CLIPTextEncode" + }, + "8": { + "inputs": { "samples": ["3", 0], "vae": ["4", 2] }, + "class_type": "VAEDecode" + }, + "9": { + "inputs": { "filename_prefix": "DrawIt", "images": ["8", 0] }, + "class_type": "SaveImage" + } +} -- 2.34.1 From 1e1408ad7f3db2cc784a30f6f12c8a55e95befff Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:11:00 +0000 Subject: [PATCH 21/29] Add ComfyUI setup notes for the Create feature --- comfyui/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 comfyui/README.md diff --git a/comfyui/README.md b/comfyui/README.md new file mode 100644 index 0000000..f6ec7af --- /dev/null +++ b/comfyui/README.md @@ -0,0 +1,28 @@ +# ComfyUI workflow for the "Create" feature + +DrawIt's **Create** feature sends a fixed coloring-book prompt to a self-hosted [ComfyUI](https://github.com/comfyanonymous/ComfyUI) +server and turns the result into a lesson. Nothing leaves your machine. + +## Setup + +1. Run ComfyUI (default: `http://127.0.0.1:8188`). +2. Set `COMFYUI_URL` in `.env.local` (blank = feature disabled). +3. Provide a workflow exported in **API format** (ComfyUI → Settings → enable dev mode → + "Save (API format)") and point `COMFYUI_WORKFLOW` at it. + +`coloring-book.workflow_api.json` here is the **stock SD1.5 text-to-image workflow** as a starting +point. DrawIt injects its prompt into the positive-prompt node and a random seed into the sampler node: + +- `COMFYUI_PROMPT_NODE` (default `6`) — the positive `CLIPTextEncode` node. +- `COMFYUI_SEED_NODE` (default `3`) — the `KSampler` node. + +## Tips for clean line art (matters — we vectorize it with potrace) + +The cleaner and bolder the outlines, the better the trace lessons look. Recommended: + +- Use a **line-art / coloring-book LoRA or model** if you have one, and bump `steps`. +- Keep the negative prompt rejecting color/shading/background clutter (already set). +- The image is auto-thresholded to pure black/white and fitted to the lesson canvas, so aim for + **bold, fully-closed outlines on a white background** — thin or broken lines vectorize poorly. + +Edit `ckpt_name` to a checkpoint you actually have installed. -- 2.34.1 From f79ce000e42ba0ac81b1004741da499c092ebe0a Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:11:09 +0000 Subject: [PATCH 22/29] Add design doc for Create feature and Creator role --- docs/DESIGN-create-ai-lessons.md | 261 +++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/DESIGN-create-ai-lessons.md diff --git a/docs/DESIGN-create-ai-lessons.md b/docs/DESIGN-create-ai-lessons.md new file mode 100644 index 0000000..0e93418 --- /dev/null +++ b/docs/DESIGN-create-ai-lessons.md @@ -0,0 +1,261 @@ +# DrawIt — Design: "Create" (AI-generated custom lessons) + the Creator role + +_Designed June 2026. Purpose: let a privileged user type a subject (e.g. "Zebra"), have a self-hosted +**ComfyUI** server generate a coloring-book line-art image, and turn that image into a real DrawIt +lesson with the usual design elements. This is a design/research document — nothing is built yet. It +assumes the current curriculum/runner architecture documented in `RESEARCH-beginner-level.md` and +`RESEARCH-early-beginner-expansion.md`._ + +## 1. Goal & summary + +A new **"Create"** option (visible only to a new **Creator** role) appears on the Early Beginner and +Beginner levels. The Creator types a subject; the app sends a fixed prompt to a configurable ComfyUI +endpoint; ComfyUI returns black-and-white line art; DrawIt processes that image into a playable lesson +(trace + color phases, badge, progress, gallery — the same machinery every other lesson uses). + +The fixed prompt (subject interpolated): + +> A coloring book page of a **[subject]**, clean black-and-white line art, bold crisp outlines, simple +> composition, large open areas to color, white background, no shading, no grayscale, no color, no +> gradients, no heavy background detail, printable page. + +Two hard problems drive the design: **(a)** an AI returns *one raster image*, but our lessons are +multi-phase, SVG-step constructions; and **(b)** this is a children's app accepting free-text that +feeds an image generator, so **safety/moderation is mandatory, not optional.** + +## 2. The Creator role + +Today `Role = "admin" | "learner"` (`src/lib/types.ts`); the DB `users.role` is already a free-text +column defaulting to `learner`, and the first registered account is forced to `admin`. Adding a role is +small and backward-compatible: + +- **Type:** `Role = "admin" | "learner" | "creator"`. A Creator has **all Learner permissions plus the + Create ability**. Admins implicitly have it too. +- **No schema change** — `role` is already TEXT. Existing rows stay `learner`/`admin`. +- **Assignment:** only an admin grants it, via the existing admin users API + (`src/app/api/admin/users/[id]/route.ts`) — add `"creator"` to the allowed set (today it coerces to + `admin`/`learner`). Signup is unchanged (first = admin, everyone else = learner). +- **Server guard:** add `requireCreator()` in `src/lib/session.ts` (mirrors `requireAdmin()`): + returns the user iff `role === "creator" || role === "admin"`. A tiny `canCreate(user)` helper feeds + the UI. **Every Create endpoint must call this server-side** — never trust the client. + +## 3. End-to-end user flow + +1. Creator opens Early Beginner or Beginner → taps **✨ Create your own**. +2. Types a subject ("Zebra"). Client does light validation; server does the real moderation (§5). +3. Server creates a `created_lessons` row (`status: 'pending'`), interpolates the subject into the + fixed prompt, and queues the ComfyUI workflow (`status: 'generating'`). +4. Client polls status (or subscribes). ComfyUI renders (seconds to ~1 min on a GPU box). +5. Server fetches the image, post-processes it (§6), derives the lesson assets, sets `status: 'ready'` + (or `'needs_review'` if moderation requires admin approval). +6. The lesson appears under a **"My Creations"** group on that level and plays through the normal + runners (trace → color), earning a badge and saving to the gallery like any lesson. + +## 4. ComfyUI integration + +**Why ComfyUI / self-hosted:** it keeps DrawIt's privacy promise — the subject text and image never +leave a server the operator runs. No third-party AI cloud. The endpoint is **configurable** so each +self-hoster points at their own GPU box (or none, disabling Create). + +**Config (env, mirroring the existing SMTP pattern in `.env.example`):** + +``` +COMFYUI_URL= # e.g. http://127.0.0.1:8188 — blank disables the Create feature +COMFYUI_API_KEY= # optional, if the box is behind an auth proxy +COMFYUI_WORKFLOW=./comfyui/coloring-book.workflow_api.json # API-format workflow template +COMFYUI_TIMEOUT_MS=120000 +COMFYUI_PROMPT_NODE=6 # id of the positive CLIP-text node to inject the prompt into +``` + +**API calls** (ComfyUI's HTTP API): + +1. `POST {COMFYUI_URL}/prompt` with `{ prompt: , client_id }`. We load the saved + **API-format workflow** (exported from ComfyUI's "Save (API format)"), and set the positive prompt + node's `inputs.text` to our interpolated prompt (and randomize the seed node). Returns `prompt_id`. +2. Poll `GET {COMFYUI_URL}/history/{prompt_id}` until the entry exists; read + `outputs[node].images[]` → `{ filename, subfolder, type }`. (Optionally subscribe to + `GET /ws?clientId=` for live progress to show a friendly "drawing your zebra…" bar.) +3. `GET {COMFYUI_URL}/view?filename=…&subfolder=…&type=output` → the PNG bytes. + +**Async model:** generation is slow, so treat it as a job. `created_lessons.status` is the state +machine: `pending → generating → (needs_review) → ready | failed | blocked`. The POST route kicks off +generation server-side and returns immediately; the client polls `GET /api/create/:id`. On a single +self-hosted node an in-process async task is fine; document that horizontal scaling would want a real +queue. Always set a timeout and a retry-once policy; surface `failed` with a kid-friendly message. + +## 5. Safety & moderation (mandatory — this is a kids' app) + +Free text → image generation for children is the riskiest part. Layered defenses, **input + output**: + +**Input (the subject string):** +- **Sanitize:** trim; cap length (~30 chars); collapse whitespace; allow only letters/spaces/hyphens; + reject newlines and any prompt-control tokens. Because we only interpolate `[subject]` into a fixed + prompt, prompt-injection surface is small — but still strip anything that could break out of the + phrase. +- **Allowlist-first (recommended for kids):** maintain a curated allowlist of safe, drawable subjects + (animals, objects, food, vehicles, nature, shapes…). If the subject is on the allowlist → proceed + automatically. This is the safest posture and the literature's recommendation for child contexts. +- **Blocklist backstop:** a denylist of profanity, violence, weapons, drugs, sexual, hateful, and + self-harm terms (and obfuscations) rejects anything obviously unsafe that slips past. +- **Anything else → admin review queue** (`status: 'needs_review'`): not on the allowlist and not on + the blocklist → a human approves before it generates or goes live. Keeps the door open to new + subjects without exposing kids to raw generation. +- **Rate-limit** per user (e.g. N creations/day) to bound abuse and GPU cost. + +**Output (the generated image):** even safe prompts can misfire. Before a lesson is playable, require a +**preview/approve step** — minimally the creating user sees it and confirms; for stricter operators, an +admin approval queue. (Optional later: an automated NSFW image classifier as a pre-filter.) + +**Logging vs. privacy:** store only what's needed to run/curate the feature (subject, status, +moderation decision). Consistent with "no analytics, data stays local." + +## 6. From image → level (the core pipeline) + +**What a lesson needs today:** a `Lesson` with phases. Trace phases (`construct`/`outline`/`detail`) +use per-step `lines: string[]` of SVG elements that `LineDraw` reveals one at a time; color/light +phases use `baseSvg` which `ColoringRunner` **rasterizes** for the fill boundary and overlay. Badge, +`sublevel`, `subjectKey`, and a group complete the level. + +**The gap:** ComfyUI returns *one raster PNG*. It is not pre-split into outline-vs-detail phases, and +it is pixels, not SVG strokes. So a Created lesson can't perfectly mirror a hand-authored 3–4-phase +subject. **Chosen approach (per §13): Option C — vectorize the line art with potrace for a +stroke-by-stroke Trace phase, and use the raster for the Color phase (2 phases total).** The three +options below are kept for context; the build targets C. + +- **Option A — Raster-native, 2-phase (recommended MVP).** Treat the cleaned PNG as the lesson art. + - **Trace** phase: show the line art faintly as a guide to trace over (reuse `TraceRunner`'s base + layer, fed a raster instead of SVG). Optional "watch it appear" = a left-to-right wipe reveal of + the bitmap rather than true per-stroke animation. + - **Color** phase: `ColoringRunner` already rasterizes its template to a boundary + overlay, so it + can take the PNG **directly** — flood-fill and overlay work as-is. This phase is essentially free. + - For Beginner-created lessons, optionally add the existing **Light** phase (sun overlay) on top. + - Smallest change: add a `templateUrl?` (raster) alongside `baseSvg` to `TraceRunner`/`ColoringRunner` + and the lesson-page meta; when present, use the bitmap path. + +- **Option B — Vectorize for true line-by-line reveal.** Run the PNG through **potrace** (pure-JS npm, + no native build — important given our better-sqlite3 build friction) to get SVG paths, then split the + SVG into multiple `lines` (by `M` subpaths) so `LineDraw` animates it stroke-by-stroke like the + hand-made lessons. Quality varies with image cleanliness; treat as an enhancement over A. + +- **Option C — Hybrid (target end state).** Vectorize for the trace reveal (B) **and** keep the raster + for coloring (A). Optionally heuristically split paths into a simpler "outline" (large/long paths) + vs "detail" (small/short paths) pass to approximate the two trace phases — best-effort, not perfect. + +**Image post-processing** (server-side, before storing) with a pure-JS lib like **jimp** (avoids native +deps) or **sharp** (faster, native): grayscale → threshold to crisp black/white → auto-trim margins → +fit to the lessons' aspect (the runners use a 4:3 / 400×300 viewBox and an 880×660 color canvas) → +flatten on white → downscale. Store the cleaned PNG; if vectorizing, store the derived SVG too. + +**Badge & metadata:** Created lessons get a generic medal — a default emoji (✏️ or 🎨, or a +creator-picked one) plus a name like "**Zebra Artist**". Reuse the existing emoji-medal `BadgeArt` and +award-PDF machinery untouched. + +## 7. Data model + +No destructive changes; one new table (plus the `Role` union value): + +```sql +CREATE TABLE created_lessons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + level TEXT NOT NULL, -- 'early-beginner' | 'beginner' + subject TEXT NOT NULL, -- sanitized, e.g. 'Zebra' + slug TEXT NOT NULL UNIQUE, -- e.g. 'created-42-zebra' + status TEXT NOT NULL DEFAULT 'pending', -- pending|generating|needs_review|ready|failed|blocked + image TEXT, -- cleaned line-art (data URL, like drawings/avatars) + template_svg TEXT, -- optional vectorized paths (Option B/C) + prompt TEXT NOT NULL, -- full prompt sent (audit) + moderation TEXT, -- 'allowlist' | 'review' | 'blocked:' + emoji TEXT, -- badge emoji + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**Integration with existing systems (reuse, don't fork):** +- **Sublevel namespacing:** created lessons keep their `level` (so the unlock gate / grouping still + apply) and use a high synthetic `sublevel = 900000 + id`, guaranteeing no collision with static + lessons. Progress, completions, drawings, and badges then work through the *existing* tables keyed by + `(user_id, level, sublevel)` with zero changes. +- **Badge key:** `created-`; `badgeDefs()` gains the user's ready created lessons at request time. +- **Not in static `LESSONS`.** Add resolver helpers that the lesson page and learn page call: + `getCreatedLesson(slug)` builds a `Lesson`-shaped object on the fly from the row (so the runners need + no special-casing), and `listCreatedLessons(userId, level)` feeds the "My Creations" group. +- **Images as data URLs in SQLite** matches the existing privacy-consistent storage for drawings and + avatars (note: line-art PNGs are small once thresholded; watch row size, consider a files dir if big). +- **Backups:** the new table rides along with the existing Litestream/SQLite backup automatically. + +## 8. API routes (Next.js App Router) + +- `POST /api/create` — `requireCreator`; body `{ level, subject }`. Validate + moderate (§5). On pass: + insert row, interpolate prompt, kick off ComfyUI job, return `{ id, slug, status }`. On block: 422 + with a gentle message. +- `GET /api/create/:id` — owner (or admin); returns `{ status, slug, image? }` for polling. +- `GET /api/create/:id/image` — serves the stored line art (or inline data URL). +- `POST /api/admin/create/:id` — admin approve/block for the review queue. +- All Create routes are no-ops/404 when `COMFYUI_URL` is unset (feature disabled cleanly). + +## 9. UI + +- **Entry point:** a "✨ Create your own" card at the top of the Early Beginner and Beginner sections of + the Learn page, **rendered only when `canCreate(user)`**. (Also a nav entry, optional.) +- **Create view:** subject input + the fixed-prompt explainer ("we'll draw a coloring page of your + subject"), a live status/progress state, then a preview with **"Start the lesson"** / **"Try again"**. +- **My Creations group:** a `getGroupedSubjects`-style group on the level listing the user's ready + creations, each opening into the normal trace/color runner. +- **Gating:** non-creators never see Create (UI), and the API rejects them regardless (defense in depth). + +## 10. Config & ops + +- Requires a reachable ComfyUI with an image model + the saved API-format workflow JSON checked in at + `comfyui/coloring-book.workflow_api.json` (with documented node ids for prompt + seed). +- New deps (all optional/pure-JS preferred): `potrace` (vectorize), `jimp` or `sharp` (image cleanup). +- Document GPU expectations, timeout, and that Create is **off by default** (blank `COMFYUI_URL`), so + the core app and existing deploys are unaffected. + +## 11. Privacy & principles (unchanged) + +Self-hosted ComfyUI means the child's subject text and the image stay on the operator's own +infrastructure — no third-party calls, consistent with "ABSOLUTELY NO DATA COLLECTED." Generated assets +live in the operator's SQLite. The feature is fully optional and disabled unless configured. + +## 12. Phased rollout + +- **Phase 1 — Plumbing:** Creator role + `requireCreator` + admin can assign; env config; `created_lessons` + table; ComfyUI client (queue → poll → fetch) behind a feature flag. +- **Phase 2 — Lesson pipeline (Option C):** image cleanup + **potrace vectorization** → Trace (stroke + reveal) + Color; subject **allowlist + admin review queue** + blocklist + sanitize; preview/approve; + "My Creations" group (private to creator); badge + gallery via existing tables; rate limiting. +- **Phase 3 — Curation:** admin **promote-to-global** (creation enters the shared curriculum with + attribution; owner becomes system/global); review-queue UI polish. + +## 13. Decisions (locked June 2026) + +Settled with Dan; these drive the build: + +1. **Phases:** a created lesson is **2-phase — Trace + Color** (one AI image isn't cleanly splittable + into outline vs detail). Beginner creations may add the Light phase later, but the baseline is two. +2. **Moderation:** **allowlist + admin review queue** (+ blocklist backstop + input sanitize). Curated + safe subjects auto-pass; anything new is held for admin approval before it generates/goes live. +3. **Line reveal:** **vectorize up front** — run the cleaned line art through **potrace** so the Trace + phase animates **stroke-by-stroke** (via `LineDraw`) from day one, exactly like hand-made lessons. + The raster is still used for the Color phase (`ColoringRunner` rasterizes anyway). So the pipeline is + Option C (hybrid): **vectorized SVG for Trace + raster for Color**. +4. **Visibility:** creations are **private to the creator by default, but admin-promotable to the global + curriculum** (with attribution). So the data model must support a "promoted/global" flag and an + owner of `null`/system once promoted. + +Still open (smaller, decide at build time): **image storage** (data URLs in SQLite vs. a files dir for +the larger line-art PNGs) and **which ComfyUI model/workflow** is the reference (affects line-art +cleanliness, hence vectorization quality — pick a model that yields bold, closed outlines). + +## Sources + +- [ComfyUI API — Hosting a Workflow via API (9elements)](https://9elements.com/blog/hosting-a-comfyui-workflow-via-api/) +- [ComfyUI API: The Complete Developer's Guide (Runflow)](https://www.runflow.io/blog/comfyui-api-developer-guide) +- [ComfyUI API Endpoints — Complete Reference (Runflow)](https://www.runflow.io/blog/comfyui-api-endpoints) +- [ComfyUI: Using the API, Part 1 (Medium)](https://medium.com/@yushantripleseven/comfyui-using-the-api-261293aa055a) +- [potrace — npm (Node bitmap→SVG)](https://www.npmjs.com/package/potrace) +- [imagetracerjs — npm (JS raster vectorizer)](https://github.com/jankovicsandras/imagetracerjs) +- [Filtering Techniques: Blocklists and Allowlists for Safe AI Prompts (Learn Prompting)](https://learnprompting.org/docs/prompt_hacking/defensive_measures/filtering) +- [Azure AI Content Safety — Filters and Best Practices](https://jannikreinhard.com/2026/02/18/what-azure-ai-content-safety-does-and-why-it-matters/) +- [Safety Filtering in AI: How to Block Harmful Model Outputs](https://www.practical-devsecops.com/glossary/safety-filtering/) -- 2.34.1 From aa55a8b724f3c94d1b2a9d83193e5d622af74c6a Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:11:10 +0000 Subject: [PATCH 23/29] Add research doc for the Beginner level curriculum --- docs/RESEARCH-beginner-level.md | 266 ++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/RESEARCH-beginner-level.md diff --git a/docs/RESEARCH-beginner-level.md b/docs/RESEARCH-beginner-level.md new file mode 100644 index 0000000..b730f9e --- /dev/null +++ b/docs/RESEARCH-beginner-level.md @@ -0,0 +1,266 @@ +# DrawIt — Research: Designing the "Beginner" Level + +_Researched June 2026. Purpose: figure out **how a child moves from Early Beginner (simple shapes +and confident lines) to genuinely more complex drawings**, and map that progression onto a concrete +Beginner curriculum for DrawIt. This is a research/planning document only — nothing here is built +yet. It builds directly on `RESEARCH-drawing-platforms-and-techniques.md`, which mapped each of our +five ability levels to a technique. That doc said Beginner should teach **shape → form and base +color**; this doc fleshes out exactly what that means, why, and how._ + +## 1. Where "Beginner" sits — the developmental anchor + +DrawIt's five levels (Early Beginner → Beginner → Learner → Advanced Learner → Superb) line up +surprisingly well with the way children's drawing actually develops. The most widely used framework +is **Viktor Lowenfeld's stages of artistic development**: + +| Lowenfeld stage | Rough age | What the child can do | DrawIt level | +| --- | --- | --- | --- | +| Pre-schematic | 4–6 | Circular figures, single objects floating in space, flat shapes | **Early Beginner** (where we are) | +| Schematic | 7–9 | Understanding of space and proportion, baselines, repeatable "symbols" for things | **Beginner** (this doc) | +| Dawning Realism | 9–11 | Strives for realistic look, more detail per part, overlapping objects, horizon line | Learner / Advanced | + +Early Beginner lives in the **pre-schematic** world: a fish is a flat outline, an object on its own. +The leap into **Beginner** mirrors the move into the **schematic stage** — the child starts to care +about *space, proportion, and how parts relate*, and wants their drawings to look more like the real +thing. That is the single most important framing for this level: **Beginner is the bridge from +"flat symbol of a thing" to "a thing that has volume, parts, and believable size."** + +Crucially, we are *not* trying to reach realism here. Lowenfeld's "Dawning Realism" (true light, +shadow, overlap) is the Learner level. Beginner's job is the rung in between. + +## 2. The leap: what actually changes from Early Beginner → Beginner + +Four concrete shifts define the jump in complexity. Everything in this level should serve one of +these four, and each is a well-documented teaching milestone, not something we invented: + +1. **Flat shape → 3-D form.** A circle becomes a sphere; a square becomes a cube; a rectangle + becomes a cylinder. With just one or two added ellipses/edges a 2-D shape reads as a solid object + with volume. The four basic forms — **sphere, cube, cylinder, cone** — are described everywhere as + "the building blocks of drawing complex objects" and the foundation for creating the illusion of + depth and volume. + +2. **Single shape → constructed object (combining shapes).** This is "construction": breaking a + complex subject into basic shapes, stacking/overlapping them, and refining into the final outline. + A mug is a cylinder with an ellipse rim; a house is a box with a prism roof; a snowman is stacked + spheres. Early Beginner draws *one* shape per subject; Beginner draws subjects *made of several + shapes fitted together*. + +3. **Guessing size → simple proportion.** The schematic-stage child begins to care that the head is + the right size for the body, that the handle fits the cup. The teachable beginner skill is + **relative size / comparative measurement** — "nail the biggest proportions first, then refine" + and "compare one part to another instead of measuring in isolation." For kids this is kept + concrete: light guide marks, "the body is about two heads tall," a center line for symmetry. + +4. **Line only → base color (and the first hint of light).** Early Beginner already added a separate + "Color it!" coloring step. Beginner makes color *part of the drawing*: flat base color inside the + lines, an intro to **primary → secondary** colors, and the very first idea of a **light side and a + shadow side** of a form (one light source) — without full shading. This is deliberately the + on-ramp to the Learner shading module, so the two levels connect. + +## 3. The technique backbone for Beginner + +Rather than "just more animals," each Beginner subject should teach one of four named techniques, in +roughly this order of difficulty. This gives the level a real spine. + +### 3a. Shape → Form (the four basic forms) + +Teach the child to turn the flat shapes they already know into solids: + +- **Circle → Sphere** (add a curved contour line + a round shadow): ball, orange, planet. +- **Square → Cube** (add depth edges): box, dice, gift. +- **Rectangle → Cylinder** (add an ellipse top/bottom): cup, can, tree trunk, log. +- **Triangle → Cone** (add an ellipse base): ice-cream cone, party hat, traffic cone, tree. + +Each form is introduced on its own (a short "make it 3-D" drill) and then immediately *used* in a +real subject, so the technique never feels abstract. This is exactly how design-sketching courses +introduce the "3 (or 4) basic forms" before any product is drawn. + +### 3b. Construction — combining shapes into a subject + +Once forms exist, teach assembly: **light construction shapes first → refine into the outline → +erase/ignore the guides.** This is the professional "block-in" workflow simplified for kids. DrawIt's +Early Beginner lessons already hint at this with the cumulative outline steps; Beginner should make +it *explicit and visible*: step 1 is always "lay in the big shapes," and the guide shapes stay faint +under the final line art (we already have a faint-guide + line-reveal mechanic in the trace runner). + +Good "combine the shapes" subjects (well attested as kid-friendly construction exercises): house +(box + prism), snowman (3 spheres), mug (cylinder + ellipse), ice-cream cone (cone + sphere), +robot (cubes + cylinders), simple car (boxes + circles), cat curled up (circle + ovals), rocket +(cylinder + cone + triangles). + +### 3c. Proportion — relative size and placement + +Keep it concrete and visual, never mathematical: + +- **Big shapes first, details last** — the most repeated rule in proportion teaching. +- **Compare part to part** — "the head is about as wide as the body is tall," "the handle is half + the cup." Show this with on-canvas guide marks rather than a ruler. +- **Center / symmetry lines** — a vertical guide for symmetric subjects (a vase, a face-on robot). +- **Baseline / ground line** — the schematic-stage hallmark: objects sit *on* something, not float. + +### 3d. Base color & first light + +- **Flat color inside the lines** (we already have the coloring studio + fill bucket). +- **Primary → secondary**: a tiny, playful color-mixing idea (red + yellow = orange) surfaced as a + tip, since 5–9 year-olds are exactly the audience for first color theory. +- **Light side / shadow side**: pick a light direction (a ☀️ marker), keep the side facing it lighter + and the far side a touch darker — *one* darker tone, not a gradient. This is the gateway drug to the + Learner shading module and keeps the levels continuous. + +## 4. How step-by-step apps ramp difficulty (and what to copy) + +The competitor scan reinforces the structure above: + +- **Themed modules, each with ~5 lessons of gradually increasing difficulty** (e.g., "Let's Learn How + to Draw!" modules; SimplyDraw's Animals/Nature/Characters paths). We should group Beginner into a + few themed packs rather than one flat list. +- **Skill-based ladder**: ArtWorkout's published progression is literally *shapes & lines → doodles & + sketches → confident line work → shading, perspective, anatomy*. Beginner is the "confident line + work + first form/color" rung — we're aligned. +- **Self-check / "ready to move on?"**: some apps (Drawy) grade the attempt and tell the child to + advance or practice more. We can do a lightweight, **fully on-device** version (e.g., a gentle + self-rating or an optional shape-overlap check) without any data collection. +- **"Draw on your own" fade-out**: apps gradually remove the prompts so the child eventually draws + unaided. Beginner can start fading guides (e.g., the final lesson of a pack shows fewer + construction hints), setting up the freer Learner/Superb levels. + +Our differentiators stay intact: **free, open source, zero data collection, self-hostable, +tablet-first** — all of the above is doable client-side with the stack we have. + +## 5. Proposed Beginner curriculum + +### 5a. Lesson shape — **4 phases (decided)** + +Early Beginner uses **Outline → Detail → Color it!**. Beginner adopts a **4-phase** flow per subject: + +1. **Construct** — lay in the basic forms/shapes (faint guides, the "make it 3-D" move). *New.* +2. **Outline** — refine the guides into clean line art (as today). +3. **Color** — flat base color inside the lines, with the primary/secondary tip (as today's "Color it!"). +4. **Light** — add the single light/shadow side using the ☀️ marker. *New, bridges to Learner.* + +This is backward-compatible with our model: each phase is its own lesson with its own sublevel, and +the construction guides reuse the existing faint-guide + line-reveal mechanics. (Badging is now +**per-pack** rather than per-subject — see §5b/§8.) + +### 5b. Subjects — **3 packs, all-new, shapes-focused (decided)** + +Per Dan's direction: **3 packs to start, all-new subjects, focused on shapes/forms** (no reuse of the +Early Beginner animals — more animals will be added to *Early Beginner* later, separately). Each pack +ramps in difficulty and earns **one per-pack completion badge** when all its subjects are finished. + +The three packs form a clean difficulty ladder: **single form → combined forms → multi-part objects.** + +| Pack | Subject (form → themed object) | Teaches (form) | Skill focus | +| --- | --- | --- | --- | +| **Pack 1 — Make it 3-D** (single forms) | Beach ball | Sphere | First 2-D→3-D; round shadow | +| | Gift box | Cube | Depth edges; cast shadow | +| | Mug | Cylinder | Ellipse rim; handle proportion | +| | Party hat | Cone | Ellipse base; first light side | +| **Pack 2 — Put it together** (combine 2–3 forms) | Ice-cream cone | Cone + sphere | First *combined* form | +| | Snowman | Stacked spheres | Proportion (3 sizes), baseline | +| | House | Box + pyramid roof | Combining; symmetry line | +| | Rocket | Cylinder + cone + fins | Combining + simple overlap | +| **Pack 3 — Build a thing** (multi-part construction) | Robot | Cubes + cylinders | Multi-part construction | +| | Car | Boxes + circles | Proportion, wheels on a baseline | +| | Castle | Cubes + cones/pyramids | Repetition + symmetry | +| | Sailboat | Triangles + curved hull | Combine + color + first light | + +**Core-form choice:** the opening pack teaches the universal trio **sphere, cube, cylinder** (the most +reusable primitives — nearly every later subject decomposes into them), with the **cone** added last as +the bridge into combining. The **pyramid** is deliberately held for packs 2–3 (house roof, castle), +where the lesson is combining/repeating forms rather than a first 2-D→3-D drill. + +Sequencing within the level: **single form → two/three combined forms → multi-part construction**, +each subject running the full Construct → Outline → Color → Light flow. Difficulty rises exactly the +way the app research recommends (themed packs, ~4 lessons each, gradual ramp, guides fading toward the +end of each pack). + +**Badge model (decided): per-pack completion badge.** Finishing every subject in a pack earns that +pack's badge (3 Beginner badges total), each with a shareable PNG + award PDF like the existing +badges. (This differs from Early Beginner's per-subject badges.) + +### 5c. Continuity — how it builds on Early Beginner and feeds Learner + +- **Unlock gate (decided):** the Beginner level stays locked until the child completes **at least 5 + Early Beginner items**. The intent is that the Early Beginner lessons give the child the "look and + feel" of how DrawIt works (the trace/line-reveal flow, coloring studio, badges, gallery) before + stepping up in complexity. Implementation note for later: count completed Early Beginner subjects + (or lessons) server-side, the same place we already gate phase progression, and grey out the + Beginner level on the Learn page with an encouraging "finish 5 Early Beginner drawings to unlock" + message until the threshold is met. +- **Looks back:** Beginner subjects are **all-new and shape-focused** (no reprise of the Early + Beginner animals). More *animals* will be added to **Early Beginner** later as a separate effort, so + the two levels stay distinct: Early Beginner = recognizable subjects & confident line; Beginner = + form, construction, proportion, base color. +- **Looks forward:** the "light side / shadow side" phase is intentionally the simplest possible + version of the Learner shading module (highlight / mid-tone / shadow, the sphere drill). A child who + finishes Beginner has already met a light source and one shadow tone, so Learner is a deepening, not + a brand-new idea. + +## 6. Feature / data-model implications (for later — not building now) + +Flagging what this level would *need* so the eventual build is scoped, consistent with the +already-proposed extensions in the prior research doc: + +- A **`construct` step kind** (faint, non-graded guide shapes) — we already have `kind` on steps and a + faint-guide render path; this mostly reuses it. +- A **base-color phase** — already covered by the coloring studio + fill bucket. +- A **light-source overlay** (☀️ marker + light/shadow side hint) — a simpler cousin of the shading + zone overlay proposed for Learner. +- **Proportion guide marks** — light center/baseline lines and "this part = half that part" hints in + the construct step. +- **Optional on-device self-check** ("ready to move on?") — purely local, no scoring sent anywhere. +- **Guide fade-out** toward the end of each pack — a per-lesson flag to show fewer hints. + +All client-side, all bundled/generated locally. **No new data collection, no third-party services, +still free and open source** — same constraints we've held throughout. + +## 7. Suggested phasing for the Beginner build (when we get there) + +- **Phase A — Forms first:** ship the four "make it 3-D" form drills + the four single-form subjects + (ball, cup, box, cone) using the existing Outline→Color flow plus a new faint `construct` step. +- **Phase B — Construction:** the "Build it" pack (snowman, house, rocket, robot) — combining shapes, + proportion guides, baseline. +- **Phase C — Form + color + first light:** the living-things pack and the optional light/shadow + phase; this is the explicit hand-off into the Learner shading module. + +## 8. Decisions (locked June 2026) + +These are settled and drive the eventual build: + +1. **Phases:** **4 phases** per subject — **Construct → Outline → Color → Light**. +2. **Packs:** **3 packs** to start (§5b): *Make it 3-D* → *Put it together* → *Build a thing*, ~4 + subjects each, ramping single form → combined forms → multi-part construction. +3. **All-new, shapes-focused subjects** — no reprise of the Early Beginner animals. (More animals will + be added to *Early Beginner* later, as a separate effort.) +4. **Unlock gate:** Beginner opens only after the child completes **≥ 5 Early Beginner items**, so they + get the DrawIt "look and feel" first. Beginner is greyed out with an encouraging unlock prompt until + then. +5. **Badges:** **per-pack completion badge** (3 Beginner badges total), each with shareable PNG + + award PDF — not per-subject. + +Still open (smaller, can be decided at build time): whether to include a lightweight, fully on-device +"ready to move on?" self-check, and the exact art direction for the 3 pack badges. + +## Sources + +- [Lowenfeld's Stages of Artistic Development — The Virtual Instructor](https://thevirtualinstructor.com/blog/the-stages-of-artistic-development) +- [Schematic Stage of Drawing for Children — Learning For A Purpose](https://learningforapurpose.com/schematic-stage/) +- [Stages of Artistic Development — Where Creativity Works](https://wherecreativityworks.com/stages-of-artistic-development/) +- [Basic forms (cube, sphere, cylinder, cone) — Fiveable, Drawing I](https://fiveable.me/drawing-foundations/unit-5/basic-forms-cube-sphere-cylinder-cone/study-guide/OGG3MWqfJGt1tDn6) +- [The 3 Basic 3D Forms (Cube, Sphere, Cylinder) — The Design Sketchbook](https://www.thedesignsketchbook.com/how-to-draw-the-3-basic-forms-of-design-sketching-cube-sphere-cylinder/) +- [How to Draw the 4 Basic Forms in 3D — The Design Sketchbook](https://www.thedesignsketchbook.com/how-to-draw-the-4-basic-forms-in-art-and-design/) +- [Drawing Basics — Construction — The Virtual Instructor](https://thevirtualinstructor.com/blog/drawing-basics-construction) +- [How to draw basic shapes — Creative Bloq](https://www.creativebloq.com/illustration/how-draw-basic-shapes-31619534) +- [How to Teach Kids to Draw Using Shapes — HubPages](https://discover.hubpages.com/art/How-to-Teach-Kids-to-Draw-Using-Shapes) +- [Easy Drawing Pictures from Shapes for kids — KidsTut](https://kidstut.com/easy-drawing-pictures-from-shapes/) +- [How to Measure Relative Proportions with a Pencil — Life Drawing Academy](https://lifedrawing.academy/life-drawing-academy-news/how-to-measure-relative-proportions-with-a-pencil) +- [Teaching Proportions in Art to Younger Students — Kymberli Grant](https://kymberligrant.com/2022/01/10/teaching-proportions-in-art-to-younger-students/) +- [Lesson 4: How to Draw with Accurate Proportions — RapidFireArt](https://rapidfireart.com/2017/05/17/lesson-4-how-to-draw-with-accurate-proportions/) +- [How to Teach Color — Deep Space Sparkle](https://www.deepspacesparkle.com/how-to-teach-color/) +- [Color Theory for Kids — Monkey Pen](https://monkeypen.com/blogs/news/teaching-color-theory-to-kids) +- [Colour Theory for Kids — Little Change Creators](https://littlechangecreators.com/blogs/blog/colour-theory-for-kids) +- [Simply Draw: Learn to Draw — App Store](https://apps.apple.com/us/app/simply-draw-learn-to-draw/id1639875485) +- [ArtWorkout: Learn How to Draw — App Store](https://apps.apple.com/us/app/artworkout-learn-how-to-draw/id1564657118) +- [11 Best Apps to Draw Step By Step (2026) — Freeappsforme](https://freeappsforme.com/apps-to-draw-step-by-step/) +- [The 11 Best Drawing Apps for Kids — PureWow](https://www.purewow.com/family/best-drawing-apps-for-kids) -- 2.34.1 From 5d961aaca0f4ac1874b5e84cd729337fbc389067 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:11:10 +0000 Subject: [PATCH 24/29] Add research doc for Early Beginner subject expansion --- docs/RESEARCH-early-beginner-expansion.md | 167 ++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/RESEARCH-early-beginner-expansion.md diff --git a/docs/RESEARCH-early-beginner-expansion.md b/docs/RESEARCH-early-beginner-expansion.md new file mode 100644 index 0000000..fec99a7 --- /dev/null +++ b/docs/RESEARCH-early-beginner-expansion.md @@ -0,0 +1,167 @@ +# DrawIt — Research: Expanding the Early Beginner Level + +_Researched June 2026. Purpose: figure out the best way to fold a batch of new subjects — +**hearts, triangles, squares, bow ties, football, basketball, clothes (shirts, dresses, socks), +trophy, eyes, faces, cups** — into the existing Early Beginner level without breaking its structure or +duplicating the new Beginner level. Research/planning only; nothing is built here. Builds on +`RESEARCH-drawing-platforms-and-techniques.md` and `RESEARCH-beginner-level.md`._ + +## 1. Where these fit — and the one rule that keeps it coherent + +Early Beginner lives in Lowenfeld's **pre-schematic** world: flat, single, recognizable subjects drawn +with confident lines. Every requested subject fits that world *as long as we keep it flat and simple*. +The pedagogy backs our existing approach almost word-for-word: teach **one shape at a time**, have the +child **trace the outline with a finger, then a pencil** (exactly our line-by-line trace runner), and +then **"think in shapes" to build objects**. So the new subjects don't need a new mechanic — they need +to be slotted into the existing **Outline → Details → Color it!** format. + +**The one rule:** keep Early Beginner versions *flat 2-D, front-on, no ellipses or depth*. This is what +separates them from the Beginner level, which deliberately teaches the same forms *with* 3-D volume. +Three of the requested subjects overlap with Beginner and must be drawn differently: + +| Subject | Early Beginner (flat) | Beginner (form) — already built | +| --- | --- | --- | +| **Cup** | A flat front-on cup: tapered sides + a handle, straight top line | **Mug** = cylinder with an elliptical rim | +| **Basketball** | A flat circle + curved seam lines | **Beach ball** = sphere with shaded panels | +| **Football** | A flat pointed oval + laces (American football) | (n/a — no conflict if we keep it American football, not a round soccer ball) | + +Keeping the EB versions flat means a child *feels* the level-up when the same object returns in Beginner +with real volume. That's a feature, not redundancy — worth saying so in the lesson copy. + +## 2. The new subjects, grouped and sequenced + +With the new objects **and** a batch of ~19 animals (below), Early Beginner grows from six subjects to +nearly forty. The pedagogy ("introduce one shape, then the next, then build objects from them") and every +competitor app point to the same answer: **group Early Beginner into themed groups with a gentle +difficulty ramp.** Proposed groups, easiest → hardest: + +| Group | Subjects | Why here / teaches | +| --- | --- | --- | +| **1. Simple Shapes** (new, foundational) | Square, Triangle, Heart | The literal building blocks; pure confident-line practice. The natural *first* thing a brand-new artist draws. | +| **2. Animals & Nature** (existing classics) | Fish, Panda, Flower, Unicorn, Tree, Dinosaur | The current core — recognizable subjects from combined shapes. | +| **3. Safari Animals** (new) | Lion, Elephant, Giraffe, Zebra, Rhino, Leopard, Tiger, Monkey, Crocodile | Bigger animals from ovals + legs; pattern practice (stripes, spots). | +| **4. Woodland Critters** (new) | Fox, Bear, Deer, Rabbit, Hedgehog, Owl, Cat, Turtle | Small rounded animals; cozy, high-appeal, very simple silhouettes. | +| **5. Up in the Air** (new) | Bird, Butterfly | Tiny, symmetric, great confident-curve practice. | +| **6. Fun Things** (new, objects) | Bow tie, Football, Basketball, Cup, Trophy | "Things are made of shapes": two triangles = bow tie, circle + seams = basketball, etc. | +| **7. Things to Wear** (new, clothes) | Shirt, Dress, Socks | Flat front-on garments; simple symmetric outlines + one detail (collar/hem/stripe). | +| **8. Faces & Features** (new, capstone) | Eyes, Face | The hardest EB skill — symmetry, placement, proportion. Do **Eyes first**, then **Face**. | + +### About the ~19 animals (from Dan's reference picture) + +The reference image is a stock/commercial illustration, so we will **not trace or copy it** — that would +lift someone else's copyrighted artwork. Instead we draw our **own original** simple line-art versions of +the same animals in DrawIt's existing rounded doodle style (poses of our choosing). The animal *subjects* +themselves aren't protectable; the specific drawing is. New animals (none overlap with the existing six): +**owl, monkey, bird, butterfly, leopard, fox, rabbit, tiger, turtle, bear, deer, zebra, crocodile, cat, +hedgehog, lion, giraffe, rhino, elephant.** They use the same 3-phase format and per-subject badge as +every other EB subject; they're split across the Safari / Woodland / Up-in-the-Air groups above purely so +the list stays browsable. + +This keeps **Fish → Outline as the free flagship lesson** (unchanged) while giving newcomers an even +gentler on-ramp (Simple Shapes) and a satisfying capstone (Faces). Within Faces, eyes are taught before +the face because the face lesson reuses them, mirroring how face tutorials build the eyes on an eye-line. + +### Per-subject sketch (shapes → what each phase does) + +Each subject keeps the **3-phase** EB format. "Detail" is where we make a plain shape *fun*. + +- **Square / Triangle / Heart** — Outline: draw the shape (trace it). Details: give it a cute face + (two eye dots + a smile) so even a square has personality. Color: fill it in. _(This is how we justify + 3 phases for a trivial shape — see §3.)_ +- **Bow tie** — Outline: two triangles meeting at a center knot. Details: a small square knot + fold + lines. Color: pick a color + polka dots. +- **Football (American)** — Outline: a pointed oval. Details: center lace stripe + stitches. Color: brown. +- **Basketball** — Outline: a circle. Details: one vertical + one horizontal seam, two side curves. + Color: orange. +- **Cup** — Outline: flat tapered cup body + straight top line + a handle. Details: a rim line + a + steam swirl. Color. _(Flat — contrast with Beginner's cylinder mug.)_ +- **Trophy** — Outline: a cup bowl on a stem + base. Details: two handles + a "★/#1" + shine marks. + Color: gold. +- **Shirt** — Outline: center line → shoulder line → trapezoid body → two sleeves → neckline (the exact + beginner method from the research). Details: collar + a pocket or stripe. Color. +- **Dress** — Outline: bodice + A-line skirt (triangle-ish). Details: waistline + neckline + hem. Color. +- **Socks** — Outline: an L-shaped tube. Details: cuff line + heel/toe + a stripe. Color. +- **Eyes** — Outline: two almond shapes on a line, one eye-width apart. Details: iris + pupil + a white + highlight + lashes/brows. Color. _(Teaches the "eyes sit halfway, spaced one eye apart" rule.)_ +- **Face** — Outline: a round head + faint center line + eye line. Details: eyes (on the line), nose + (halfway to chin), mouth (a third below nose), ears, hair. Color. _(The EB capstone.)_ + +## 3. Phase handling — the one real design question + +For animals the 3-phase format is obviously right. For a **square or a heart**, "Outline → Details → +Color" risks feeling padded. Two options: + +- **(A) Keep all subjects 3-phase (recommended).** Make the *Details* phase "make it cute" — add a tiny + face or pattern. This keeps the data model, gating, badges, and UI perfectly uniform, and turns a + boring shape into something a kid wants to finish and color. Pedagogically fine: the outline phase is + pure shape practice; details add confident small marks. +- **(B) Add a one-step "warm-up" lesson type** for pure shapes (single phase, no detail/color). More + faithful to "it's just a square," but it's a new lesson shape to special-case in the runner, learn + page, gating, and badges — more code for little gain. + +Recommendation: **(A)**. It's zero new mechanics and more fun. Flagged as a decision in §6. + +## 4. How to implement it in the codebase (no schema change) + +Everything reuses the patterns the Beginner build just established. + +- **Data model:** each new subject = **three `Lesson` entries** (outline/detail/extra) in + `src/lib/curriculum.ts`, identical in shape to the existing Fish/Panda/etc. Per-subject badge on the + **Detail** lesson (`badgeKey: "early-beginner--"`), exactly as today. **Early Beginner keeps + per-subject badges** (not the per-pack badges we used for Beginner) — these are bite-size wins for the + youngest users. +- **Sublevels:** continue the existing `subjectIndex*10 + phase` scheme (phase 1/2/3). New subjects get + indices 7+ → sublevels **71/72/73, 81/82/83, …**. Sublevels are namespaced by `level`, so an EB sublevel + never collides with a Beginner one (they differ by `level`). Assign indices by group order from §2. +- **Grouping (the only new UI work):** rather than tag every lesson, add a small **groups config** keyed + by `subjectKey` (e.g. `EARLY_BEGINNER_GROUPS = [{ key, name, emoji, subjectKeys[] }]`) plus a + `getGroupedSubjects(level)` helper that buckets `getSubjects()` output into ordered groups (anything + unlisted falls into a default group). The Learn page then renders Early Beginner as `
` group + sections — the same accordion treatment Beginner packs use, **minus** the pack badge (EB keeps + per-subject badges). No per-lesson field and no edits to the existing six lessons required. +- **Art:** extend the existing generator approach (`outputs/gen_beginner.py` / the earlier `gen4` + pattern) — define each subject's `lines` as simple geometric SVG, render a **cairosvg montage** to + verify, then emit the curriculum entries. These subjects are mostly primitives (circle, oval, triangle, + trapezoid, almond) so the art is quick and low-risk. +- **Badges/awards:** no changes — the emoji-medal badge art and award PDF already work for any + per-subject badge. +- **Unlock gate:** unaffected. Beginner still opens after 5 completed EB lessons; more EB content just + gives kids more ways to get there. (Optionally bump the threshold later, but no need.) +- **Free lesson:** unchanged — `fish-outline` stays the one free, no-account lesson. +- **Landing page:** today it shows every EB subject as a teaser grid. With ~19 subjects that's a wall — + recommend the landing show only **Group 1 (Simple Shapes)** plus an "…and lots more" link, so the + marketing page stays tight. Small change in `src/app/page.tsx`. + +## 5. Suggested rollout phasing (when we build) + +- **Phase A — Grouping UI + Simple Shapes** (square, triangle, heart). Proves the group treatment. +- **Phase B — Fun Things** (bow tie, football, basketball, cup, trophy). +- **Phase C — Things to Wear** (shirt, dress, socks). +- **Phase D — Faces & Features** (eyes → face), the capstone. +- **Phase E — Animals** (the ~19 originals, across Safari / Woodland / Up-in-the-Air). Largest art effort; + done last because each animal is an organic doodle (more drawing work than the geometric subjects). + +## 6. Open decisions for Dan + +1. **Phase handling for pure shapes** — go with option (A): keep all subjects 3-phase, with a "make it + cute" Details step? (Recommended.) +2. **Grouping** — add the lightweight EB group UI (Simple Shapes / Animals & Nature / Fun Things / Things + to Wear / Faces), or keep Early Beginner as one flat list? +3. **Football** — American football (pointed oval + laces, no overlap with basketball), or a round soccer + ball (which would duplicate the basketball circle)? (Recommend American football.) +4. **Group order** — put **Simple Shapes first** (before the existing Animals), as recommended, or keep + the existing animals first and append the new groups after? +5. **Clothes scope** — three garments (shirt, dress, socks) to start, or add more later (hat, shoes)? + +## Sources + +- [How to Draw a Face for Kids — Art Projects for Kids](https://artprojectsforkids.org/how-to-draw-a-face/) +- [How to Draw a Face: Facial Proportions — The Virtual Instructor](https://thevirtualinstructor.com/facialproportions.html) +- [How to Draw a Face — How to Draw for Kids](https://howtodrawforkids.com/how-to-draw-a-face/) +- [How to Draw a Nose and Eyes — How to Draw for Kids](https://howtodrawforkids.com/how-to-draw-a-nose-and-eyes/) +- [Teaching Shapes to Preschoolers — ABCJesusLovesMe](https://www.abcjesuslovesme.com/ideas/teaching-shapes) +- [Discovering Shapes and Space in Preschool — NAEYC](https://www.naeyc.org/resources/pubs/tyc/apr2014/discovering-shapes-and-space-preschool) +- [Exploring Shapes — Mensa for Kids](https://www.mensaforkids.org/teach/lesson-plans/exploring-shapes/) +- [How to Draw a Dress — How to Draw for Kids](https://howtodrawforkids.com/how-to-draw-a-dress/) +- [How To Draw Clothes Step By Step (For Kids & Beginners)](https://how-to-drawa.com/clothes/) +- [Easy Clothes Drawing Tutorials — Easy Drawing Guides](https://easydrawingguides.com/easy-clothes-drawing-tutorials/) -- 2.34.1 From 21bf3459a77381e722bbb460b0e3ec99c0b17ba4 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:11:11 +0000 Subject: [PATCH 25/29] Add research doc on drawing platforms and techniques --- ...SEARCH-drawing-platforms-and-techniques.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/RESEARCH-drawing-platforms-and-techniques.md diff --git a/docs/RESEARCH-drawing-platforms-and-techniques.md b/docs/RESEARCH-drawing-platforms-and-techniques.md new file mode 100644 index 0000000..ed1b848 --- /dev/null +++ b/docs/RESEARCH-drawing-platforms-and-techniques.md @@ -0,0 +1,173 @@ +# DrawIt — Research: Teaching Platforms & Techniques (Shading, Layering, and More) + +_Researched June 2026. Purpose: see what SimplyDraw and similar apps do well, understand how +drawing is actually taught (especially shading and layering), and lay out concrete ways to fold +those ideas into DrawIt while staying free, open-source, and privacy-first._ + +## 1. What the competition does + +**Simply Draw** (by Simply / JoyTunes, the Simply Piano people) is the closest match to our vision +and the most polished. Its pitch is "draw like you always wanted," and the teaching model has three +pillars: + +- **Guided every step of the way** — each drawing is broken into clear, easy-to-follow steps so + "anyone can create something they're proud of." This is the part DrawIt already does. +- **Bring drawings to life with shading** — they explicitly frame shading as the thing that "adds + depth, dimension, and brings sketches to life," and walk learners from studying a reference image + all the way through to shading, with smart tips on proportion, shading, and texture. +- **Spark imagination** — every tutorial pushes creative thinking and personal expression, not just + copying. They also use video sessions led by professional artists that you can pause, rewind, and + draw along with, plus a "personalized path" and weekly new content. + +Other platforms worth borrowing from: + +| Platform | Audience | What it does well | What we can borrow | Gap we can beat | +| --- | --- | --- | --- | --- | +| **Simply Draw** | Kids→adult | Step-by-step + shading + texture + reference study; pausable artist videos; personalized path | Shading module, reference-image study, replayable demos | Paid subscription; not open; collects data | +| **Drawing Desk** | Kids→advanced | Step lessons with on-screen guides + **text and voice** guides; themed tracks (anime, kawaii, doodle); beginner→refine | Multi-modal guidance (visual + voice + text); themed lesson packs | Ads / IAP; not private | +| **Project Aqua** (Adobe) | Ages 5–12 | Completely **free, ad-free, family-focused**; color premade pages or your own sketch; step-by-step tutorials | Free/ad-free family stance (matches us); color-the-sketch mode | Closed source; Adobe account/ecosystem | +| **Kids Doodle** | Ages 2–8 | Very simple; themed coloring pages; **video replay** of how the art was made | Stroke **replay** is a delightful, low-cost feature; big simple UI | Little real skill progression | +| **Draw.ai** | Kids | Pick artwork, recreate it with **animated** step-by-step instructions | Animated (not just static) step guides | Thin on fundamentals | + +The recurring winning ingredients: guided steps (we have it), **shading taught explicitly**, **reference +study**, **replayable/animated demos**, multi-modal cues (show + tell), themed packs, and a creative +"make it your own" finish. Our differentiators stay strong: **free, open source, zero data +collection, self-hostable, tablet-first.** + +## 2. How drawing is actually taught (the pedagogy) + +Art instruction is remarkably consistent about the order things should be learned. DrawIt's five +ability levels line up well with it; we just need to attach the right *techniques* to each level. + +**Work broad → specific ("blocking-in").** Every strong drawing starts by establishing big basic +shapes to nail proportion, placement, and composition *before* any detail. Construction shapes +(ovals, boxes, cylinders) come first; refinement comes later. DrawIt's fish/panda/etc. already teach +this implicitly — we should make it explicit and name it. + +**The fundamentals, in rough teaching order:** + +1. **Line & mark-making** — confident lines, contours, and the different marks (crisp outline vs. + light construction line vs. hatching stroke). Each mark has a purpose. +2. **Shape → Form** — turning flat shapes into 3-D volumes (circle → sphere, square → cube). +3. **Value & shading** — light and shadow. This is the big one (see below). +4. **Texture** — implying surface (fur, scales, bark) with marks. +5. **Proportion & perspective** — relative sizes, foreshortening, simple 1-point perspective. +6. **Color** — basic color theory, then color + shading together. + +**Shading, specifically** (what SimplyDraw leans on). You teach it by introducing a **light source** +and the three value zones every form has: **highlight, mid-tone, shadow** (plus cast shadow). The +core techniques, easiest to hardest: + +- **Tonal/blended shading** — smooth gradients from light to dark. +- **Hatching** — parallel lines; closer together = darker, farther apart = lighter. +- **Cross-hatching** — overlapping sets of lines; denser crossings = darker. +- **Stippling** — dots; denser = darker. + +The pro move taught everywhere: **curve your hatching to follow the form's surface**, space lines +wider in light areas and tighter in shadow, and **build up gradually** in passes. The classic +practice object is a sphere lit from one side — exactly the kind of single-shape drill we can ship. + +**Layering** — this word has two useful meanings, and we want both: + +- **Pedagogical layering (process):** a drawing is built up in stages — + **construction/under-drawing → refined line art → base color → shading/details.** This is the + professional workflow (underdrawing → underpainting → overpainting → glazes), simplified for kids. + Teaching the *process* of layers is as important as teaching shapes. +- **Technical layering (tool):** in digital art, **layers** are stacked transparent sheets — sketch + on one, line art on another, color below the lines, shading on top. Being able to draw the + construction loosely and then ink/color *without destroying it* is what makes the process above + actually learnable. This is a feature we can build into the canvas. + +## 3. How to incorporate this into DrawIt + +### 3a. Map techniques onto our five levels + +Our levels already describe the learner; here's the technique each one should introduce, so the +curriculum has a real backbone instead of just "more lessons." + +- **Early Beginner** — simple shapes & confident lines (what we have today: fish, panda, flower, + unicorn, tree, dinosaur). Add one idea: **construction first** (draw the light guide shapes, then + the outline). +- **Beginner** — **shape → form** and **base color**: turn the same shapes into 3-D (sphere, cube, + cylinder), introduce the idea of a light source, color-the-sketch lessons. +- **Learner** — **shading & value**: the sphere drill, highlight/mid-tone/shadow, intro hatching; + this is the "SimplyDraw moment" where drawings start to look real. +- **Advanced Learned** — **layering, texture, proportion**: full construction → line → color → + shade workflow on a multi-part subject; cross-hatching; simple perspective. +- **Superb** — **composition & personal style**: multi-object scenes, free-draw challenges with + light-touch guidance, "make it your own." + +### 3b. Extend the lesson data model (small, backward-compatible) + +Today a lesson is steps with a single cumulative SVG. Add optional fields so a step can declare what +kind of step it is and what it teaches: + +- `kind: "construct" | "outline" | "color" | "shade" | "detail"` — drives UI hints and which canvas + layer the child should be drawing on. +- `targetLayer` — which layer this step belongs to (sketch / line / color / shade). +- `shadeGuide` — optional overlay showing the light source ☀️ and the highlight/mid-tone/shadow + zones for the step (so we can *show* where it gets dark). +- `palette` — for color/shade steps, a small kid-safe swatch set (and a **grayscale value strip** + for shading lessons, since value is taught before hue). + +Because these are all optional, the six existing lessons keep working untouched. + +### 3c. Canvas / tool features to build (in priority order) + +1. **Layers in the drawing stage** (highest leverage — unlocks "layering" properly). Start with a + fixed set — *Sketch, Lines, Color, Shading* — each a transparent canvas with show/hide and + opacity. The construction guide lives on Sketch at low opacity; kids ink on Lines; the lesson can + auto-advance the active layer per step. This is the single feature that most closes the gap with + SimplyDraw and teaches the pro workflow at the same time. +2. **A shading brush** — a soft, low-opacity, pressure-/speed-sensitive brush (stack passes to go + darker), plus a **hatching helper** that snaps stroke direction to follow the form. Add a + light-source marker and faint highlight/shadow zones as a guide overlay. +3. **Value-first palette** — a grayscale strip for value drills and a small color palette for color + lessons; teach value before color, as the books do. +4. **Reference panel** — an optional side-by-side reference image (or the finished cumulative SVG) + the child studies before/while drawing, mirroring SimplyDraw's "study the reference → draw → + shade" flow. +5. **Stroke replay / animated steps** — record the lesson's guide as a timeline and play it back as + an animation ("watch it drawn, then you try"). Cheap to do with our SVG steps and matches + Draw.ai / Kids Doodle's most-loved feature. Bonus: kids can replay *their own* drawing. +6. **Multi-modal cues** — we already have text instructions and tips; add optional short audio + narration per step (Drawing Desk's voice guides) using the browser's built-in speech synthesis, + so no files and nothing leaves the device. +7. **"Make it your own" finish** — after the guided steps, a free-draw canvas seeded with their + drawing, so every lesson ends in creative expression (and still earns the badge/award). + +### 3d. Keep our principles + +Everything above can be done client-side with the same stack: extra canvases for layers, +`SpeechSynthesis` for narration, SVG timelines for replay/animation. **No new data collection, no +third-party services, still free and open source.** Reference images and audio are generated locally +or bundled, so nothing about the child leaves the server you run. + +## 4. Suggested roadmap + +- **Phase 1 (quick wins):** name "construction first" in Early Beginner; add per-step `kind`/tip + copy; add stroke **replay** of the guide; add optional speech narration. Small code, big feel. +- **Phase 2 (shading module):** value strip + soft shading brush + light-source/zone overlays; ship + the **sphere** drill and a shaded version of an existing subject (e.g., a shaded fish) as the first + Learner lessons. +- **Phase 3 (layering engine):** the Sketch/Lines/Color/Shading layer system in the canvas, with the + lesson auto-selecting the active layer per step; convert one subject to the full + construct→outline→color→shade workflow as the flagship Advanced lesson. +- **Phase 4 (color + scenes + polish):** basic color theory lessons, reference panel, multi-object + Superb challenges, and a personalized "what do you want to draw?" path. + +## Sources + +- [Simply Draw — official](https://www.hellosimply.com/simply-draw) +- [Simply Draw on the App Store](https://apps.apple.com/us/app/simply-draw-learn-to-draw/id1639875485) +- [Simply Draw review — Research.com](https://research.com/software/reviews/simplydraw-review) +- [7 Best Drawing Apps for Kids — EducationalAppStore](https://www.educationalappstore.com/best-apps/best-drawing-apps-for-kids) +- [Best Drawing Apps for Kids — PureWow](https://www.purewow.com/family/best-drawing-apps-for-kids) +- [Drawing Desk — Google Play](https://play.google.com/store/apps/details?id=com.axis.drawingdesk.v3) +- [Best Drawing Apps for Kids — Adobe Aqua](https://aqua.adobe.com/learn/drawing-apps-for-kids) +- [What are the Drawing Fundamentals? — Fine Art Tutorials](https://finearttutorials.com/guide/drawing-fundamentals/) +- [Shading Techniques — The Virtual Instructor](https://thevirtualinstructor.com/shading-techniques-basics.html) +- [Hatching and cross-hatching — Fiveable](https://fiveable.me/drawing-foundations/unit-6/hatching-cross-hatching/study-guide/SBjr8djW1TCdGeXY) +- [Working in layers — Wikipedia](https://en.wikipedia.org/wiki/Working_in_layers) +- [Underdrawing — Wikipedia](https://en.wikipedia.org/wiki/Underdrawing) +- [Layers (digital image editing) — Wikipedia](https://en.wikipedia.org/wiki/Layers_(digital_image_editing)) -- 2.34.1 From 9880cf40d5bb8dddba262f00235780999a9d2ee3 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:11:11 +0000 Subject: [PATCH 26/29] Add repo guidance file referencing curriculum and design docs --- CLAUDE.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4cffdbe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md — DrawIt + +Guidance for Claude (and humans) working in this repo. + +## What DrawIt is + +A free, open-source, **privacy-first** web app that teaches kids to draw — mobile/iPad-first. +**Non-negotiable principle: no data collection.** No analytics, no trackers, no third-party calls. +All data stays in the operator's self-hosted SQLite. Any feature must preserve this. + +## Stack + +Next.js 15 (App Router) + TypeScript · `better-sqlite3` (WAL) · custom cookie-session auth (scrypt) · +`nodemailer` SMTP · `pdf-lib` (award certificates) · hand-written mobile-first CSS (no UI framework). +Path alias `@/*` → `./src/*` (needs `baseUrl` in tsconfig for the Windows/Next build). + +## How the curriculum works (read before touching lessons) + +- **Levels:** Early Beginner → Beginner → Learner → Advanced Learner → Superb (`LEVELS` in + `src/lib/curriculum.ts`). +- **Subjects & phases:** each subject is a set of `Lesson` entries, one per phase. + - Early Beginner = 3 phases: **Outline → Details → Color it!** (per-subject badge on Details). + - Beginner = 4 phases: **Construct → Outline → Color → Light** (per-**pack** badge). +- **Runners:** trace phases use per-step `lines: string[]` (SVG elements) revealed one at a time by + `LineDraw` inside `TraceRunner`; color/light phases use `baseSvg` rasterized by `ColoringRunner`. +- **Curriculum files:** static lessons live in `src/lib/curriculum.ts` and the generated + `curriculum.beginner.ts`, `curriculum.eb2.ts`, `curriculum.animals.ts`. Art is produced by Python + generators in the scratch outputs dir (cairosvg-rendered montages verify it before wiring). +- **Grouping/gating:** `getGroupedSubjects` + `EARLY_BEGINNER_GROUPS` (EB themed groups); + `src/lib/gating.ts` (Beginner unlocks after 5 EB lessons; pack completion). Per-subject vs per-pack + badges via `badgeDefs()`. + +## Build / verify + +`npm run build` and `npm run typecheck` are the source of truth — **run them locally**. Note: the +Cowork sandbox cannot reliably run them (typescript is a devDep often absent; native `better-sqlite3` +won't compile; shell file reads of the mount can truncate). Verify edits via the file tools and the +cairosvg art montages; do a real build on a normal machine before shipping. + +## Design & research docs (`docs/`) + +Read the relevant doc before building a feature it covers: + +- `docs/RESEARCH-drawing-platforms-and-techniques.md` — competitor scan + drawing pedagogy; maps + techniques to the five levels. +- `docs/RESEARCH-beginner-level.md` — the Beginner level spec (4 phases, 3 packs, unlock gate, + per-pack badges). **Built.** +- `docs/RESEARCH-early-beginner-expansion.md` — the Early Beginner expansion (Simple Shapes, Fun + Things, Clothes, Faces, ~19 animals; themed groups). **Built.** +- **`docs/DESIGN-create-ai-lessons.md` — the "Create" feature + Creator role: type a subject → + ComfyUI generates coloring-book line art → DrawIt turns it into a lesson. Covers the Creator role, + ComfyUI API integration, the image→level pipeline, mandatory kids-safety/moderation, data model, + API routes, and phased rollout. Design only (not built). Read this before implementing Create.** + +Operational/setup docs also live in `docs/` (BUILDING, DOCKER, SYSTEMD, REVERSE_PROXIES, AUTO_STARTING). + +## Conventions + +- Preserve the privacy principle in every change. +- First registered account is forced `admin` + active; everyone else defaults `learner` + `pending` + until email confirmation. Roles: `admin | learner | creator` (creator = learner + the Create ability). +- Adding a lesson subject = append `Lesson` entries (+ art); routes/runners are generic and handle the + rest. Keep sublevels unique within a level (`subjectIndex*10 + phase` for static lessons). +- Images (drawings, avatars, created line art) are stored as data URLs in SQLite for privacy. -- 2.34.1 From 8cd36c3182c7b91bffbee2551518652c1debdb5c Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:11:12 +0000 Subject: [PATCH 27/29] Normalize line endings to LF; mark binary assets --- .gitattributes | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..170e1b5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Normalize line endings: treat files as text and check out with LF. +# Prevents CRLF/LF churn showing whole files as "modified". +* text=auto eol=lf + +# Binary assets — never normalize. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.woff binary +*.woff2 binary -- 2.34.1 From 249167c32177f4309b0184c54fb3f875ed5b4dcf Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:16:51 +0000 Subject: [PATCH 28/29] Ignore .claude agent tooling cache --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b3cd633..9323a25 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ next-env.d.ts # OS .DS_Store Thumbs.db + +# Claude agent tooling / skill cache (not project source) +.claude/ -- 2.34.1 From 011918c62c88aece42f86ec3716f4f97c2407d47 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 29 Jun 2026 09:19:02 -0500 Subject: [PATCH 29/29] Add creator mode --- .env.example | 11 +++++ .gitignore | 3 -- src/app/admin/AdminUsers.tsx | 19 ++++++++- src/app/admin/page.tsx | 25 +++++++++++- src/app/api/admin/users/[id]/route.ts | 4 +- src/app/create/page.tsx | 8 +++- src/app/learn/[level]/[slug]/page.tsx | 58 ++++++++++++++++++++++++++- src/app/learn/page.tsx | 49 +++++++++++++++++++++- src/lib/db.ts | 30 +++++++++++++- src/lib/session.ts | 11 +++++ src/lib/types.ts | 6 ++- 11 files changed, 213 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 191ddfb..2b7be24 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,14 @@ SMTP_SECURE=false SMTP_USER= SMTP_PASS= SMTP_FROM="DrawIt " + +# --- "Create" feature (AI-generated lessons via a self-hosted ComfyUI) --- +# Leave COMFYUI_URL blank to disable the Create feature entirely. +COMFYUI_URL=http://127.0.0.1:8188 +COMFYUI_API_KEY= +# Path to a ComfyUI workflow exported in API format ("Save (API format)"). +COMFYUI_WORKFLOW=./comfyui/coloring-book.workflow_api.json +# Node ids in that workflow to inject into: the positive prompt text, and the sampler seed. +COMFYUI_PROMPT_NODE=6 +COMFYUI_SEED_NODE=3 +COMFYUI_TIMEOUT_MS=120000 diff --git a/.gitignore b/.gitignore index 9323a25..b3cd633 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,3 @@ next-env.d.ts # OS .DS_Store Thumbs.db - -# Claude agent tooling / skill cache (not project source) -.claude/ diff --git a/src/app/admin/AdminUsers.tsx b/src/app/admin/AdminUsers.tsx index be3dc6e..1491fb6 100644 --- a/src/app/admin/AdminUsers.tsx +++ b/src/app/admin/AdminUsers.tsx @@ -111,4 +111,21 @@ export default function AdminUsers({ className="btn ghost" style={btn} disabled={busy || isLastAdmin} - \ No newline at end of file + title={isLastAdmin ? "Can't demote the only admin" : undefined} + onClick={() => act(u.id, "role", "learner")} + > + Make learner + + )} + + + + ); + })} + + + + ); +} + +const btn: React.CSSProperties = { minHeight: 36, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" }; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 2252bcf..abdd5f3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -42,4 +42,27 @@ export default async function AdminPage() {
-
+

Users

+
+ +
+
+ +
+

Created lessons {reviewCreations.length > 0 && {reviewCreations.length} to review}

+
+ +
+
+ +
+

Issue reports

+
+ +
+
+ + + ); +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 342f4b1..080409b 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -49,4 +49,6 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }> return NextResponse.json({ ok: true }); } default: - return NextResponse.json({ error: "Unknown action." }, { status: 400 \ No newline at end of file + return NextResponse.json({ error: "Unknown action." }, { status: 400 }); + } +} diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index a962420..cb587f5 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -24,4 +24,10 @@ export default async function CreatePage({ searchParams }: { searchParams: Promi

✨ Create your own lesson

Type something you'd love to draw and DrawIt will make a brand-new coloring-book lesson for it — - \ No newline at end of file + trace the lines, then color it in. +

+ + + + ); +} diff --git a/src/app/learn/[level]/[slug]/page.tsx b/src/app/learn/[level]/[slug]/page.tsx index 148c721..d20f0c8 100644 --- a/src/app/learn/[level]/[slug]/page.tsx +++ b/src/app/learn/[level]/[slug]/page.tsx @@ -115,4 +115,60 @@ export default async function LessonPage({ params }: { params: Promise<{ level: {isShade ? ( ) : ( - + )} + + + ); +} + +// Render an AI-created lesson (Trace → Color), built on the fly from its DB row. +async function renderCreated(level: string, slug: string) { + const resolved = resolveCreatedLesson(slug); + if (!resolved || resolved.row.level !== level) notFound(); + const { lessons, index, row } = resolved; + + const user = await getCurrentUser(); + if (!user) redirect("/signup"); + // Visible to the owner, anyone if promoted to global, or an admin. + const canView = row.user_id === user.id || row.promoted === 1 || user.role === "admin"; + if (!canView) notFound(); + if (!isLevelUnlocked(user.id, level)) redirect("/learn"); + + const lesson = lessons[index]; + const prev = index > 0 ? lessons[index - 1] : undefined; + const stepCount = (l: Lesson) => l.steps.length || 1; + // Gate Color behind a completed Trace. + if (prev && getCompletedSteps(user.id, prev.level, prev.sublevel).length < stepCount(prev)) { + redirect(`/learn/${level}/${prev.slug}`); + } + + const levelName = getLevel(level)?.name ?? ""; + const completed = getCompletedSteps(user.id, lesson.level, lesson.sublevel); + const common = { level: lesson.level, levelName, sublevel: lesson.sublevel, title: lesson.title, emoji: lesson.emoji, intro: lesson.intro }; + + if (lesson.phase === "outline") { + const steps = lesson.steps.map((s) => ({ n: s.n, title: s.title, instruction: s.instruction, tip: s.tip ?? "", lines: s.lines ?? [] })); + const meta = { ...common, badgeKey: "", badgeName: lesson.badgeName, baseSvg: lesson.baseSvg ?? "", phaseLabel: "Trace" }; + return ( + <> + +
+ +
+ + ); + } + + // Color phase + const userSketch = prev ? (getLatestDrawing(user.id, level, prev.sublevel) ?? "") : ""; + const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel: "Color" }; + return ( + <> + +
+ +
+ + ); +} diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index 860aa40..c034bfc 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -182,4 +182,51 @@ export default async function LearnPage() { // Early Beginner: themed groups, each holding its subjects.
{creationsSection(lvl.key)} - {getGroupedSubjects(lvl.key).map((group) = \ No newline at end of file + {getGroupedSubjects(lvl.key).map((group) => { + const groupDone = group.subjects.every((s) => subjectComplete(s)); + return ( +
+
+ {group.emoji} {group.name} + {group.subjects.filter((s) => subjectComplete(s)).length}/{group.subjects.length} done +
+
+ {group.subjects.map((subj) => subjectAccordion(subj, subj.key === firstIncomplete))} +
+
+ ); + })} +
+ ) : ( + // Any future flat level +
+ {subjects.map((subj) => { + const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase; + if (!phased) { + const l = subj.lessons[0]; + const done = isDone(l); + const inProg = !done && doneCount(l) > 0; + return ( + +
+ {subj.emoji} {l.title} + {done ? ✓ Done : inProg ? Step {Math.min(doneCount(l) + 1, lessonStepCount(l))}/{lessonStepCount(l)} : {lessonStepCount(l)} steps} +
+ + ); + } + return subjectAccordion(subj, subj.key === firstIncomplete); + })} +
+ )} + + ); + })} + + + + ); +} + +const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 }); +const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" }; diff --git a/src/lib/db.ts b/src/lib/db.ts index 6cb07c2..0af45a5 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -138,4 +138,32 @@ function migrate(db: Database.Database) { CREATE TABLE IF NOT EXISTS created_lessons ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, -- null once promoted to global - level \ No newline at end of file + level TEXT NOT NULL, -- 'early-beginner' | 'beginner' + subject TEXT NOT NULL, -- sanitized, e.g. 'Zebra' + slug TEXT NOT NULL UNIQUE, -- e.g. 'created-42-zebra' + status TEXT NOT NULL DEFAULT 'pending', -- pending|generating|needs_review|ready|failed|blocked + template_svg TEXT, -- vectorized line art (potrace), fitted to 0 0 400 300 + image TEXT, -- cleaned raster line art (data URL) — optional/debug + prompt TEXT NOT NULL, -- full prompt sent to ComfyUI (audit) + moderation TEXT, -- 'allowlist' | 'review' | 'blocked:' | 'approved' + emoji TEXT NOT NULL DEFAULT '🎨', + error TEXT, -- failure reason if status='failed' + promoted INTEGER NOT NULL DEFAULT 0, -- 1 = part of the shared/global curriculum + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_created_user ON created_lessons(user_id, level); + CREATE INDEX IF NOT EXISTS idx_created_status ON created_lessons(status); + `); + + // Profile fields (added later — safe for existing databases) + addColumn(db, "users", "display_name", "TEXT"); + addColumn(db, "users", "avatar", "TEXT"); +} + +export function getDb(): Database.Database { + if (!global.__drawitDb) { + global.__drawitDb = createConnection(); + } + return global.__drawitDb; +} diff --git a/src/lib/session.ts b/src/lib/session.ts index 460a7bd..b153dd5 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -59,3 +59,14 @@ export async function requireAdmin(): Promise { if (!user || user.role !== "admin") return null; return user; } + +/** True if the user may use the "Create" feature (Creator or Admin). */ +export function canCreate(user: { role: string } | null | undefined): boolean { + return !!user && (user.role === "creator" || user.role === "admin"); +} + +/** Guard for Create endpoints — returns the user iff they can create, else null. */ +export async function requireCreator(): Promise { + const user = await getCurrentUser(); + return canCreate(user) ? user : null; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 93f856f..7d6d442 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,4 +52,8 @@ export function toPublicUser(u: User): PublicUser { level: u.level, status: u.status, email_verified: !!u.email_verified, - display_name: u.di \ No newline at end of file + display_name: u.display_name ?? null, + avatar: u.avatar ?? null, + created_at: u.created_at, + }; +} -- 2.34.1