[Feature] comfyui creator #12

Merged
iamdoubz merged 29 commits from feature_comfyui_creator into main 2026-06-29 09:22:09 -05:00
29 changed files with 1948 additions and 8 deletions
+11
View File
@@ -15,3 +15,14 @@ SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM="DrawIt <no-reply@example.com>"
# --- "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
+13
View File
@@ -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
+64
View File
@@ -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.
+28
View File
@@ -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.
+41
View File
@@ -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"
}
}
+261
View File
@@ -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: <workflow_api_json>, 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 34-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:<reason>'
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-<id>`; `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/)
+266
View File
@@ -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 | 46 | Circular figures, single objects floating in space, flat shapes | **Early Beginner** (where we are) |
| Schematic | 79 | Understanding of space and proportion, baselines, repeatable "symbols" for things | **Beginner** (this doc) |
| Dawning Realism | 911 | 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 59 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 23 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 23 (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)
@@ -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 512 | 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 28 | 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))
+167
View File
@@ -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-<n>-<name>"`), 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 `<details>` 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 -3
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface Row {
id: number;
subject: string;
level: string;
status: string;
slug: string;
emoji: string;
}
export default function AdminCreations({ review, ready }: { review: Row[]; ready: Row[] }) {
const router = useRouter();
const [busy, setBusy] = useState<number | null>(null);
async function act(id: number, action: "approve" | "block" | "promote") {
setBusy(id);
try {
await fetch(`/api/admin/create/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
router.refresh();
} finally {
setBusy(null);
}
}
if (review.length === 0 && ready.length === 0) {
return <p className="muted" style={{ padding: 16, margin: 0 }}>No creations awaiting review.</p>;
}
return (
<div style={{ padding: 12 }}>
{review.length > 0 && (
<>
<strong>Awaiting review</strong>
<div className="stack" style={{ marginTop: 8, marginBottom: 16 }}>
{review.map((r) => (
<div key={r.id} className="row" style={{ justifyContent: "space-between", alignItems: "center", border: "1px solid var(--line)", borderRadius: 10, padding: "8px 12px" }}>
<span>{r.emoji} <strong>{r.subject}</strong> <span className="muted" style={{ fontSize: "0.82rem" }}>· {r.level}</span></span>
<span className="row" style={{ gap: 6 }}>
<button className="btn" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "approve")}> Approve</button>
<button className="btn danger" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "block")}>Block</button>
</span>
</div>
))}
</div>
</>
)}
{ready.length > 0 && (
<>
<strong>Ready promote to everyone?</strong>
<div className="stack" style={{ marginTop: 8 }}>
{ready.map((r) => (
<div key={r.id} className="row" style={{ justifyContent: "space-between", alignItems: "center", border: "1px solid var(--line)", borderRadius: 10, padding: "8px 12px" }}>
<span>{r.emoji} <strong>{r.subject}</strong> <span className="muted" style={{ fontSize: "0.82rem" }}>· {r.level}</span></span>
<button className="btn secondary" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "promote")}>🌟 Promote to global</button>
</div>
))}
</div>
</>
)}
</div>
);
}
const mini: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" };
+14 -2
View File
@@ -90,11 +90,23 @@ export default function AdminUsers({
Unsuspend
</button>
) : null}
{u.role === "learner" ? (
{u.role !== "admin" && (
<button className="btn ghost" style={btn} disabled={busy} onClick={() => act(u.id, "role", "admin")}>
Make admin
</button>
) : (
)}
{u.role !== "creator" && (
<button
className="btn ghost"
style={btn}
disabled={busy || isLastAdmin}
title={isLastAdmin ? "Can't demote the only admin" : undefined}
onClick={() => act(u.id, "role", "creator")}
>
Make creator
</button>
)}
{u.role !== "learner" && (
<button
className="btn ghost"
style={btn}
+12
View File
@@ -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 (
<>
@@ -44,6 +49,13 @@ export default async function AdminPage() {
</div>
</section>
<section style={{ marginTop: 28 }}>
<h2>Created lessons {reviewCreations.length > 0 && <span className="pill pending">{reviewCreations.length} to review</span>}</h2>
<div className="card" style={{ padding: 0 }}>
<AdminCreations review={reviewCreations} ready={readyCreations} />
</div>
</section>
<section style={{ marginTop: 28 }}>
<h2>Issue reports</h2>
<div className="card" style={{ padding: 0 }}>
+37
View File
@@ -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 });
}
}
+1 -1
View File
@@ -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") {
+17
View File
@@ -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 });
}
+46
View File
@@ -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",
});
}
+131
View File
@@ -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<Phase>("idle");
const [msg, setMsg] = useState("");
const [slug, setSlug] = useState("");
const poll = useRef<ReturnType<typeof setInterval> | 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 (
<div className="card" style={{ marginTop: 16 }}>
<p className="muted" style={{ margin: 0 }}>
The Create feature isn&apos;t turned on for this DrawIt instance yet. Ask your administrator to configure a
drawing server (ComfyUI).
</p>
</div>
);
}
const busy = phase === "submitting" || phase === "generating";
return (
<div style={{ marginTop: 16, maxWidth: 560 }}>
<form onSubmit={start} className="card" style={{ display: "grid", gap: 12 }}>
<label className="field">
<span style={{ fontWeight: 700 }}>What do you want to draw?</span>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="e.g. Zebra"
maxLength={30}
disabled={busy}
required
/>
</label>
<label className="field">
<span style={{ fontWeight: 700 }}>Level</span>
<select value={level} onChange={(e) => setLevel(e.target.value)} disabled={busy}>
<option value="early-beginner">Early Beginner</option>
<option value="beginner">Beginner</option>
</select>
</label>
<button className="btn big" type="submit" disabled={busy || subject.trim().length < 2}>
{busy ? "Drawing…" : "✨ Make my lesson"}
</button>
</form>
{phase === "generating" && (
<div className="card" style={{ marginTop: 12, textAlign: "center" }}>
<p style={{ margin: 0 }}>🎨 Drawing your {subject || "picture"} this can take a little while.</p>
<p className="muted" style={{ marginTop: 6, fontSize: "0.9rem" }}>You can stay on this page.</p>
</div>
)}
{phase === "review" && (
<div className="card" style={{ marginTop: 12 }}>
<p style={{ margin: 0 }}>📨 Thanks! &quot;{subject}&quot; was sent to a grown-up to approve. Check back soon.</p>
</div>
)}
{phase === "ready" && (
<div className="card" style={{ marginTop: 12, textAlign: "center" }}>
<p style={{ marginTop: 0 }}>🎉 Your lesson is ready!</p>
<Link className="btn big" href={`/learn/${level}/${slug}-trace`}>Start drawing</Link>
</div>
)}
{phase === "failed" && (
<div className="notice" style={{ marginTop: 12 }}>
<p style={{ margin: 0 }}>{msg || "Something went wrong. Try a different subject."}</p>
</div>
)}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
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 (
<>
<SiteNav />
<main className="container page">
<h1> Create your own lesson</h1>
<p className="muted" style={{ maxWidth: 640 }}>
Type something you&apos;d love to draw and DrawIt will make a brand-new coloring-book lesson for it
trace the lines, then color it in.
</p>
<CreateForm level={lvl} enabled={isCreateEnabled()} />
</main>
</>
);
}
+54 -1
View File
@@ -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");
@@ -119,3 +121,54 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
</>
);
}
// 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 (
<>
<SiteNav />
<main className="container page">
<TraceRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={true} username={user.username} completedSteps={completed} alreadyEarned={false} nextHref={`/learn/${level}/${lessons[1].slug}`} nextLabel="Next: Color" />
</main>
</>
);
}
// Color phase
const userSketch = prev ? (getLatestDrawing(user.id, level, prev.sublevel) ?? "") : "";
const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel: "Color" };
return (
<>
<SiteNav />
<main className="container page">
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={true} username={user.username} alreadyEarned={false} userSketch={userSketch} />
</main>
</>
);
}
+46
View File
@@ -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 (
<div style={{ border: "2px dashed var(--primary)", borderRadius: 16, padding: "12px 14px" }}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<strong style={{ fontSize: "1.05rem" }}>{canCreateNow ? "✨ Create your own" : "✨ My Creations"}</strong>
{canCreateNow && <Link className="btn" style={{ minHeight: 36, padding: "6px 12px" }} href={`/create?level=${lvlKey}`}>+ New lesson</Link>}
</div>
<div className="stack" style={{ marginTop: 10 }}>
{ready.map((r) => subjectAccordion(toCreatedSubject(r), false))}
{pending.map((r) => (
<div key={r.slug} style={phaseBox}>
<strong>{r.emoji} {r.subject}</strong>{" "}
<span className="muted" style={{ fontSize: "0.82rem" }}>{r.status === "needs_review" ? "⏳ Waiting for approval" : "🎨 Drawing…"}</span>
</div>
))}
{canCreateNow && ready.length === 0 && pending.length === 0 && <p className="muted" style={{ fontSize: "0.9rem", margin: 0 }}>No creations yet tap + New lesson.</p>}
{featured.length > 0 && (
<>
<strong style={{ fontSize: "0.95rem", marginTop: 6 }}>🌟 Featured creations</strong>
{featured.map((r) => subjectAccordion(toCreatedSubject(r), false))}
</>
)}
</div>
</div>
);
};
return (
<>
<SiteNav />
@@ -116,6 +160,7 @@ export default async function LearnPage() {
) : lvl.key === "beginner" ? (
// Beginner: group subjects into packs, each with a per-pack badge.
<div className="stack" style={{ marginTop: 6 }}>
{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,6 +181,7 @@ export default async function LearnPage() {
) : lvl.key === "early-beginner" ? (
// Early Beginner: themed groups, each holding its subjects.
<div className="stack" style={{ marginTop: 6 }}>
{creationsSection(lvl.key)}
{getGroupedSubjects(lvl.key).map((group) => {
const groupDone = group.subjects.every((s) => subjectComplete(s));
return (
+90
View File
@@ -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<string, string> {
return API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {};
}
function loadWorkflow(): Record<string, { inputs: Record<string, unknown>; 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<Buffer> {
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<string, { outputs?: Record<string, { images?: { filename: string; subfolder: string; type: string }[] }> }>;
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());
}
+188
View File
@@ -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<void> {
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 };
}
+21
View File
@@ -133,6 +133,27 @@ 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);
-- 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 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:<reason>' | '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)
+64
View File
@@ -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<string>([
// 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 };
}
+11
View File
@@ -59,3 +59,14 @@ export async function requireAdmin(): Promise<User | null> {
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<User | null> {
const user = await getCurrentUser();
return canCreate(user) ? user : null;
}
+2 -1
View File
@@ -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 {
+76
View File
@@ -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 <path> (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<string> {
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<string> {
const svg = await traceToSvg(png);
// Source dimensions (potrace emits width/height + viewBox on the <svg>).
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 `<path d="${combined}" transform="${transform}" fill="${STROKE}" fill-rule="evenodd"/>`;
}
/** 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) => `<path d="${d}"${transform} fill="${STROKE}" fill-rule="evenodd"/>`);
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;
}
+8
View File
@@ -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<string, unknown>, cb: TraceCallback): void;
export function trace(file: Buffer | string, cb: TraceCallback): void;
export function posterize(file: Buffer | string, options: Record<string, unknown>, cb: TraceCallback): void;
export function posterize(file: Buffer | string, cb: TraceCallback): void;
}