Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd0468651 | ||
|
|
8fb4ce8be7 | ||
|
|
2ee11aef7e | ||
|
|
011168c3b8 | ||
|
|
7b4650cca2 | ||
|
|
82af4c1830 | ||
|
|
021bfc302d | ||
|
|
09a0c7e504 | ||
|
|
aa78b135e3 | ||
|
|
89b175faf0 | ||
|
|
90c744bf13 | ||
|
|
939f4dbc87 | ||
|
|
eabd482db0 | ||
|
|
011918c62c | ||
|
|
249167c321 | ||
|
|
8cd36c3182 | ||
|
|
9880cf40d5 | ||
|
|
21bf3459a7 | ||
|
|
5d961aaca0 | ||
|
|
aa55a8b724 | ||
|
|
f79ce000e4 | ||
|
|
1e1408ad7f | ||
|
|
917991c290 | ||
|
|
8c07bc4986 | ||
|
|
2efdc54a16 | ||
|
|
d89227c259 | ||
|
|
21376fd1b4 | ||
|
|
b39bdaaa78 | ||
|
|
e918955cda | ||
|
|
2e20f54513 | ||
|
|
a772c23559 | ||
|
|
ed30020b6c | ||
|
|
c9aed9f283 | ||
|
|
9a7fd01eae | ||
|
|
e7759405ce | ||
|
|
5ec87c4fd5 | ||
|
|
cb80cdfc35 | ||
|
|
ddb5362050 | ||
|
|
99af05b8d0 | ||
|
|
fb55c3b788 | ||
|
|
d2fe11ea6e | ||
|
|
667d5d83cc | ||
|
|
ebd66e2d9c | ||
|
|
4b0807719e | ||
|
|
52363fc107 | ||
|
|
b69408aad9 | ||
|
|
088cc4179d | ||
|
|
aa8474caa9 | ||
|
|
668f3569f7 | ||
|
|
be470268ab | ||
|
|
ee8e218b7d | ||
|
|
c0504c7665 |
@@ -15,3 +15,16 @@ 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/ColorBook01.json
|
||||
# Node ids in that workflow: the positive-prompt node and the sampler (seed) node.
|
||||
COMFYUI_PROMPT_NODE=6
|
||||
COMFYUI_SEED_NODE=3
|
||||
# Placeholder inside the positive prompt that gets replaced with the creator's subject.
|
||||
COMFYUI_SUBJECT_TOKEN=[DrawItSubject]
|
||||
COMFYUI_TIMEOUT_MS=120000
|
||||
|
||||
@@ -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
|
||||
@@ -20,3 +20,6 @@ next-env.d.ts
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude agent tooling / skill cache (not project source)
|
||||
.claude/
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 149056623501121,
|
||||
"steps": 20,
|
||||
"cfg": 8,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors"
|
||||
},
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": {
|
||||
"title": "Load Checkpoint"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "Empty Latent Image"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": "coloring book page, single cute [DrawItSubject], black and white line art, clean bold outlines, thick clear lines, no shading, no color, white background, simple cartoon style, friendly expression, flowing mane and tail, magical horn, full body, centered composition, children's coloring book, easy to color, large simple shapes, high contrast lineart, vector style",
|
||||
"clip": [
|
||||
"4",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Positive Prompt)"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"text": "color, colored, shading, shadows, grayscale, gray tones, gradient, realistic, photograph, 3d render, complex details, intricate, cluttered background, scenery, multiple subjects, text, watermark, signature, blurry, sketchy lines, rough lines, crosshatching, dark areas, filled shapes, painting, thin faint lines, low contrast",
|
||||
"clip": [
|
||||
"4",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Negative Prompt)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"4",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
|
||||
`ColorBook01.json` here is the active workflow (`COMFYUI_WORKFLOW`). Its positive-prompt node contains a
|
||||
**`[DrawItSubject]` placeholder** — DrawIt replaces that token with the creator's one-word subject and
|
||||
sets a fresh random seed; the rest of your prompt is kept exactly as written. (`coloring-book.workflow_api.json`
|
||||
is the older stock SD1.5 template, kept for reference.)
|
||||
|
||||
- `COMFYUI_PROMPT_NODE` (default `6`) — the positive `CLIPTextEncode` node containing `[DrawItSubject]`.
|
||||
- `COMFYUI_SEED_NODE` (default `3`) — the `KSampler` node.
|
||||
- `COMFYUI_SUBJECT_TOKEN` (default `[DrawItSubject]`) — the placeholder to substitute.
|
||||
|
||||
Example: node 6 text `"coloring book page, single cute [DrawItSubject], black and white line art, …"`
|
||||
with subject `Zebra` becomes `"coloring book page, single cute Zebra, black and white line art, …"`.
|
||||
|
||||
## 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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 3–4-phase
|
||||
subject. **Chosen approach (per §13): Option C — vectorize the line art with potrace for a
|
||||
stroke-by-stroke Trace phase, and use the raster for the Color phase (2 phases total).** The three
|
||||
options below are kept for context; the build targets C.
|
||||
|
||||
- **Option A — Raster-native, 2-phase (recommended MVP).** Treat the cleaned PNG as the lesson art.
|
||||
- **Trace** phase: show the line art faintly as a guide to trace over (reuse `TraceRunner`'s base
|
||||
layer, fed a raster instead of SVG). Optional "watch it appear" = a left-to-right wipe reveal of
|
||||
the bitmap rather than true per-stroke animation.
|
||||
- **Color** phase: `ColoringRunner` already rasterizes its template to a boundary + overlay, so it
|
||||
can take the PNG **directly** — flood-fill and overlay work as-is. This phase is essentially free.
|
||||
- For Beginner-created lessons, optionally add the existing **Light** phase (sun overlay) on top.
|
||||
- Smallest change: add a `templateUrl?` (raster) alongside `baseSvg` to `TraceRunner`/`ColoringRunner`
|
||||
and the lesson-page meta; when present, use the bitmap path.
|
||||
|
||||
- **Option B — Vectorize for true line-by-line reveal.** Run the PNG through **potrace** (pure-JS npm,
|
||||
no native build — important given our better-sqlite3 build friction) to get SVG paths, then split the
|
||||
SVG into multiple `lines` (by `M` subpaths) so `LineDraw` animates it stroke-by-stroke like the
|
||||
hand-made lessons. Quality varies with image cleanliness; treat as an enhancement over A.
|
||||
|
||||
- **Option C — Hybrid (target end state).** Vectorize for the trace reveal (B) **and** keep the raster
|
||||
for coloring (A). Optionally heuristically split paths into a simpler "outline" (large/long paths)
|
||||
vs "detail" (small/short paths) pass to approximate the two trace phases — best-effort, not perfect.
|
||||
|
||||
**Image post-processing** (server-side, before storing) with a pure-JS lib like **jimp** (avoids native
|
||||
deps) or **sharp** (faster, native): grayscale → threshold to crisp black/white → auto-trim margins →
|
||||
fit to the lessons' aspect (the runners use a 4:3 / 400×300 viewBox and an 880×660 color canvas) →
|
||||
flatten on white → downscale. Store the cleaned PNG; if vectorizing, store the derived SVG too.
|
||||
|
||||
**Badge & metadata:** Created lessons get a generic medal — a default emoji (✏️ or 🎨, or a
|
||||
creator-picked one) plus a name like "**Zebra Artist**". Reuse the existing emoji-medal `BadgeArt` and
|
||||
award-PDF machinery untouched.
|
||||
|
||||
## 7. Data model
|
||||
|
||||
No destructive changes; one new table (plus the `Role` union value):
|
||||
|
||||
```sql
|
||||
CREATE TABLE created_lessons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
level TEXT NOT NULL, -- 'early-beginner' | 'beginner'
|
||||
subject TEXT NOT NULL, -- sanitized, e.g. 'Zebra'
|
||||
slug TEXT NOT NULL UNIQUE, -- e.g. 'created-42-zebra'
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending|generating|needs_review|ready|failed|blocked
|
||||
image TEXT, -- cleaned line-art (data URL, like drawings/avatars)
|
||||
template_svg TEXT, -- optional vectorized paths (Option B/C)
|
||||
prompt TEXT NOT NULL, -- full prompt sent (audit)
|
||||
moderation TEXT, -- 'allowlist' | 'review' | 'blocked:<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/)
|
||||
@@ -0,0 +1,266 @@
|
||||
# DrawIt — Research: Designing the "Beginner" Level
|
||||
|
||||
_Researched June 2026. Purpose: figure out **how a child moves from Early Beginner (simple shapes
|
||||
and confident lines) to genuinely more complex drawings**, and map that progression onto a concrete
|
||||
Beginner curriculum for DrawIt. This is a research/planning document only — nothing here is built
|
||||
yet. It builds directly on `RESEARCH-drawing-platforms-and-techniques.md`, which mapped each of our
|
||||
five ability levels to a technique. That doc said Beginner should teach **shape → form and base
|
||||
color**; this doc fleshes out exactly what that means, why, and how._
|
||||
|
||||
## 1. Where "Beginner" sits — the developmental anchor
|
||||
|
||||
DrawIt's five levels (Early Beginner → Beginner → Learner → Advanced Learner → Superb) line up
|
||||
surprisingly well with the way children's drawing actually develops. The most widely used framework
|
||||
is **Viktor Lowenfeld's stages of artistic development**:
|
||||
|
||||
| Lowenfeld stage | Rough age | What the child can do | DrawIt level |
|
||||
| --- | --- | --- | --- |
|
||||
| Pre-schematic | 4–6 | Circular figures, single objects floating in space, flat shapes | **Early Beginner** (where we are) |
|
||||
| Schematic | 7–9 | Understanding of space and proportion, baselines, repeatable "symbols" for things | **Beginner** (this doc) |
|
||||
| Dawning Realism | 9–11 | Strives for realistic look, more detail per part, overlapping objects, horizon line | Learner / Advanced |
|
||||
|
||||
Early Beginner lives in the **pre-schematic** world: a fish is a flat outline, an object on its own.
|
||||
The leap into **Beginner** mirrors the move into the **schematic stage** — the child starts to care
|
||||
about *space, proportion, and how parts relate*, and wants their drawings to look more like the real
|
||||
thing. That is the single most important framing for this level: **Beginner is the bridge from
|
||||
"flat symbol of a thing" to "a thing that has volume, parts, and believable size."**
|
||||
|
||||
Crucially, we are *not* trying to reach realism here. Lowenfeld's "Dawning Realism" (true light,
|
||||
shadow, overlap) is the Learner level. Beginner's job is the rung in between.
|
||||
|
||||
## 2. The leap: what actually changes from Early Beginner → Beginner
|
||||
|
||||
Four concrete shifts define the jump in complexity. Everything in this level should serve one of
|
||||
these four, and each is a well-documented teaching milestone, not something we invented:
|
||||
|
||||
1. **Flat shape → 3-D form.** A circle becomes a sphere; a square becomes a cube; a rectangle
|
||||
becomes a cylinder. With just one or two added ellipses/edges a 2-D shape reads as a solid object
|
||||
with volume. The four basic forms — **sphere, cube, cylinder, cone** — are described everywhere as
|
||||
"the building blocks of drawing complex objects" and the foundation for creating the illusion of
|
||||
depth and volume.
|
||||
|
||||
2. **Single shape → constructed object (combining shapes).** This is "construction": breaking a
|
||||
complex subject into basic shapes, stacking/overlapping them, and refining into the final outline.
|
||||
A mug is a cylinder with an ellipse rim; a house is a box with a prism roof; a snowman is stacked
|
||||
spheres. Early Beginner draws *one* shape per subject; Beginner draws subjects *made of several
|
||||
shapes fitted together*.
|
||||
|
||||
3. **Guessing size → simple proportion.** The schematic-stage child begins to care that the head is
|
||||
the right size for the body, that the handle fits the cup. The teachable beginner skill is
|
||||
**relative size / comparative measurement** — "nail the biggest proportions first, then refine"
|
||||
and "compare one part to another instead of measuring in isolation." For kids this is kept
|
||||
concrete: light guide marks, "the body is about two heads tall," a center line for symmetry.
|
||||
|
||||
4. **Line only → base color (and the first hint of light).** Early Beginner already added a separate
|
||||
"Color it!" coloring step. Beginner makes color *part of the drawing*: flat base color inside the
|
||||
lines, an intro to **primary → secondary** colors, and the very first idea of a **light side and a
|
||||
shadow side** of a form (one light source) — without full shading. This is deliberately the
|
||||
on-ramp to the Learner shading module, so the two levels connect.
|
||||
|
||||
## 3. The technique backbone for Beginner
|
||||
|
||||
Rather than "just more animals," each Beginner subject should teach one of four named techniques, in
|
||||
roughly this order of difficulty. This gives the level a real spine.
|
||||
|
||||
### 3a. Shape → Form (the four basic forms)
|
||||
|
||||
Teach the child to turn the flat shapes they already know into solids:
|
||||
|
||||
- **Circle → Sphere** (add a curved contour line + a round shadow): ball, orange, planet.
|
||||
- **Square → Cube** (add depth edges): box, dice, gift.
|
||||
- **Rectangle → Cylinder** (add an ellipse top/bottom): cup, can, tree trunk, log.
|
||||
- **Triangle → Cone** (add an ellipse base): ice-cream cone, party hat, traffic cone, tree.
|
||||
|
||||
Each form is introduced on its own (a short "make it 3-D" drill) and then immediately *used* in a
|
||||
real subject, so the technique never feels abstract. This is exactly how design-sketching courses
|
||||
introduce the "3 (or 4) basic forms" before any product is drawn.
|
||||
|
||||
### 3b. Construction — combining shapes into a subject
|
||||
|
||||
Once forms exist, teach assembly: **light construction shapes first → refine into the outline →
|
||||
erase/ignore the guides.** This is the professional "block-in" workflow simplified for kids. DrawIt's
|
||||
Early Beginner lessons already hint at this with the cumulative outline steps; Beginner should make
|
||||
it *explicit and visible*: step 1 is always "lay in the big shapes," and the guide shapes stay faint
|
||||
under the final line art (we already have a faint-guide + line-reveal mechanic in the trace runner).
|
||||
|
||||
Good "combine the shapes" subjects (well attested as kid-friendly construction exercises): house
|
||||
(box + prism), snowman (3 spheres), mug (cylinder + ellipse), ice-cream cone (cone + sphere),
|
||||
robot (cubes + cylinders), simple car (boxes + circles), cat curled up (circle + ovals), rocket
|
||||
(cylinder + cone + triangles).
|
||||
|
||||
### 3c. Proportion — relative size and placement
|
||||
|
||||
Keep it concrete and visual, never mathematical:
|
||||
|
||||
- **Big shapes first, details last** — the most repeated rule in proportion teaching.
|
||||
- **Compare part to part** — "the head is about as wide as the body is tall," "the handle is half
|
||||
the cup." Show this with on-canvas guide marks rather than a ruler.
|
||||
- **Center / symmetry lines** — a vertical guide for symmetric subjects (a vase, a face-on robot).
|
||||
- **Baseline / ground line** — the schematic-stage hallmark: objects sit *on* something, not float.
|
||||
|
||||
### 3d. Base color & first light
|
||||
|
||||
- **Flat color inside the lines** (we already have the coloring studio + fill bucket).
|
||||
- **Primary → secondary**: a tiny, playful color-mixing idea (red + yellow = orange) surfaced as a
|
||||
tip, since 5–9 year-olds are exactly the audience for first color theory.
|
||||
- **Light side / shadow side**: pick a light direction (a ☀️ marker), keep the side facing it lighter
|
||||
and the far side a touch darker — *one* darker tone, not a gradient. This is the gateway drug to the
|
||||
Learner shading module and keeps the levels continuous.
|
||||
|
||||
## 4. How step-by-step apps ramp difficulty (and what to copy)
|
||||
|
||||
The competitor scan reinforces the structure above:
|
||||
|
||||
- **Themed modules, each with ~5 lessons of gradually increasing difficulty** (e.g., "Let's Learn How
|
||||
to Draw!" modules; SimplyDraw's Animals/Nature/Characters paths). We should group Beginner into a
|
||||
few themed packs rather than one flat list.
|
||||
- **Skill-based ladder**: ArtWorkout's published progression is literally *shapes & lines → doodles &
|
||||
sketches → confident line work → shading, perspective, anatomy*. Beginner is the "confident line
|
||||
work + first form/color" rung — we're aligned.
|
||||
- **Self-check / "ready to move on?"**: some apps (Drawy) grade the attempt and tell the child to
|
||||
advance or practice more. We can do a lightweight, **fully on-device** version (e.g., a gentle
|
||||
self-rating or an optional shape-overlap check) without any data collection.
|
||||
- **"Draw on your own" fade-out**: apps gradually remove the prompts so the child eventually draws
|
||||
unaided. Beginner can start fading guides (e.g., the final lesson of a pack shows fewer
|
||||
construction hints), setting up the freer Learner/Superb levels.
|
||||
|
||||
Our differentiators stay intact: **free, open source, zero data collection, self-hostable,
|
||||
tablet-first** — all of the above is doable client-side with the stack we have.
|
||||
|
||||
## 5. Proposed Beginner curriculum
|
||||
|
||||
### 5a. Lesson shape — **4 phases (decided)**
|
||||
|
||||
Early Beginner uses **Outline → Detail → Color it!**. Beginner adopts a **4-phase** flow per subject:
|
||||
|
||||
1. **Construct** — lay in the basic forms/shapes (faint guides, the "make it 3-D" move). *New.*
|
||||
2. **Outline** — refine the guides into clean line art (as today).
|
||||
3. **Color** — flat base color inside the lines, with the primary/secondary tip (as today's "Color it!").
|
||||
4. **Light** — add the single light/shadow side using the ☀️ marker. *New, bridges to Learner.*
|
||||
|
||||
This is backward-compatible with our model: each phase is its own lesson with its own sublevel, and
|
||||
the construction guides reuse the existing faint-guide + line-reveal mechanics. (Badging is now
|
||||
**per-pack** rather than per-subject — see §5b/§8.)
|
||||
|
||||
### 5b. Subjects — **3 packs, all-new, shapes-focused (decided)**
|
||||
|
||||
Per Dan's direction: **3 packs to start, all-new subjects, focused on shapes/forms** (no reuse of the
|
||||
Early Beginner animals — more animals will be added to *Early Beginner* later, separately). Each pack
|
||||
ramps in difficulty and earns **one per-pack completion badge** when all its subjects are finished.
|
||||
|
||||
The three packs form a clean difficulty ladder: **single form → combined forms → multi-part objects.**
|
||||
|
||||
| Pack | Subject (form → themed object) | Teaches (form) | Skill focus |
|
||||
| --- | --- | --- | --- |
|
||||
| **Pack 1 — Make it 3-D** (single forms) | Beach ball | Sphere | First 2-D→3-D; round shadow |
|
||||
| | Gift box | Cube | Depth edges; cast shadow |
|
||||
| | Mug | Cylinder | Ellipse rim; handle proportion |
|
||||
| | Party hat | Cone | Ellipse base; first light side |
|
||||
| **Pack 2 — Put it together** (combine 2–3 forms) | Ice-cream cone | Cone + sphere | First *combined* form |
|
||||
| | Snowman | Stacked spheres | Proportion (3 sizes), baseline |
|
||||
| | House | Box + pyramid roof | Combining; symmetry line |
|
||||
| | Rocket | Cylinder + cone + fins | Combining + simple overlap |
|
||||
| **Pack 3 — Build a thing** (multi-part construction) | Robot | Cubes + cylinders | Multi-part construction |
|
||||
| | Car | Boxes + circles | Proportion, wheels on a baseline |
|
||||
| | Castle | Cubes + cones/pyramids | Repetition + symmetry |
|
||||
| | Sailboat | Triangles + curved hull | Combine + color + first light |
|
||||
|
||||
**Core-form choice:** the opening pack teaches the universal trio **sphere, cube, cylinder** (the most
|
||||
reusable primitives — nearly every later subject decomposes into them), with the **cone** added last as
|
||||
the bridge into combining. The **pyramid** is deliberately held for packs 2–3 (house roof, castle),
|
||||
where the lesson is combining/repeating forms rather than a first 2-D→3-D drill.
|
||||
|
||||
Sequencing within the level: **single form → two/three combined forms → multi-part construction**,
|
||||
each subject running the full Construct → Outline → Color → Light flow. Difficulty rises exactly the
|
||||
way the app research recommends (themed packs, ~4 lessons each, gradual ramp, guides fading toward the
|
||||
end of each pack).
|
||||
|
||||
**Badge model (decided): per-pack completion badge.** Finishing every subject in a pack earns that
|
||||
pack's badge (3 Beginner badges total), each with a shareable PNG + award PDF like the existing
|
||||
badges. (This differs from Early Beginner's per-subject badges.)
|
||||
|
||||
### 5c. Continuity — how it builds on Early Beginner and feeds Learner
|
||||
|
||||
- **Unlock gate (decided):** the Beginner level stays locked until the child completes **at least 5
|
||||
Early Beginner items**. The intent is that the Early Beginner lessons give the child the "look and
|
||||
feel" of how DrawIt works (the trace/line-reveal flow, coloring studio, badges, gallery) before
|
||||
stepping up in complexity. Implementation note for later: count completed Early Beginner subjects
|
||||
(or lessons) server-side, the same place we already gate phase progression, and grey out the
|
||||
Beginner level on the Learn page with an encouraging "finish 5 Early Beginner drawings to unlock"
|
||||
message until the threshold is met.
|
||||
- **Looks back:** Beginner subjects are **all-new and shape-focused** (no reprise of the Early
|
||||
Beginner animals). More *animals* will be added to **Early Beginner** later as a separate effort, so
|
||||
the two levels stay distinct: Early Beginner = recognizable subjects & confident line; Beginner =
|
||||
form, construction, proportion, base color.
|
||||
- **Looks forward:** the "light side / shadow side" phase is intentionally the simplest possible
|
||||
version of the Learner shading module (highlight / mid-tone / shadow, the sphere drill). A child who
|
||||
finishes Beginner has already met a light source and one shadow tone, so Learner is a deepening, not
|
||||
a brand-new idea.
|
||||
|
||||
## 6. Feature / data-model implications (for later — not building now)
|
||||
|
||||
Flagging what this level would *need* so the eventual build is scoped, consistent with the
|
||||
already-proposed extensions in the prior research doc:
|
||||
|
||||
- A **`construct` step kind** (faint, non-graded guide shapes) — we already have `kind` on steps and a
|
||||
faint-guide render path; this mostly reuses it.
|
||||
- A **base-color phase** — already covered by the coloring studio + fill bucket.
|
||||
- A **light-source overlay** (☀️ marker + light/shadow side hint) — a simpler cousin of the shading
|
||||
zone overlay proposed for Learner.
|
||||
- **Proportion guide marks** — light center/baseline lines and "this part = half that part" hints in
|
||||
the construct step.
|
||||
- **Optional on-device self-check** ("ready to move on?") — purely local, no scoring sent anywhere.
|
||||
- **Guide fade-out** toward the end of each pack — a per-lesson flag to show fewer hints.
|
||||
|
||||
All client-side, all bundled/generated locally. **No new data collection, no third-party services,
|
||||
still free and open source** — same constraints we've held throughout.
|
||||
|
||||
## 7. Suggested phasing for the Beginner build (when we get there)
|
||||
|
||||
- **Phase A — Forms first:** ship the four "make it 3-D" form drills + the four single-form subjects
|
||||
(ball, cup, box, cone) using the existing Outline→Color flow plus a new faint `construct` step.
|
||||
- **Phase B — Construction:** the "Build it" pack (snowman, house, rocket, robot) — combining shapes,
|
||||
proportion guides, baseline.
|
||||
- **Phase C — Form + color + first light:** the living-things pack and the optional light/shadow
|
||||
phase; this is the explicit hand-off into the Learner shading module.
|
||||
|
||||
## 8. Decisions (locked June 2026)
|
||||
|
||||
These are settled and drive the eventual build:
|
||||
|
||||
1. **Phases:** **4 phases** per subject — **Construct → Outline → Color → Light**.
|
||||
2. **Packs:** **3 packs** to start (§5b): *Make it 3-D* → *Put it together* → *Build a thing*, ~4
|
||||
subjects each, ramping single form → combined forms → multi-part construction.
|
||||
3. **All-new, shapes-focused subjects** — no reprise of the Early Beginner animals. (More animals will
|
||||
be added to *Early Beginner* later, as a separate effort.)
|
||||
4. **Unlock gate:** Beginner opens only after the child completes **≥ 5 Early Beginner items**, so they
|
||||
get the DrawIt "look and feel" first. Beginner is greyed out with an encouraging unlock prompt until
|
||||
then.
|
||||
5. **Badges:** **per-pack completion badge** (3 Beginner badges total), each with shareable PNG +
|
||||
award PDF — not per-subject.
|
||||
|
||||
Still open (smaller, can be decided at build time): whether to include a lightweight, fully on-device
|
||||
"ready to move on?" self-check, and the exact art direction for the 3 pack badges.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Lowenfeld's Stages of Artistic Development — The Virtual Instructor](https://thevirtualinstructor.com/blog/the-stages-of-artistic-development)
|
||||
- [Schematic Stage of Drawing for Children — Learning For A Purpose](https://learningforapurpose.com/schematic-stage/)
|
||||
- [Stages of Artistic Development — Where Creativity Works](https://wherecreativityworks.com/stages-of-artistic-development/)
|
||||
- [Basic forms (cube, sphere, cylinder, cone) — Fiveable, Drawing I](https://fiveable.me/drawing-foundations/unit-5/basic-forms-cube-sphere-cylinder-cone/study-guide/OGG3MWqfJGt1tDn6)
|
||||
- [The 3 Basic 3D Forms (Cube, Sphere, Cylinder) — The Design Sketchbook](https://www.thedesignsketchbook.com/how-to-draw-the-3-basic-forms-of-design-sketching-cube-sphere-cylinder/)
|
||||
- [How to Draw the 4 Basic Forms in 3D — The Design Sketchbook](https://www.thedesignsketchbook.com/how-to-draw-the-4-basic-forms-in-art-and-design/)
|
||||
- [Drawing Basics — Construction — The Virtual Instructor](https://thevirtualinstructor.com/blog/drawing-basics-construction)
|
||||
- [How to draw basic shapes — Creative Bloq](https://www.creativebloq.com/illustration/how-draw-basic-shapes-31619534)
|
||||
- [How to Teach Kids to Draw Using Shapes — HubPages](https://discover.hubpages.com/art/How-to-Teach-Kids-to-Draw-Using-Shapes)
|
||||
- [Easy Drawing Pictures from Shapes for kids — KidsTut](https://kidstut.com/easy-drawing-pictures-from-shapes/)
|
||||
- [How to Measure Relative Proportions with a Pencil — Life Drawing Academy](https://lifedrawing.academy/life-drawing-academy-news/how-to-measure-relative-proportions-with-a-pencil)
|
||||
- [Teaching Proportions in Art to Younger Students — Kymberli Grant](https://kymberligrant.com/2022/01/10/teaching-proportions-in-art-to-younger-students/)
|
||||
- [Lesson 4: How to Draw with Accurate Proportions — RapidFireArt](https://rapidfireart.com/2017/05/17/lesson-4-how-to-draw-with-accurate-proportions/)
|
||||
- [How to Teach Color — Deep Space Sparkle](https://www.deepspacesparkle.com/how-to-teach-color/)
|
||||
- [Color Theory for Kids — Monkey Pen](https://monkeypen.com/blogs/news/teaching-color-theory-to-kids)
|
||||
- [Colour Theory for Kids — Little Change Creators](https://littlechangecreators.com/blogs/blog/colour-theory-for-kids)
|
||||
- [Simply Draw: Learn to Draw — App Store](https://apps.apple.com/us/app/simply-draw-learn-to-draw/id1639875485)
|
||||
- [ArtWorkout: Learn How to Draw — App Store](https://apps.apple.com/us/app/artworkout-learn-how-to-draw/id1564657118)
|
||||
- [11 Best Apps to Draw Step By Step (2026) — Freeappsforme](https://freeappsforme.com/apps-to-draw-step-by-step/)
|
||||
- [The 11 Best Drawing Apps for Kids — PureWow](https://www.purewow.com/family/best-drawing-apps-for-kids)
|
||||
@@ -0,0 +1,173 @@
|
||||
# DrawIt — Research: Teaching Platforms & Techniques (Shading, Layering, and More)
|
||||
|
||||
_Researched June 2026. Purpose: see what SimplyDraw and similar apps do well, understand how
|
||||
drawing is actually taught (especially shading and layering), and lay out concrete ways to fold
|
||||
those ideas into DrawIt while staying free, open-source, and privacy-first._
|
||||
|
||||
## 1. What the competition does
|
||||
|
||||
**Simply Draw** (by Simply / JoyTunes, the Simply Piano people) is the closest match to our vision
|
||||
and the most polished. Its pitch is "draw like you always wanted," and the teaching model has three
|
||||
pillars:
|
||||
|
||||
- **Guided every step of the way** — each drawing is broken into clear, easy-to-follow steps so
|
||||
"anyone can create something they're proud of." This is the part DrawIt already does.
|
||||
- **Bring drawings to life with shading** — they explicitly frame shading as the thing that "adds
|
||||
depth, dimension, and brings sketches to life," and walk learners from studying a reference image
|
||||
all the way through to shading, with smart tips on proportion, shading, and texture.
|
||||
- **Spark imagination** — every tutorial pushes creative thinking and personal expression, not just
|
||||
copying. They also use video sessions led by professional artists that you can pause, rewind, and
|
||||
draw along with, plus a "personalized path" and weekly new content.
|
||||
|
||||
Other platforms worth borrowing from:
|
||||
|
||||
| Platform | Audience | What it does well | What we can borrow | Gap we can beat |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Simply Draw** | Kids→adult | Step-by-step + shading + texture + reference study; pausable artist videos; personalized path | Shading module, reference-image study, replayable demos | Paid subscription; not open; collects data |
|
||||
| **Drawing Desk** | Kids→advanced | Step lessons with on-screen guides + **text and voice** guides; themed tracks (anime, kawaii, doodle); beginner→refine | Multi-modal guidance (visual + voice + text); themed lesson packs | Ads / IAP; not private |
|
||||
| **Project Aqua** (Adobe) | Ages 5–12 | Completely **free, ad-free, family-focused**; color premade pages or your own sketch; step-by-step tutorials | Free/ad-free family stance (matches us); color-the-sketch mode | Closed source; Adobe account/ecosystem |
|
||||
| **Kids Doodle** | Ages 2–8 | Very simple; themed coloring pages; **video replay** of how the art was made | Stroke **replay** is a delightful, low-cost feature; big simple UI | Little real skill progression |
|
||||
| **Draw.ai** | Kids | Pick artwork, recreate it with **animated** step-by-step instructions | Animated (not just static) step guides | Thin on fundamentals |
|
||||
|
||||
The recurring winning ingredients: guided steps (we have it), **shading taught explicitly**, **reference
|
||||
study**, **replayable/animated demos**, multi-modal cues (show + tell), themed packs, and a creative
|
||||
"make it your own" finish. Our differentiators stay strong: **free, open source, zero data
|
||||
collection, self-hostable, tablet-first.**
|
||||
|
||||
## 2. How drawing is actually taught (the pedagogy)
|
||||
|
||||
Art instruction is remarkably consistent about the order things should be learned. DrawIt's five
|
||||
ability levels line up well with it; we just need to attach the right *techniques* to each level.
|
||||
|
||||
**Work broad → specific ("blocking-in").** Every strong drawing starts by establishing big basic
|
||||
shapes to nail proportion, placement, and composition *before* any detail. Construction shapes
|
||||
(ovals, boxes, cylinders) come first; refinement comes later. DrawIt's fish/panda/etc. already teach
|
||||
this implicitly — we should make it explicit and name it.
|
||||
|
||||
**The fundamentals, in rough teaching order:**
|
||||
|
||||
1. **Line & mark-making** — confident lines, contours, and the different marks (crisp outline vs.
|
||||
light construction line vs. hatching stroke). Each mark has a purpose.
|
||||
2. **Shape → Form** — turning flat shapes into 3-D volumes (circle → sphere, square → cube).
|
||||
3. **Value & shading** — light and shadow. This is the big one (see below).
|
||||
4. **Texture** — implying surface (fur, scales, bark) with marks.
|
||||
5. **Proportion & perspective** — relative sizes, foreshortening, simple 1-point perspective.
|
||||
6. **Color** — basic color theory, then color + shading together.
|
||||
|
||||
**Shading, specifically** (what SimplyDraw leans on). You teach it by introducing a **light source**
|
||||
and the three value zones every form has: **highlight, mid-tone, shadow** (plus cast shadow). The
|
||||
core techniques, easiest to hardest:
|
||||
|
||||
- **Tonal/blended shading** — smooth gradients from light to dark.
|
||||
- **Hatching** — parallel lines; closer together = darker, farther apart = lighter.
|
||||
- **Cross-hatching** — overlapping sets of lines; denser crossings = darker.
|
||||
- **Stippling** — dots; denser = darker.
|
||||
|
||||
The pro move taught everywhere: **curve your hatching to follow the form's surface**, space lines
|
||||
wider in light areas and tighter in shadow, and **build up gradually** in passes. The classic
|
||||
practice object is a sphere lit from one side — exactly the kind of single-shape drill we can ship.
|
||||
|
||||
**Layering** — this word has two useful meanings, and we want both:
|
||||
|
||||
- **Pedagogical layering (process):** a drawing is built up in stages —
|
||||
**construction/under-drawing → refined line art → base color → shading/details.** This is the
|
||||
professional workflow (underdrawing → underpainting → overpainting → glazes), simplified for kids.
|
||||
Teaching the *process* of layers is as important as teaching shapes.
|
||||
- **Technical layering (tool):** in digital art, **layers** are stacked transparent sheets — sketch
|
||||
on one, line art on another, color below the lines, shading on top. Being able to draw the
|
||||
construction loosely and then ink/color *without destroying it* is what makes the process above
|
||||
actually learnable. This is a feature we can build into the canvas.
|
||||
|
||||
## 3. How to incorporate this into DrawIt
|
||||
|
||||
### 3a. Map techniques onto our five levels
|
||||
|
||||
Our levels already describe the learner; here's the technique each one should introduce, so the
|
||||
curriculum has a real backbone instead of just "more lessons."
|
||||
|
||||
- **Early Beginner** — simple shapes & confident lines (what we have today: fish, panda, flower,
|
||||
unicorn, tree, dinosaur). Add one idea: **construction first** (draw the light guide shapes, then
|
||||
the outline).
|
||||
- **Beginner** — **shape → form** and **base color**: turn the same shapes into 3-D (sphere, cube,
|
||||
cylinder), introduce the idea of a light source, color-the-sketch lessons.
|
||||
- **Learner** — **shading & value**: the sphere drill, highlight/mid-tone/shadow, intro hatching;
|
||||
this is the "SimplyDraw moment" where drawings start to look real.
|
||||
- **Advanced Learned** — **layering, texture, proportion**: full construction → line → color →
|
||||
shade workflow on a multi-part subject; cross-hatching; simple perspective.
|
||||
- **Superb** — **composition & personal style**: multi-object scenes, free-draw challenges with
|
||||
light-touch guidance, "make it your own."
|
||||
|
||||
### 3b. Extend the lesson data model (small, backward-compatible)
|
||||
|
||||
Today a lesson is steps with a single cumulative SVG. Add optional fields so a step can declare what
|
||||
kind of step it is and what it teaches:
|
||||
|
||||
- `kind: "construct" | "outline" | "color" | "shade" | "detail"` — drives UI hints and which canvas
|
||||
layer the child should be drawing on.
|
||||
- `targetLayer` — which layer this step belongs to (sketch / line / color / shade).
|
||||
- `shadeGuide` — optional overlay showing the light source ☀️ and the highlight/mid-tone/shadow
|
||||
zones for the step (so we can *show* where it gets dark).
|
||||
- `palette` — for color/shade steps, a small kid-safe swatch set (and a **grayscale value strip**
|
||||
for shading lessons, since value is taught before hue).
|
||||
|
||||
Because these are all optional, the six existing lessons keep working untouched.
|
||||
|
||||
### 3c. Canvas / tool features to build (in priority order)
|
||||
|
||||
1. **Layers in the drawing stage** (highest leverage — unlocks "layering" properly). Start with a
|
||||
fixed set — *Sketch, Lines, Color, Shading* — each a transparent canvas with show/hide and
|
||||
opacity. The construction guide lives on Sketch at low opacity; kids ink on Lines; the lesson can
|
||||
auto-advance the active layer per step. This is the single feature that most closes the gap with
|
||||
SimplyDraw and teaches the pro workflow at the same time.
|
||||
2. **A shading brush** — a soft, low-opacity, pressure-/speed-sensitive brush (stack passes to go
|
||||
darker), plus a **hatching helper** that snaps stroke direction to follow the form. Add a
|
||||
light-source marker and faint highlight/shadow zones as a guide overlay.
|
||||
3. **Value-first palette** — a grayscale strip for value drills and a small color palette for color
|
||||
lessons; teach value before color, as the books do.
|
||||
4. **Reference panel** — an optional side-by-side reference image (or the finished cumulative SVG)
|
||||
the child studies before/while drawing, mirroring SimplyDraw's "study the reference → draw →
|
||||
shade" flow.
|
||||
5. **Stroke replay / animated steps** — record the lesson's guide as a timeline and play it back as
|
||||
an animation ("watch it drawn, then you try"). Cheap to do with our SVG steps and matches
|
||||
Draw.ai / Kids Doodle's most-loved feature. Bonus: kids can replay *their own* drawing.
|
||||
6. **Multi-modal cues** — we already have text instructions and tips; add optional short audio
|
||||
narration per step (Drawing Desk's voice guides) using the browser's built-in speech synthesis,
|
||||
so no files and nothing leaves the device.
|
||||
7. **"Make it your own" finish** — after the guided steps, a free-draw canvas seeded with their
|
||||
drawing, so every lesson ends in creative expression (and still earns the badge/award).
|
||||
|
||||
### 3d. Keep our principles
|
||||
|
||||
Everything above can be done client-side with the same stack: extra canvases for layers,
|
||||
`SpeechSynthesis` for narration, SVG timelines for replay/animation. **No new data collection, no
|
||||
third-party services, still free and open source.** Reference images and audio are generated locally
|
||||
or bundled, so nothing about the child leaves the server you run.
|
||||
|
||||
## 4. Suggested roadmap
|
||||
|
||||
- **Phase 1 (quick wins):** name "construction first" in Early Beginner; add per-step `kind`/tip
|
||||
copy; add stroke **replay** of the guide; add optional speech narration. Small code, big feel.
|
||||
- **Phase 2 (shading module):** value strip + soft shading brush + light-source/zone overlays; ship
|
||||
the **sphere** drill and a shaded version of an existing subject (e.g., a shaded fish) as the first
|
||||
Learner lessons.
|
||||
- **Phase 3 (layering engine):** the Sketch/Lines/Color/Shading layer system in the canvas, with the
|
||||
lesson auto-selecting the active layer per step; convert one subject to the full
|
||||
construct→outline→color→shade workflow as the flagship Advanced lesson.
|
||||
- **Phase 4 (color + scenes + polish):** basic color theory lessons, reference panel, multi-object
|
||||
Superb challenges, and a personalized "what do you want to draw?" path.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Simply Draw — official](https://www.hellosimply.com/simply-draw)
|
||||
- [Simply Draw on the App Store](https://apps.apple.com/us/app/simply-draw-learn-to-draw/id1639875485)
|
||||
- [Simply Draw review — Research.com](https://research.com/software/reviews/simplydraw-review)
|
||||
- [7 Best Drawing Apps for Kids — EducationalAppStore](https://www.educationalappstore.com/best-apps/best-drawing-apps-for-kids)
|
||||
- [Best Drawing Apps for Kids — PureWow](https://www.purewow.com/family/best-drawing-apps-for-kids)
|
||||
- [Drawing Desk — Google Play](https://play.google.com/store/apps/details?id=com.axis.drawingdesk.v3)
|
||||
- [Best Drawing Apps for Kids — Adobe Aqua](https://aqua.adobe.com/learn/drawing-apps-for-kids)
|
||||
- [What are the Drawing Fundamentals? — Fine Art Tutorials](https://finearttutorials.com/guide/drawing-fundamentals/)
|
||||
- [Shading Techniques — The Virtual Instructor](https://thevirtualinstructor.com/shading-techniques-basics.html)
|
||||
- [Hatching and cross-hatching — Fiveable](https://fiveable.me/drawing-foundations/unit-6/hatching-cross-hatching/study-guide/SBjr8djW1TCdGeXY)
|
||||
- [Working in layers — Wikipedia](https://en.wikipedia.org/wiki/Working_in_layers)
|
||||
- [Underdrawing — Wikipedia](https://en.wikipedia.org/wiki/Underdrawing)
|
||||
- [Layers (digital image editing) — Wikipedia](https://en.wikipedia.org/wiki/Layers_(digital_image_editing))
|
||||
@@ -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/)
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// better-sqlite3 is a native module; keep it external to the server bundle.
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
// Keep these out of the server bundle: better-sqlite3 is native; potrace/jimp are CommonJS and
|
||||
// break when webpack-bundled ("Right-hand side of 'instanceof' is not callable" from `x instanceof Jimp`).
|
||||
serverExternalPackages: ["better-sqlite3", "potrace", "jimp"],
|
||||
// Privacy: no telemetry headers, no powered-by header.
|
||||
poweredByHeader: false,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Row {
|
||||
id: number;
|
||||
subject: string;
|
||||
level: string;
|
||||
status: string;
|
||||
slug: string;
|
||||
emoji: string;
|
||||
image: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function AdminCreations({ review, ready }: { review: Row[]; ready: Row[] }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<number | null>(null);
|
||||
const [lightbox, setLightbox] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
const [prog, setProg] = useState<Record<number, { percent: number; etaSeconds: number | null }>>({});
|
||||
const editVal = useRef("");
|
||||
|
||||
const etaText = (secs: number | null) =>
|
||||
secs === null ? "estimating…" : secs <= 0 ? "almost done…" : secs < 60 ? `about ${secs}s left` : `about ${Math.ceil(secs / 60)} min left`;
|
||||
|
||||
// Auto-update: while anything is generating, poll its status and refresh when it changes
|
||||
// (so a Regenerate's new image appears on screen without a manual reload).
|
||||
useEffect(() => {
|
||||
const working = review.filter((r) => r.status === "pending" || r.status === "generating");
|
||||
if (working.length === 0) return;
|
||||
const iv = setInterval(async () => {
|
||||
for (const r of working) {
|
||||
try {
|
||||
const res = await fetch(`/api/create/${r.id}`);
|
||||
const d = await res.json();
|
||||
if (d.progress) setProg((prev) => ({ ...prev, [r.id]: { percent: d.progress.percent, etaSeconds: d.progress.etaSeconds } }));
|
||||
if (d.status && d.status !== r.status) {
|
||||
router.refresh();
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(iv);
|
||||
}, [review, router]);
|
||||
|
||||
async function act(id: number, action: "approve" | "block" | "promote" | "regenerate" | "rename", subject?: string) {
|
||||
setBusy(id);
|
||||
try {
|
||||
await fetch(`/api/admin/create/${id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(subject !== undefined ? { action, subject } : { action }),
|
||||
});
|
||||
setEditing(null);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (review.length === 0 && ready.length === 0) {
|
||||
return <p className="muted" style={{ padding: 16, margin: 0 }}>No created lessons yet.</p>;
|
||||
}
|
||||
|
||||
const thumb = (r: Row) =>
|
||||
r.image ? (
|
||||
<img
|
||||
src={r.image}
|
||||
alt={`${r.subject} line art`}
|
||||
onClick={() => setLightbox(r.image)}
|
||||
title="Click to view full screen"
|
||||
style={{ width: 96, height: 72, objectFit: "contain", background: "#fff", border: "1px solid var(--line)", borderRadius: 8, flex: "0 0 auto", cursor: "zoom-in" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 96, height: 72, display: "grid", placeItems: "center", border: "1px dashed var(--line)", borderRadius: 8, flex: "0 0 auto", fontSize: "1.4rem" }}>{r.emoji}</div>
|
||||
);
|
||||
|
||||
const previewHref = (r: Row) => `/learn/${r.level}/${r.slug}-outline`;
|
||||
|
||||
const card = (r: Row, kind: "queue" | "ready") => {
|
||||
const generated = !!r.image && (r.status === "review" || r.status === "ready");
|
||||
const working = r.status === "pending" || r.status === "generating";
|
||||
return (
|
||||
<div key={r.id} className="row" style={{ gap: 12, alignItems: "center", border: "1px solid var(--line)", borderRadius: 12, padding: 10, flexWrap: "wrap" }}>
|
||||
{thumb(r)}
|
||||
<div style={{ flex: 1, minWidth: 180 }}>
|
||||
{editing === r.id ? (
|
||||
<div className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
<input defaultValue={r.subject} onChange={(e) => (editVal.current = e.target.value)} maxLength={30} style={{ flex: 1, minWidth: 120 }} />
|
||||
<button className="btn" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "rename", editVal.current || r.subject)}>Save</button>
|
||||
<button className="btn ghost" style={mini} onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<strong>
|
||||
{r.emoji} {r.subject}{" "}
|
||||
<button className="btn ghost" style={{ ...mini, minHeight: 26, padding: "2px 8px" }} onClick={() => { editVal.current = r.subject; setEditing(r.id); }} title="Rename subject">✏️</button>
|
||||
<span className="muted" style={{ fontSize: "0.82rem", fontWeight: 400 }}> · {r.level}</span>
|
||||
</strong>
|
||||
)}
|
||||
<div className="muted" style={{ fontSize: "0.8rem", marginTop: 2 }}>
|
||||
{working ? `🎨 Drawing… ${prog[r.id] ? `${prog[r.id].percent}% · ${etaText(prog[r.id].etaSeconds)}` : "(updates automatically)"}` : r.status === "review" ? "🔎 Awaiting approval" : r.status === "needs_review" ? "⏳ Needs generation" : r.status === "failed" ? `⚠️ Failed: ${r.error || "unknown error"}` : r.status === "ready" ? "✓ Ready" : r.status}
|
||||
</div>
|
||||
</div>
|
||||
<span className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{generated && <a className="btn ghost" style={mini} href={previewHref(r)} target="_blank" rel="noopener">👁 Preview</a>}
|
||||
{kind === "queue" && (r.status === "review" || r.status === "needs_review") && (
|
||||
<button className="btn" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "approve")}>✓ Approve</button>
|
||||
)}
|
||||
{(generated || r.status === "failed") && (
|
||||
<button className="btn secondary" style={mini} disabled={busy === r.id || working} onClick={() => act(r.id, "regenerate")}>↻ Regenerate</button>
|
||||
)}
|
||||
{kind === "ready" && (
|
||||
<button className="btn secondary" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "promote")}>🌟 Promote</button>
|
||||
)}
|
||||
{r.status !== "blocked" && (
|
||||
<button className="btn danger" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "block")}>Block</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ padding: 12 }}>
|
||||
{review.length > 0 && (
|
||||
<>
|
||||
<strong>In review / in progress</strong>
|
||||
{review.map((r) => card(r, "queue"))}
|
||||
</>
|
||||
)}
|
||||
{ready.length > 0 && (
|
||||
<>
|
||||
<strong style={{ marginTop: 8 }}>Live — promote to everyone?</strong>
|
||||
{ready.map((r) => card(r, "ready"))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{lightbox && (
|
||||
<div
|
||||
onClick={() => setLightbox(null)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(20,16,30,0.82)", display: "grid", placeItems: "center", padding: 20, zIndex: 100, cursor: "zoom-out" }}
|
||||
>
|
||||
<img src={lightbox} alt="Generated line art, full size" style={{ maxWidth: "100%", maxHeight: "90vh", borderRadius: 12, background: "#fff", boxShadow: "0 12px 40px rgba(0,0,0,0.5)" }} />
|
||||
<button onClick={(e) => { e.stopPropagation(); setLightbox(null); }} aria-label="Close" style={{ position: "fixed", top: 16, right: 20, fontSize: "1.8rem", lineHeight: 1, background: "rgba(255,255,255,0.9)", border: "none", borderRadius: "50%", width: 44, height: 44, cursor: "pointer", fontWeight: 800 }}>×</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mini: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" };
|
||||
@@ -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}
|
||||
|
||||
@@ -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 { listAdminQueue, listReadyUnpromoted } from "@/lib/createdLessons";
|
||||
|
||||
export const metadata = { title: "Admin · DrawIt" };
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -19,6 +21,10 @@ 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; image: string | null; error: string | null }) =>
|
||||
({ id: r.id, subject: r.subject, level: r.level, status: r.status, slug: r.slug, emoji: r.emoji, image: r.image, error: r.error });
|
||||
const reviewCreations = listAdminQueue().map(pick);
|
||||
const readyCreations = listReadyUnpromoted().map(pick);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,6 +50,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 }}>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/session";
|
||||
import { getCreatedById, updateCreatedStatus, promoteCreated, processCreation, approveCreated, updateCreatedSubject } from "@/lib/createdLessons";
|
||||
import { sanitizeSubject } from "@/lib/moderation";
|
||||
import { resolvePrompt } from "@/lib/comfyui";
|
||||
|
||||
// Admin review queue: approve, regenerate, rename, block, or promote a creation.
|
||||
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; subject?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
|
||||
}
|
||||
|
||||
switch (body.action) {
|
||||
case "approve":
|
||||
if (row.template_svg) {
|
||||
// Already generated and previewed → make it live immediately (no re-generation).
|
||||
approveCreated(row.id);
|
||||
return NextResponse.json({ ok: true, status: "ready" });
|
||||
}
|
||||
// Legacy/not-generated → mark approved then generate; it will finish as 'ready'.
|
||||
approveCreated(row.id);
|
||||
updateCreatedStatus(row.id, "pending");
|
||||
void processCreation(row.id);
|
||||
return NextResponse.json({ ok: true, status: "generating" });
|
||||
case "regenerate":
|
||||
// Re-run ComfyUI + vectorize; keeps its moderation, so a review item returns to 'review'.
|
||||
updateCreatedStatus(row.id, "pending");
|
||||
void processCreation(row.id);
|
||||
return NextResponse.json({ ok: true, status: "generating" });
|
||||
case "rename": {
|
||||
// Admins can fix the subject wording; the new prompt is used on the next regenerate.
|
||||
const subject = sanitizeSubject(body.subject || "");
|
||||
if (!subject) return NextResponse.json({ error: "Enter a subject." }, { status: 400 });
|
||||
updateCreatedSubject(row.id, subject, resolvePrompt(subject));
|
||||
return NextResponse.json({ ok: true, subject });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { hasBadge } from "@/lib/progress";
|
||||
import { LESSONS } from "@/lib/curriculum";
|
||||
import { LESSONS, BEGINNER_PACKS } from "@/lib/curriculum";
|
||||
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
||||
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ badge: string }> }) {
|
||||
@@ -8,14 +8,18 @@ export async function GET(_req: Request, ctx: { params: Promise<{ badge: string
|
||||
if (!user) return new Response("Log in to download your award.", { status: 401 });
|
||||
|
||||
const { badge } = await ctx.params;
|
||||
// Award can belong to a lesson (Early Beginner) or a pack (Beginner).
|
||||
const lesson = LESSONS.find((l) => l.badgeKey === badge);
|
||||
if (!lesson) return new Response("Unknown award.", { status: 404 });
|
||||
const pack = BEGINNER_PACKS.find((p) => p.badgeKey === badge);
|
||||
if (!lesson && !pack) return new Response("Unknown award.", { status: 404 });
|
||||
if (!hasBadge(user.id, badge)) return new Response("You haven't earned this award yet.", { status: 403 });
|
||||
|
||||
const badgeName = lesson ? lesson.badgeName : pack!.badgeName;
|
||||
const lessonTitle = lesson ? lesson.title : `the ${pack!.name} pack`;
|
||||
const pdfBytes = await buildCertificate({
|
||||
name: user.username,
|
||||
badgeName: lesson.badgeName,
|
||||
lessonTitle: lesson.title,
|
||||
badgeName,
|
||||
lessonTitle,
|
||||
date: new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { getCreatedById, getCreatedProgress } 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 });
|
||||
|
||||
// Best-effort generation progress + ETA (from ComfyUI's WebSocket step events).
|
||||
let progress: { value: number; max: number; percent: number; etaSeconds: number | null } | null = null;
|
||||
const p = getCreatedProgress(row.id);
|
||||
if (p && p.max > 0) {
|
||||
const elapsed = Date.now() - p.startedAt;
|
||||
const perStep = p.value > 0 ? elapsed / p.value : 0;
|
||||
const etaSeconds = perStep > 0 ? Math.round((Math.max(0, p.max - p.value) * perStep) / 1000) : null;
|
||||
progress = { value: p.value, max: p.max, percent: Math.round((p.value / p.max) * 100), etaSeconds };
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: row.id, slug: row.slug, status: row.status, error: row.error, subject: row.subject, progress });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireCreator } from "@/lib/session";
|
||||
import { isCreateEnabled, resolvePrompt } from "@/lib/comfyui";
|
||||
import { moderateSubject } from "@/lib/moderation";
|
||||
import { createCreatedLesson, processCreation, listCreatedForUser } from "@/lib/createdLessons";
|
||||
import { CREATABLE_LEVELS } from "@/lib/curriculum";
|
||||
|
||||
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 (!CREATABLE_LEVELS.includes(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 = resolvePrompt(mod.subject);
|
||||
// Both allowlisted and review subjects generate now; review subjects finish in a quarantined
|
||||
// 'review' state (hidden from kids) so an admin can preview before approving. Blocked never reaches here.
|
||||
const row = createCreatedLesson({ userId: user.id, level, subject: mod.subject, prompt, moderation: mod.decision, status: "pending" });
|
||||
void processCreation(row.id);
|
||||
|
||||
return NextResponse.json({ id: row.id, slug: row.slug, status: "generating" });
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { markStepComplete, awardBadge, recordCompletion } from "@/lib/progress";
|
||||
import { getLesson } from "@/lib/curriculum";
|
||||
import { markStepComplete, awardBadge, recordCompletion, hasBadge } from "@/lib/progress";
|
||||
import { getLesson, getPackForLesson } from "@/lib/curriculum";
|
||||
import { isPackComplete } from "@/lib/gating";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const user = await getCurrentUser();
|
||||
@@ -25,7 +26,7 @@ export async function POST(req: Request) {
|
||||
let awarded = false;
|
||||
if (body.badgeKey) {
|
||||
const lesson = getLesson(level, sublevel);
|
||||
// Only award the badge that actually belongs to this lesson.
|
||||
// Only award the badge that actually belongs to this lesson (Early Beginner per-subject badges).
|
||||
if (lesson && lesson.badgeKey === body.badgeKey) {
|
||||
awardBadge(user.id, body.badgeKey);
|
||||
recordCompletion(user.id, level, sublevel, body.badgeKey);
|
||||
@@ -33,5 +34,14 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Beginner: award the per-pack badge once every subject/phase in the pack is finished.
|
||||
const lesson = getLesson(level, sublevel);
|
||||
const pack = lesson ? getPackForLesson(lesson) : undefined;
|
||||
if (pack && !hasBadge(user.id, pack.badgeKey) && isPackComplete(user.id, pack)) {
|
||||
awardBadge(user.id, pack.badgeKey);
|
||||
recordCompletion(user.id, pack.level, 0, pack.badgeKey);
|
||||
awarded = true;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, awarded });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"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, levels, enabled }: { level: string; levels: { key: string; name: 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 [progress, setProgress] = useState<{ percent: number; etaSeconds: number | null } | null>(null);
|
||||
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.progress) setProgress({ percent: d.progress.percent, etaSeconds: d.progress.etaSeconds });
|
||||
if (d.status === "ready") {
|
||||
clearInterval(poll.current);
|
||||
setProgress(null);
|
||||
setSlug(baseSlug);
|
||||
setPhase("ready");
|
||||
} else if (d.status === "review") {
|
||||
clearInterval(poll.current);
|
||||
setProgress(null);
|
||||
setPhase("review");
|
||||
} else if (d.status === "failed" || d.status === "blocked") {
|
||||
clearInterval(poll.current);
|
||||
setProgress(null);
|
||||
setPhase("failed");
|
||||
setMsg(d.error || "The drawing couldn't be made. Try a different subject.");
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function etaText(secs: number | null): string {
|
||||
if (secs === null) return "estimating…";
|
||||
if (secs <= 0) return "almost done…";
|
||||
if (secs < 60) return `about ${secs}s left`;
|
||||
return `about ${Math.ceil(secs / 60)} min left`;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The Create feature isn'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}>
|
||||
{levels.map((l) => (
|
||||
<option key={l.key} value={l.key}>{l.name}</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"}…</p>
|
||||
<div style={{ height: 10, borderRadius: 999, background: "var(--line)", overflow: "hidden", margin: "12px 0 6px" }}>
|
||||
<div style={{ height: "100%", width: `${progress ? Math.max(4, progress.percent) : 4}%`, background: "var(--primary)", transition: "width 0.4s ease" }} />
|
||||
</div>
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.9rem" }}>
|
||||
{progress ? `${progress.percent}% · ${etaText(progress.etaSeconds)}` : "Starting up…"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{phase === "review" && (
|
||||
<div className="card" style={{ marginTop: 12 }}>
|
||||
<p style={{ margin: 0 }}>📨 Thanks! "{subject}" 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}-outline`}>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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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";
|
||||
import { creatableLevels } from "@/lib/curriculum";
|
||||
|
||||
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 levels = creatableLevels().map((l) => ({ key: l.key, name: l.name }));
|
||||
const { level } = await searchParams;
|
||||
const lvl = levels.some((l) => l.key === level) ? (level as string) : levels[0]?.key ?? "early-beginner";
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<h1>✨ Create your own lesson</h1>
|
||||
<p className="muted" style={{ maxWidth: 640 }}>
|
||||
Type something you'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} levels={levels} enabled={isCreateEnabled()} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,22 @@ import LessonRunner from "@/components/LessonRunner";
|
||||
import ShadingRunner from "@/components/ShadingRunner";
|
||||
import TraceRunner from "@/components/TraceRunner";
|
||||
import ColoringRunner from "@/components/ColoringRunner";
|
||||
import { getLessonBySlug, getLevel, getSubjects, cumulativeSvg, type Lesson } from "@/lib/curriculum";
|
||||
import { getLessonBySlug, getLevel, getSubjects, cumulativeSvg, lessonStepCount, LIGHT_HINT_SVG, type Lesson } from "@/lib/curriculum";
|
||||
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";
|
||||
|
||||
const FREE_SLUG = "fish-outline"; // the only lesson playable without an account
|
||||
|
||||
// Friendly label for each phase (Early Beginner + Beginner).
|
||||
const PHASE_LABEL: Record<string, string> = {
|
||||
construct: "Shapes", outline: "Outline", detail: "Details", color: "Color", light: "Light & shadow", extra: "Color it!",
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const lesson = getLessonBySlug(slug);
|
||||
@@ -22,74 +29,76 @@ 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 (…-outline / …-detail / …-color).
|
||||
if (!lesson || lesson.level !== level) return renderCreated(level, slug);
|
||||
|
||||
const user = await getCurrentUser();
|
||||
if (!user && lesson.slug !== FREE_SLUG) redirect("/signup");
|
||||
|
||||
const levelName = getLevel(lesson.level)?.name ?? "";
|
||||
const stepCount = (l: Lesson) => l.steps.length || 1;
|
||||
const isComplete = (l: Lesson | undefined) =>
|
||||
!!l && !!user && getCompletedSteps(user.id, l.level, l.sublevel).length >= stepCount(l);
|
||||
// Level gate: e.g. Beginner stays locked until 5 Early Beginner lessons are done.
|
||||
if (user && !isLevelUnlocked(user.id, lesson.level)) redirect("/learn");
|
||||
|
||||
// Phase gating: Detail needs Outline done; Extra needs Detail done.
|
||||
if (user && lesson.phase) {
|
||||
const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey);
|
||||
const find = (ph: string) => subj?.lessons.find((l) => l.phase === ph);
|
||||
if (lesson.phase === "detail") {
|
||||
const o = find("outline");
|
||||
if (o && !isComplete(o)) redirect(`/learn/${level}/${o.slug}`);
|
||||
} else if (lesson.phase === "extra") {
|
||||
const d = find("detail");
|
||||
if (d && !isComplete(d) && !(d.badgeKey && hasBadge(user.id, d.badgeKey))) redirect(`/learn/${level}/${d.slug}`);
|
||||
}
|
||||
const levelName = getLevel(lesson.level)?.name ?? "";
|
||||
const isComplete = (l: Lesson | undefined) =>
|
||||
!!l && !!user && getCompletedSteps(user.id, l.level, l.sublevel).length >= lessonStepCount(l);
|
||||
|
||||
// Subject phase order (works for both 3-phase EB and 4-phase Beginner).
|
||||
const subj = lesson.subjectKey ? getSubjects(level).find((s) => s.key === lesson.subjectKey) : undefined;
|
||||
const ordered = subj?.lessons ?? [lesson];
|
||||
const myIdx = ordered.findIndex((l) => l.slug === lesson.slug);
|
||||
const prevLesson = myIdx > 0 ? ordered[myIdx - 1] : undefined;
|
||||
const nextLesson = myIdx >= 0 && myIdx < ordered.length - 1 ? ordered[myIdx + 1] : undefined;
|
||||
|
||||
// Gate each phase behind the previous one in the subject.
|
||||
if (user && prevLesson && !isComplete(prevLesson) && !(prevLesson.badgeKey && hasBadge(user.id, prevLesson.badgeKey))) {
|
||||
redirect(`/learn/${level}/${prevLesson.slug}`);
|
||||
}
|
||||
|
||||
const completed = user ? getCompletedSteps(user.id, lesson.level, lesson.sublevel) : [];
|
||||
const alreadyEarned = !!user && !!lesson.badgeKey && hasBadge(user.id, lesson.badgeKey);
|
||||
const phaseLabel = PHASE_LABEL[lesson.phase ?? ""] ?? "";
|
||||
const common = { level: lesson.level, levelName, sublevel: lesson.sublevel, title: lesson.title, emoji: lesson.emoji, intro: lesson.intro };
|
||||
|
||||
// Coloring-book trace lessons (outline / detail)
|
||||
if (lesson.phase === "outline" || lesson.phase === "detail") {
|
||||
// The user's drawing carried from the previous phase (their own line work / coloring).
|
||||
const prevDrawing = user && prevLesson ? (getLatestDrawing(user.id, level, prevLesson.sublevel) ?? "") : "";
|
||||
|
||||
// Trace phases: lay-in shapes / refine outline / add details (line-by-line, traced).
|
||||
if (lesson.phase === "construct" || lesson.phase === "outline" || lesson.phase === "detail") {
|
||||
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: lesson.badgeKey, badgeName: lesson.badgeName, baseSvg: lesson.baseSvg ?? "", phaseLabel: lesson.phase === "outline" ? "Outline" : "Details" };
|
||||
const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey);
|
||||
// Carry the user's own outline drawing into the Details lesson as the starting canvas.
|
||||
let carryImage = "";
|
||||
if (lesson.phase === "detail" && user) {
|
||||
const o = subj?.lessons.find((l) => l.phase === "outline");
|
||||
if (o) carryImage = getLatestDrawing(user.id, level, o.sublevel) ?? "";
|
||||
}
|
||||
// Link to the next lesson in the subject.
|
||||
let nextHref = "", nextLabel = "";
|
||||
const nextPhase = lesson.phase === "outline" ? "detail" : "extra";
|
||||
const nextLesson = subj?.lessons.find((l) => l.phase === nextPhase);
|
||||
if (nextLesson) { nextHref = `/learn/${level}/${nextLesson.slug}`; nextLabel = lesson.phase === "outline" ? "Add the details" : "Color it in!"; }
|
||||
const meta = { ...common, badgeKey: lesson.badgeKey, badgeName: lesson.badgeName, baseSvg: lesson.baseSvg ?? "", phaseLabel };
|
||||
const nextHref = nextLesson ? `/learn/${level}/${nextLesson.slug}` : "";
|
||||
const nextLabel = nextLesson ? `Next: ${PHASE_LABEL[nextLesson.phase ?? ""] ?? "Continue"}` : "";
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<TraceRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} carryImage={carryImage} nextHref={nextHref} nextLabel={nextLabel} />
|
||||
<TraceRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} carryImage={prevDrawing} nextHref={nextHref} nextLabel={nextLabel} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Coloring studio (extra)
|
||||
if (lesson.phase === "extra") {
|
||||
const meta = { ...common, baseSvg: lesson.baseSvg ?? "" };
|
||||
// The user's own drawn sketch (from the Details lesson) — offered as an alternative to the stock line art.
|
||||
let userSketch = "";
|
||||
if (user) {
|
||||
const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey);
|
||||
const d = subj?.lessons.find((l) => l.phase === "detail");
|
||||
if (d) userSketch = getLatestDrawing(user.id, level, d.sublevel) ?? "";
|
||||
}
|
||||
// Coloring studio (Early Beginner "extra", Beginner "color").
|
||||
if (lesson.phase === "extra" || lesson.phase === "color") {
|
||||
const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel };
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={!!user} username={user?.username ?? ""} alreadyEarned={alreadyEarned} userSketch={userSketch} />
|
||||
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={!!user} username={user?.username ?? ""} alreadyEarned={alreadyEarned} userSketch={prevDrawing} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Light & shadow (Beginner): start from the colored drawing, add a single shadow side.
|
||||
if (lesson.phase === "light") {
|
||||
const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel, hintSvg: LIGHT_HINT_SVG };
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={!!user} username={user?.username ?? ""} alreadyEarned={alreadyEarned} carryImage={prevDrawing} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
@@ -112,3 +121,64 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Render an AI-created lesson (Outline → Details → Color it!), built on the fly from its DB row.
|
||||
const CREATED_PHASE_LABEL: Record<string, string> = { outline: "Outline", detail: "Details", color: "Color it!" };
|
||||
|
||||
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");
|
||||
// Admins can preview any generated lesson (incl. 'review'). Everyone else only sees 'ready' ones
|
||||
// they own or that were promoted to global — a 'review' item stays hidden until approved.
|
||||
const isReady = row.status === "ready";
|
||||
const canView = user.role === "admin" || (isReady && (row.user_id === user.id || row.promoted === 1));
|
||||
if (!canView) notFound();
|
||||
if (!isLevelUnlocked(user.id, level)) redirect("/learn");
|
||||
|
||||
const lesson = lessons[index];
|
||||
const prev = index > 0 ? lessons[index - 1] : undefined;
|
||||
const next = index < lessons.length - 1 ? lessons[index + 1] : undefined;
|
||||
const stepCount = (l: Lesson) => l.steps.length || 1;
|
||||
// Gate each phase behind the previous one.
|
||||
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 phaseLabel = CREATED_PHASE_LABEL[lesson.phase ?? ""] ?? "";
|
||||
const common = { level: lesson.level, levelName, sublevel: lesson.sublevel, title: lesson.title, emoji: lesson.emoji, intro: lesson.intro };
|
||||
// The child's drawing carried from the previous phase (their traced outline → details → color).
|
||||
const prevDrawing = prev ? (getLatestDrawing(user.id, level, prev.sublevel) ?? "") : "";
|
||||
|
||||
// Trace phases: outline / details.
|
||||
if (lesson.phase === "outline" || lesson.phase === "detail") {
|
||||
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 };
|
||||
const nextHref = next ? `/learn/${level}/${next.slug}` : "";
|
||||
const nextLabel = next ? `Next: ${CREATED_PHASE_LABEL[next.phase ?? ""] ?? "Continue"}` : "";
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<TraceRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={true} username={user.username} completedSteps={completed} alreadyEarned={false} carryImage={prevDrawing} nextHref={nextHref} nextLabel={nextLabel} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Color phase — "use my sketch" offers their traced drawing; "use template" uses the full line art.
|
||||
const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel };
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={true} username={user.username} alreadyEarned={false} userSketch={prevDrawing} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+233
-126
@@ -1,126 +1,233 @@
|
||||
import Link from "next/link";
|
||||
import SiteNav from "@/components/SiteNav";
|
||||
import { LEVELS, getSubjects, type Lesson } from "@/lib/curriculum";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { getCompletedSteps, hasBadge } from "@/lib/progress";
|
||||
|
||||
export const metadata = { title: "Learn · DrawIt" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PHASE_LABEL: Record<string, string> = { outline: "Outline", detail: "Details", extra: "Color it!" };
|
||||
|
||||
export default async function LearnPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
const stepCount = (l: Lesson) => l.steps.length || 1;
|
||||
const doneCount = (l: Lesson) => (user ? getCompletedSteps(user.id, l.level, l.sublevel).length : 0);
|
||||
const isDone = (l: Lesson) => {
|
||||
if (!user) return false;
|
||||
if (l.badgeKey && hasBadge(user.id, l.badgeKey)) return true;
|
||||
return doneCount(l) >= stepCount(l);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<h1>Learn to draw 🎨</h1>
|
||||
<p className="muted">
|
||||
Each picture has an <strong>Outline</strong> lesson and a <strong>Details</strong> lesson (earn the badge), plus an optional <strong>Color it!</strong> lesson.
|
||||
{!user && " Log in to track your progress and earn badges."}
|
||||
</p>
|
||||
|
||||
<div className="stack" style={{ marginTop: 18 }}>
|
||||
{LEVELS.map((lvl, idx) => {
|
||||
const subjects = getSubjects(lvl.key);
|
||||
const complete = new Map<string, boolean>(subjects.map((s) => [s.key, s.lessons.every((l) => isDone(l))]));
|
||||
// The first not-yet-finished phased subject is expanded; completed ones collapse.
|
||||
const firstIncomplete = subjects.find((s) => (s.lessons.length > 1 || !!s.lessons[0].phase) && !complete.get(s.key))?.key;
|
||||
|
||||
return (
|
||||
<div className="card" key={lvl.key}>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0 }}>{lvl.emoji} {idx + 1}. {lvl.name}</h2>
|
||||
{subjects.length === 0 && <span className="pill pending">Coming soon</span>}
|
||||
</div>
|
||||
<p className="muted">{lvl.blurb}</p>
|
||||
|
||||
<div className="stack" style={{ marginTop: 6 }}>
|
||||
{subjects.map((subj) => {
|
||||
const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase;
|
||||
|
||||
if (!phased) {
|
||||
const l = subj.lessons[0];
|
||||
const done = isDone(l);
|
||||
const inProg = !done && doneCount(l) > 0;
|
||||
return (
|
||||
<Link key={subj.key} href={`/learn/${l.level}/${l.slug}`} className="card" style={subCard(done)}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong>{subj.emoji} {l.title}</strong>
|
||||
{done ? <span className="pill active">✓ Done</span> : inProg ? <span className="pill pending">Step {Math.min(doneCount(l) + 1, stepCount(l))}/{stepCount(l)}</span> : <span className="muted" style={{ fontSize: "0.85rem" }}>{stepCount(l)} steps</span>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const byPhase = (p: string) => subj.lessons.find((l) => l.phase === p);
|
||||
const outline = byPhase("outline");
|
||||
const detail = byPhase("detail");
|
||||
const extra = byPhase("extra");
|
||||
const outlineDone = outline ? isDone(outline) : false;
|
||||
const detailDone = detail ? isDone(detail) : false;
|
||||
const subjComplete = !!complete.get(subj.key);
|
||||
|
||||
const phaseCard = (l: Lesson | undefined, locked: boolean, optional = false) => {
|
||||
if (!l) return null;
|
||||
const done = isDone(l);
|
||||
const inProg = !done && !locked && doneCount(l) > 0;
|
||||
const inner = (
|
||||
<div>
|
||||
<strong style={{ display: "block" }}>{PHASE_LABEL[l.phase ?? ""]}{optional ? " (optional)" : ""}</strong>
|
||||
<span className="muted" style={{ fontSize: "0.82rem" }}>
|
||||
{locked ? "🔒 Locked" : done ? "✓ Done" : inProg ? `Step ${Math.min(doneCount(l) + 1, stepCount(l))}/${stepCount(l)}` : l.phase === "extra" ? "Add color" : `${stepCount(l)} steps`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return locked ? (
|
||||
<div key={l.slug} style={{ ...phaseBox, opacity: 0.55 }}>{inner}</div>
|
||||
) : (
|
||||
<Link key={l.slug} href={`/learn/${l.level}/${l.slug}`} style={{ ...phaseBox, textDecoration: "none", color: "inherit", borderColor: done ? "var(--leaf)" : "var(--line)" }}>{inner}</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<details
|
||||
key={subj.key}
|
||||
open={subj.key === firstIncomplete}
|
||||
style={{ border: subjComplete ? "2px solid var(--leaf)" : "2px solid var(--line)", borderRadius: 14, padding: "10px 14px", background: "var(--surface)" }}
|
||||
>
|
||||
<summary style={{ cursor: "pointer", fontWeight: 800, fontSize: "1.05rem" }}>
|
||||
{subj.emoji} {subj.name}
|
||||
{subjComplete ? (
|
||||
<span className="pill active" style={{ marginLeft: 8 }}>✓ Done</span>
|
||||
) : outlineDone || detailDone ? (
|
||||
<span className="pill pending" style={{ marginLeft: 8 }}>In progress</span>
|
||||
) : null}
|
||||
</summary>
|
||||
<div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", marginTop: 12 }}>
|
||||
{phaseCard(outline, false)}
|
||||
{phaseCard(detail, !outlineDone)}
|
||||
{phaseCard(extra, !detailDone, true)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 });
|
||||
const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" };
|
||||
import Link from "next/link";
|
||||
import SiteNav from "@/components/SiteNav";
|
||||
import { LEVELS, getSubjects, getGroupedSubjects, BEGINNER_PACKS, CREATABLE_LEVELS, lessonStepCount, type Lesson } from "@/lib/curriculum";
|
||||
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";
|
||||
|
||||
const PHASE_LABEL: Record<string, string> = {
|
||||
construct: "Shapes", outline: "Outline", detail: "Details", color: "Color", light: "Light & shadow", extra: "Color it!",
|
||||
};
|
||||
const OPTIONAL_PHASES = new Set(["extra"]);
|
||||
|
||||
type Subject = ReturnType<typeof getSubjects>[number];
|
||||
|
||||
export default async function LearnPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
const doneCount = (l: Lesson) => (user ? getCompletedSteps(user.id, l.level, l.sublevel).length : 0);
|
||||
const isDone = (l: Lesson) => {
|
||||
if (!user) return false;
|
||||
if (l.badgeKey && hasBadge(user.id, l.badgeKey)) return true;
|
||||
return doneCount(l) >= lessonStepCount(l);
|
||||
};
|
||||
const subjectComplete = (s: Subject) => s.lessons.every((l) => isDone(l));
|
||||
|
||||
// One card per phase, each gated behind the previous phase in the subject.
|
||||
const phaseCard = (l: Lesson, locked: boolean) => {
|
||||
const optional = OPTIONAL_PHASES.has(l.phase ?? "");
|
||||
const done = isDone(l);
|
||||
const inProg = !done && !locked && doneCount(l) > 0;
|
||||
const inner = (
|
||||
<div>
|
||||
<strong style={{ display: "block" }}>{PHASE_LABEL[l.phase ?? ""] ?? l.title}{optional ? " (optional)" : ""}</strong>
|
||||
<span className="muted" style={{ fontSize: "0.82rem" }}>
|
||||
{locked ? "🔒 Locked" : done ? "✓ Done" : inProg ? `Step ${Math.min(doneCount(l) + 1, lessonStepCount(l))}/${lessonStepCount(l)}` : l.steps.length === 0 ? "Tap to start" : `${lessonStepCount(l)} steps`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return locked ? (
|
||||
<div key={l.slug} style={{ ...phaseBox, opacity: 0.55 }}>{inner}</div>
|
||||
) : (
|
||||
<Link key={l.slug} href={`/learn/${l.level}/${l.slug}`} style={{ ...phaseBox, textDecoration: "none", color: "inherit", borderColor: done ? "var(--leaf)" : "var(--line)" }}>{inner}</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const subjectAccordion = (subj: Subject, open: boolean) => {
|
||||
const complete = subjectComplete(subj);
|
||||
const anyStarted = subj.lessons.some((l) => doneCount(l) > 0);
|
||||
// gate each phase behind the previous one being done
|
||||
let prevDone = true;
|
||||
const cards = subj.lessons.map((l) => {
|
||||
const locked = !prevDone;
|
||||
const card = phaseCard(l, locked);
|
||||
prevDone = isDone(l);
|
||||
return card;
|
||||
});
|
||||
return (
|
||||
<details key={subj.key} open={open} style={{ border: complete ? "2px solid var(--leaf)" : "2px solid var(--line)", borderRadius: 14, padding: "10px 14px", background: "var(--surface)" }}>
|
||||
<summary style={{ cursor: "pointer", fontWeight: 800, fontSize: "1.05rem" }}>
|
||||
{subj.emoji} {subj.name}
|
||||
{complete ? <span className="pill active" style={{ marginLeft: 8 }}>✓ Done</span> : anyStarted ? <span className="pill pending" style={{ marginLeft: 8 }}>In progress</span> : null}
|
||||
</summary>
|
||||
<div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", marginTop: 12 }}>
|
||||
{cards}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
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) });
|
||||
|
||||
// A single "Create your own" section for admins/creators, shown once at the bottom of the page.
|
||||
// Gathers the user's creations across every creatable level, plus any promoted (global) ones.
|
||||
const creationsSection = () => {
|
||||
if (!creator) return null; // only admins/creators see this section
|
||||
const mine: CreatedLessonRow[] = user ? CREATABLE_LEVELS.flatMap((lvl) => listCreatedForUser(user.id, lvl)) : [];
|
||||
const featured: CreatedLessonRow[] = CREATABLE_LEVELS.flatMap((lvl) => listPromoted(lvl));
|
||||
const ready = mine.filter((r) => r.status === "ready");
|
||||
const pending = mine.filter((r) => ["pending", "generating", "needs_review", "review"].includes(r.status));
|
||||
// Hide entirely if Create isn't configured and there's nothing to show.
|
||||
if (!createEnabled && ready.length === 0 && pending.length === 0 && featured.length === 0) return null;
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 18, border: "2px dashed var(--primary)" }}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
||||
<h2 style={{ margin: 0 }}>✨ Create your own</h2>
|
||||
{createEnabled && <Link className="btn" style={{ minHeight: 36, padding: "6px 12px" }} href="/create">+ New lesson</Link>}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 4 }}>Make a brand-new lesson from any subject you can imagine.</p>
|
||||
<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 === "review" || r.status === "needs_review" ? "⏳ Waiting for approval" : "🎨 Drawing…"}</span>
|
||||
</div>
|
||||
))}
|
||||
{createEnabled && 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 />
|
||||
<main className="container page">
|
||||
<h1>Learn to draw 🎨</h1>
|
||||
<p className="muted">
|
||||
Each picture is built up in steps. Finish the lessons to earn badges and fill your gallery.
|
||||
{!user && " Log in to track your progress and earn badges."}
|
||||
</p>
|
||||
|
||||
<div className="stack" style={{ marginTop: 18 }}>
|
||||
{LEVELS.map((lvl, idx) => {
|
||||
const subjects = getSubjects(lvl.key);
|
||||
const unlocked = isLevelUnlocked(user?.id ?? null, lvl.key);
|
||||
const orderedSubjects = lvl.key === "early-beginner" ? getGroupedSubjects(lvl.key).flatMap((g) => g.subjects) : subjects;
|
||||
const firstIncomplete = orderedSubjects.find((s) => !subjectComplete(s))?.key;
|
||||
|
||||
return (
|
||||
<div className="card" key={lvl.key}>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0 }}>{lvl.emoji} {idx + 1}. {lvl.name}</h2>
|
||||
{subjects.length === 0 && <span className="pill pending">Coming soon</span>}
|
||||
{subjects.length > 0 && !unlocked && <span className="pill pending">🔒 Locked</span>}
|
||||
</div>
|
||||
<p className="muted">{lvl.blurb}</p>
|
||||
|
||||
{/* Locked level: show how to unlock */}
|
||||
{subjects.length > 0 && !unlocked ? (
|
||||
<div className="card" style={{ background: "var(--surface)", textAlign: "center", marginTop: 6 }}>
|
||||
{!user ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/signup">Sign up</Link> and finish {BEGINNER_UNLOCK_THRESHOLD} Early Beginner drawings to unlock this level.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ margin: "0 0 6px" }}>🔒 Finish <strong>{BEGINNER_UNLOCK_THRESHOLD}</strong> Early Beginner lessons to unlock the {lvl.name} level.</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
You've done {Math.min(countCompletedLessons(user.id, "early-beginner"), BEGINNER_UNLOCK_THRESHOLD)} of {BEGINNER_UNLOCK_THRESHOLD}. Keep going!
|
||||
</p>
|
||||
<Link className="btn" href="/learn/early-beginner/fish-outline" style={{ marginTop: 10 }}>Practice Early Beginner</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : lvl.key === "beginner" ? (
|
||||
// Beginner: group subjects into packs, each with a per-pack badge.
|
||||
<div className="stack" style={{ marginTop: 6 }}>
|
||||
{BEGINNER_PACKS.map((pack) => {
|
||||
const packSubjects = subjects.filter((s) => pack.subjectKeys.includes(s.key));
|
||||
const packDone = !!user && isPackComplete(user.id, pack);
|
||||
return (
|
||||
<div key={pack.key} style={{ border: packDone ? "2px solid var(--leaf)" : "2px dashed var(--line)", borderRadius: 16, padding: "12px 14px" }}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
||||
<strong style={{ fontSize: "1.05rem" }}>{pack.emoji} {pack.name}</strong>
|
||||
{packDone ? <span className="pill active">🏅 {pack.badgeName}</span> : <span className="muted" style={{ fontSize: "0.82rem" }}>Finish all {pack.subjectKeys.length} to earn {pack.badgeName}</span>}
|
||||
</div>
|
||||
<p className="muted" style={{ margin: "4px 0 10px", fontSize: "0.9rem" }}>{pack.blurb}</p>
|
||||
<div className="stack">
|
||||
{packSubjects.map((subj) => subjectAccordion(subj, subj.key === firstIncomplete))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : lvl.key === "early-beginner" ? (
|
||||
// Early Beginner: themed groups, each holding its subjects.
|
||||
<div className="stack" style={{ marginTop: 6 }}>
|
||||
{getGroupedSubjects(lvl.key).map((group) => {
|
||||
const groupDone = group.subjects.every((s) => subjectComplete(s));
|
||||
return (
|
||||
<div key={group.key} style={{ border: groupDone ? "2px solid var(--leaf)" : "2px dashed var(--line)", borderRadius: 16, padding: "12px 14px" }}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong style={{ fontSize: "1.05rem" }}>{group.emoji} {group.name}</strong>
|
||||
<span className="muted" style={{ fontSize: "0.82rem" }}>{group.subjects.filter((s) => subjectComplete(s)).length}/{group.subjects.length} done</span>
|
||||
</div>
|
||||
<div className="stack" style={{ marginTop: 10 }}>
|
||||
{group.subjects.map((subj) => subjectAccordion(subj, subj.key === firstIncomplete))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// Any future flat level
|
||||
<div className="stack" style={{ marginTop: 6 }}>
|
||||
{subjects.map((subj) => {
|
||||
const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase;
|
||||
if (!phased) {
|
||||
const l = subj.lessons[0];
|
||||
const done = isDone(l);
|
||||
const inProg = !done && doneCount(l) > 0;
|
||||
return (
|
||||
<Link key={subj.key} href={`/learn/${l.level}/${l.slug}`} className="card" style={subCard(done)}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong>{subj.emoji} {l.title}</strong>
|
||||
{done ? <span className="pill active">✓ Done</span> : inProg ? <span className="pill pending">Step {Math.min(doneCount(l) + 1, lessonStepCount(l))}/{lessonStepCount(l)}</span> : <span className="muted" style={{ fontSize: "0.85rem" }}>{lessonStepCount(l)} steps</span>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return subjectAccordion(subj, subj.key === firstIncomplete);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Single "Create your own" section at the very bottom (admins/creators only). */}
|
||||
{creationsSection()}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 });
|
||||
const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" };
|
||||
|
||||
+124
-121
@@ -1,121 +1,124 @@
|
||||
import Link from "next/link";
|
||||
import SiteNav from "@/components/SiteNav";
|
||||
import FishSlideshow from "@/components/FishSlideshow";
|
||||
import { LEVELS, getSubjects, FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON } from "@/lib/curriculum";
|
||||
|
||||
const FREE_SLUG = "fish-outline";
|
||||
|
||||
export default function Home() {
|
||||
// Build a coloring-book "video" preview from the fish outline + detail lines
|
||||
const fishSteps = [...FISH_OUTLINE_LESSON.steps, ...FISH_DETAIL_LESSON.steps];
|
||||
let cum = "";
|
||||
const slides = fishSteps.map((s, i) => {
|
||||
cum += (s.lines || []).join("\n") + "\n";
|
||||
return { n: i + 1, title: s.title, instruction: s.instruction, cumulativeSvg: cum };
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main>
|
||||
<section className="section">
|
||||
<div className="container" style={{ textAlign: "center" }}>
|
||||
<div className="pill role" style={{ marginBottom: 14 }}>🆓 Free & open source · 🔒 No data collected, ever</div>
|
||||
<h1>Learn to draw, one line at a time</h1>
|
||||
<p className="muted" style={{ fontSize: "1.2rem", maxWidth: 640, margin: "0 auto 24px" }}>
|
||||
DrawIt teaches kids to draw with simple, friendly steps — built for iPads and tablets. Each
|
||||
picture has an <strong>Outline</strong> lesson, a <strong>Details</strong> lesson, and a fun
|
||||
<strong> Color it!</strong> lesson. <strong>The first lesson is free</strong> — create an account to unlock the rest.
|
||||
</p>
|
||||
<div className="row" style={{ justifyContent: "center" }}>
|
||||
<Link href={`/learn/early-beginner/${FREE_SLUG}`} className="btn big">🐟 Try the first lesson free</Link>
|
||||
<Link href="/signup" className="btn secondary big">Create a free account</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ background: "var(--surface)", borderBlock: "1px solid var(--line)" }}>
|
||||
<div className="container">
|
||||
<h2 className="center">Watch it drawn, line by line 🐟</h2>
|
||||
<p className="center muted" style={{ maxWidth: 560, margin: "0 auto 26px" }}>
|
||||
Lessons draw each line slowly so kids can follow along, like a coloring book that builds itself.
|
||||
</p>
|
||||
<FishSlideshow steps={slides} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2 className="center">Start free — unlock everything with an account</h2>
|
||||
<p className="center muted" style={{ maxWidth: 600, margin: "0 auto 26px" }}>
|
||||
The first lesson (Fish → Outline) is free to try. A free account unlocks the rest, saves your
|
||||
progress, and lets you earn badges and keep your drawings.
|
||||
</p>
|
||||
<div className="stack">
|
||||
{LEVELS.map((lvl, idx) => {
|
||||
const subjects = getSubjects(lvl.key);
|
||||
const isFirst = lvl.key === "early-beginner";
|
||||
return (
|
||||
<div className="card" key={lvl.key}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3 style={{ margin: 0 }}>{lvl.emoji} {idx + 1}. {lvl.name}</h3>
|
||||
{!isFirst && <span className="pill pending">🔒 Account needed</span>}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 6 }}>{lvl.blurb}</p>
|
||||
{isFirst && subjects.length > 0 && (
|
||||
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", marginTop: 8 }}>
|
||||
{subjects.map((subj) => {
|
||||
const first = subj.lessons[0];
|
||||
const free = first.slug === FREE_SLUG;
|
||||
return (
|
||||
<Link key={subj.key} href={free ? `/learn/${first.level}/${first.slug}` : "/signup"} style={card(!free)}>
|
||||
<span style={tag(free)}>{free ? "Free" : "🔒"}</span>
|
||||
<div style={{ fontSize: "1.8rem" }}>{subj.emoji}</div>
|
||||
<strong style={{ display: "block", paddingRight: 40 }}>{subj.name}</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.85rem" }}>
|
||||
{free ? "Outline · free to try" : "Create an account to unlock"}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isFirst && (
|
||||
<Link href="/signup" style={{ ...card(true), marginTop: 8, display: "block" }}>
|
||||
<span style={tag(false)}>🔒</span>
|
||||
<strong>Unlock {lvl.name}</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.85rem" }}>Create a free account to explore.</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="center" style={{ marginTop: 26 }}>
|
||||
<Link href="/signup" className="btn big">Create a free account</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ background: "var(--surface)", borderTop: "1px solid var(--line)" }}>
|
||||
<div className="container">
|
||||
<h2 className="center">How DrawIt works</h2>
|
||||
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", marginTop: 18 }}>
|
||||
<div className="card"><h3>✏️ Outline → Details</h3><p className="muted">Trace the coloring-book outline, then add the details that bring it to life.</p></div>
|
||||
<div className="card"><h3>🎨 Color it in</h3><p className="muted">Finish with the coloring studio — color, shade, and make it your own.</p></div>
|
||||
<div className="card"><h3>🔒 Private by design</h3><p className="muted">No analytics, no trackers, no data sold or shared. Just drawing.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer className="site">DrawIt · Free & open source · No data collected. Made for young artists. 🎨</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function card(locked: boolean): React.CSSProperties {
|
||||
return { position: "relative", textDecoration: "none", color: "inherit", background: "var(--surface)", borderRadius: "var(--radius)", border: "2px solid var(--line)", padding: 18, opacity: locked ? 0.6 : 1, filter: locked ? "grayscale(0.7)" : "none" };
|
||||
}
|
||||
function tag(free: boolean): React.CSSProperties {
|
||||
return { position: "absolute", top: 12, right: 12, fontWeight: 800, fontSize: "0.8rem", padding: "3px 10px", borderRadius: 999, background: free ? "#e8f7ee" : "#f0ebf7", color: free ? "#1f7a45" : "var(--ink-soft)" };
|
||||
}
|
||||
import Link from "next/link";
|
||||
import SiteNav from "@/components/SiteNav";
|
||||
import FishSlideshow from "@/components/FishSlideshow";
|
||||
import { LEVELS, getSubjects, FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON } from "@/lib/curriculum";
|
||||
|
||||
const FREE_SLUG = "fish-outline";
|
||||
|
||||
export default function Home() {
|
||||
// Build a coloring-book "video" preview from the fish outline + detail lines
|
||||
const fishSteps = [...FISH_OUTLINE_LESSON.steps, ...FISH_DETAIL_LESSON.steps];
|
||||
let cum = "";
|
||||
const slides = fishSteps.map((s, i) => {
|
||||
cum += (s.lines || []).join("\n") + "\n";
|
||||
return { n: i + 1, title: s.title, instruction: s.instruction, cumulativeSvg: cum };
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main>
|
||||
<section className="section">
|
||||
<div className="container" style={{ textAlign: "center" }}>
|
||||
<div className="pill role" style={{ marginBottom: 14 }}>🆓 Free & open source · 🔒 No data collected, ever</div>
|
||||
<h1>Learn to draw, one line at a time</h1>
|
||||
<p className="muted" style={{ fontSize: "1.2rem", maxWidth: 640, margin: "0 auto 24px" }}>
|
||||
DrawIt teaches kids to draw with simple, friendly steps — built for iPads and tablets. Each
|
||||
picture has an <strong>Outline</strong> lesson, a <strong>Details</strong> lesson, and a fun
|
||||
<strong> Color it!</strong> lesson. <strong>The first lesson is free</strong> — create an account to unlock the rest.
|
||||
</p>
|
||||
<div className="row" style={{ justifyContent: "center" }}>
|
||||
<Link href={`/learn/early-beginner/${FREE_SLUG}`} className="btn big">🐟 Try the first lesson free</Link>
|
||||
<Link href="/signup" className="btn secondary big">Create a free account</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ background: "var(--surface)", borderBlock: "1px solid var(--line)" }}>
|
||||
<div className="container">
|
||||
<h2 className="center">Watch it drawn, line by line 🐟</h2>
|
||||
<p className="center muted" style={{ maxWidth: 560, margin: "0 auto 26px" }}>
|
||||
Lessons draw each line slowly so kids can follow along, like a coloring book that builds itself.
|
||||
</p>
|
||||
<FishSlideshow steps={slides} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2 className="center">Start free — unlock everything with an account</h2>
|
||||
<p className="center muted" style={{ maxWidth: 600, margin: "0 auto 26px" }}>
|
||||
The first lesson (Fish → Outline) is free to try. A free account unlocks the rest, saves your
|
||||
progress, and lets you earn badges and keep your drawings.
|
||||
</p>
|
||||
<div className="stack">
|
||||
{LEVELS.map((lvl, idx) => {
|
||||
const subjects = getSubjects(lvl.key);
|
||||
const isFirst = lvl.key === "early-beginner";
|
||||
return (
|
||||
<div className="card" key={lvl.key}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3 style={{ margin: 0 }}>{lvl.emoji} {idx + 1}. {lvl.name}</h3>
|
||||
{!isFirst && <span className="pill pending">🔒 Account needed</span>}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 6 }}>{lvl.blurb}</p>
|
||||
{isFirst && subjects.length > 0 && (
|
||||
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", marginTop: 8 }}>
|
||||
{subjects.slice(0, 6).map((subj) => {
|
||||
const first = subj.lessons[0];
|
||||
const free = first.slug === FREE_SLUG;
|
||||
return (
|
||||
<Link key={subj.key} href={free ? `/learn/${first.level}/${first.slug}` : "/signup"} style={card(!free)}>
|
||||
<span style={tag(free)}>{free ? "Free" : "🔒"}</span>
|
||||
<div style={{ fontSize: "1.8rem" }}>{subj.emoji}</div>
|
||||
<strong style={{ display: "block", paddingRight: 40 }}>{subj.name}</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.85rem" }}>
|
||||
{free ? "Outline · free to try" : "Create an account to unlock"}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isFirst && subjects.length > 6 && (
|
||||
<p className="muted" style={{ marginTop: 10, fontSize: "0.9rem" }}>…and lots more — shapes, animals, clothes, faces and more inside.</p>
|
||||
)}
|
||||
{!isFirst && (
|
||||
<Link href="/signup" style={{ ...card(true), marginTop: 8, display: "block" }}>
|
||||
<span style={tag(false)}>🔒</span>
|
||||
<strong>Unlock {lvl.name}</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.85rem" }}>Create a free account to explore.</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="center" style={{ marginTop: 26 }}>
|
||||
<Link href="/signup" className="btn big">Create a free account</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ background: "var(--surface)", borderTop: "1px solid var(--line)" }}>
|
||||
<div className="container">
|
||||
<h2 className="center">How DrawIt works</h2>
|
||||
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", marginTop: 18 }}>
|
||||
<div className="card"><h3>✏️ Outline → Details</h3><p className="muted">Trace the coloring-book outline, then add the details that bring it to life.</p></div>
|
||||
<div className="card"><h3>🎨 Color it in</h3><p className="muted">Finish with the coloring studio — color, shade, and make it your own.</p></div>
|
||||
<div className="card"><h3>🔒 Private by design</h3><p className="muted">No analytics, no trackers, no data sold or shared. Just drawing.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer className="site">DrawIt · Free & open source · No data collected. Made for young artists. 🎨</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function card(locked: boolean): React.CSSProperties {
|
||||
return { position: "relative", textDecoration: "none", color: "inherit", background: "var(--surface)", borderRadius: "var(--radius)", border: "2px solid var(--line)", padding: 18, opacity: locked ? 0.6 : 1, filter: locked ? "grayscale(0.7)" : "none" };
|
||||
}
|
||||
function tag(free: boolean): React.CSSProperties {
|
||||
return { position: "absolute", top: 12, right: 12, fontWeight: 800, fontSize: "0.8rem", padding: "3px 10px", borderRadius: 999, background: free ? "#e8f7ee" : "#f0ebf7", color: free ? "#1f7a45" : "var(--ink-soft)" };
|
||||
}
|
||||
|
||||
+12
-15
@@ -6,7 +6,7 @@ import ProfileGallery from "./ProfileGallery";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { listFinalDrawings } from "@/lib/drawings";
|
||||
import { listBadges, listCompletions } from "@/lib/progress";
|
||||
import { LESSONS, getLesson } from "@/lib/curriculum";
|
||||
import { badgeDefs, getLesson } from "@/lib/curriculum";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "My profile · DrawIt" };
|
||||
@@ -41,27 +41,24 @@ export default async function ProfilePage() {
|
||||
datesByBadge.get(c.badge_key)!.push(c.completed_at);
|
||||
}
|
||||
|
||||
// Earned badges → rich cards
|
||||
// Earned badges → rich cards (per-subject Early Beginner + per-pack Beginner)
|
||||
const earnedKeys = new Set(listBadges(user.id).map((b) => b.badge_key));
|
||||
const awardedAt = new Map(listBadges(user.id).map((b) => [b.badge_key, b.awarded_at]));
|
||||
const earned = LESSONS.filter((l) => l.badgeKey && earnedKeys.has(l.badgeKey)).map((l) => {
|
||||
const groupKey = `${l.level}-${l.sublevel}`;
|
||||
const dates = datesByBadge.get(l.badgeKey) ?? [awardedAt.get(l.badgeKey) ?? ""];
|
||||
const defs = badgeDefs();
|
||||
const earned = defs.filter((d) => earnedKeys.has(d.badgeKey)).map((d) => {
|
||||
const dates = datesByBadge.get(d.badgeKey) ?? [awardedAt.get(d.badgeKey) ?? ""];
|
||||
return {
|
||||
badgeKey: l.badgeKey,
|
||||
name: l.badgeName,
|
||||
emoji: l.emoji,
|
||||
badgeKey: d.badgeKey,
|
||||
name: d.name,
|
||||
emoji: d.emoji,
|
||||
dates: dates.filter(Boolean),
|
||||
drawingCount: drawingCountByGroup.get(groupKey) ?? 0,
|
||||
groupKey,
|
||||
drawingCount: d.groupKey ? (drawingCountByGroup.get(d.groupKey) ?? 0) : 0,
|
||||
groupKey: d.groupKey,
|
||||
};
|
||||
});
|
||||
|
||||
// Badges still to collect (only badge-bearing lessons; one per subject)
|
||||
const locked = LESSONS.filter((l) => l.badgeKey && !earnedKeys.has(l.badgeKey)).map((l) => ({
|
||||
name: l.badgeName,
|
||||
emoji: l.emoji,
|
||||
}));
|
||||
// Badges still to collect
|
||||
const locked = defs.filter((d) => !earnedKeys.has(d.badgeKey)).map((d) => ({ name: d.name, emoji: d.emoji }));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence";
|
||||
|
||||
interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string }
|
||||
interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string; phaseLabel?: string; hintSvg?: string }
|
||||
|
||||
const W = 880;
|
||||
const H = 660;
|
||||
@@ -32,10 +32,11 @@ function newLayer(id: number, name: string): Layer {
|
||||
return { id, name, visible: true, canvas: c };
|
||||
}
|
||||
|
||||
export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "" }: {
|
||||
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string;
|
||||
export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "", carryImage = "" }: {
|
||||
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string; carryImage?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const phaseLabel = meta.phaseLabel || "Color it!";
|
||||
// Which line art to color: the stock template or the child's own sketch.
|
||||
// If they have no sketch of their own, skip the choice and use the stock art.
|
||||
const [sketch, setSketch] = useState<"stock" | "yours" | null>(userSketch ? null : "stock");
|
||||
@@ -115,18 +116,24 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
|
||||
return () => { alive = false; };
|
||||
}, [sketch, meta.baseSvg, userSketch]);
|
||||
|
||||
// Restore saved coloring into the bottom layer
|
||||
// Restore saved coloring into the bottom layer; if none, seed with the carry image
|
||||
// (e.g. the Light phase starts from the colored drawing).
|
||||
useEffect(() => {
|
||||
if (!loggedIn) { composite(); return; }
|
||||
let alive = true;
|
||||
loadProgressDrawing(meta.level, meta.sublevel).then((data) => {
|
||||
if (!data || !alive) { composite(); return; }
|
||||
const seed = (data: string) => {
|
||||
const im = new Image();
|
||||
im.onload = () => { layersRef.current[0].canvas.getContext("2d")!.drawImage(im, 0, 0, W, H); composite(); };
|
||||
im.onload = () => { if (alive) { layersRef.current[0]?.canvas.getContext("2d")!.drawImage(im, 0, 0, W, H); composite(); } };
|
||||
im.src = data;
|
||||
};
|
||||
if (!loggedIn) { if (carryImage) seed(carryImage); else composite(); return; }
|
||||
loadProgressDrawing(meta.level, meta.sublevel).then((data) => {
|
||||
if (!alive) return;
|
||||
if (data) seed(data);
|
||||
else if (carryImage) seed(carryImage);
|
||||
else composite();
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [loggedIn, meta.level, meta.sublevel, composite]);
|
||||
}, [loggedIn, meta.level, meta.sublevel, composite, carryImage]);
|
||||
|
||||
function flatten(withTemplate: boolean): string {
|
||||
const c = document.createElement("canvas"); c.width = W; c.height = H;
|
||||
@@ -261,7 +268,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
|
||||
if (sketch === null) {
|
||||
return (
|
||||
<div>
|
||||
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · Color it!</div>
|
||||
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · {phaseLabel}</div>
|
||||
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
|
||||
<p className="muted">Which drawing would you like to color in?</p>
|
||||
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", marginTop: 12 }}>
|
||||
@@ -320,7 +327,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · Color it!</div>
|
||||
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · {phaseLabel}</div>
|
||||
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
|
||||
</div>
|
||||
<Link className="btn ghost" href={`/report?level=${meta.level}&sublevel=${meta.sublevel}`} style={{ minHeight: 40 }}>Report a problem</Link>
|
||||
@@ -333,6 +340,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
|
||||
<canvas ref={dispRef} width={W} height={H} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: tool === "fill" ? "pointer" : "crosshair" }} />
|
||||
{overlayUrl && <img src={overlayUrl} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "fill", pointerEvents: "none" }} />}
|
||||
{meta.hintSvg && <svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: meta.hintSvg }} />}
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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/ColorBook01.json";
|
||||
const TIMEOUT_MS = Number(process.env.COMFYUI_TIMEOUT_MS || 120000);
|
||||
const PROMPT_NODE = process.env.COMFYUI_PROMPT_NODE || "6"; // positive CLIPTextEncode in the workflow
|
||||
const SEED_NODE = process.env.COMFYUI_SEED_NODE || "3"; // KSampler in the workflow
|
||||
// Placeholder inside the workflow's positive prompt that we replace with the creator's subject.
|
||||
const SUBJECT_TOKEN = process.env.COMFYUI_SUBJECT_TOKEN || "[DrawItSubject]";
|
||||
|
||||
export function isCreateEnabled(): boolean {
|
||||
return URL_BASE.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved positive prompt that will be sent: the workflow's prompt-node text with the
|
||||
* `[DrawItSubject]` placeholder replaced by the subject. Stored for audit. Falls back to the bare
|
||||
* subject if the workflow can't be read.
|
||||
*/
|
||||
export function resolvePrompt(subject: string): string {
|
||||
try {
|
||||
const text = loadWorkflow()[PROMPT_NODE]?.inputs?.text;
|
||||
if (typeof text === "string") return text.split(SUBJECT_TOKEN).join(subject);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
|
||||
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 subject. Returns the PNG bytes.
|
||||
* Injects the subject into the workflow's prompt placeholder + a fresh seed.
|
||||
* `onProgress` (best-effort, via ComfyUI's WebSocket) reports sampler step value/max for an ETA.
|
||||
* Throws on timeout / ComfyUI errors (caller marks the lesson 'failed').
|
||||
*/
|
||||
export type ProgressFn = (p: { value: number; max: number }) => void;
|
||||
|
||||
export async function generateLineArt(subject: string, onProgress?: ProgressFn): Promise<Buffer> {
|
||||
if (!isCreateEnabled()) throw new Error("Create is disabled (COMFYUI_URL not set).");
|
||||
|
||||
const workflow = loadWorkflow();
|
||||
// Replace the [DrawItSubject] placeholder in the positive prompt with the creator's subject.
|
||||
const promptNode = workflow[PROMPT_NODE];
|
||||
if (promptNode?.inputs && typeof promptNode.inputs.text === "string") {
|
||||
promptNode.inputs.text = promptNode.inputs.text.split(SUBJECT_TOKEN).join(subject);
|
||||
}
|
||||
// Fresh seed each run so repeats vary.
|
||||
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)}`;
|
||||
|
||||
// Open a best-effort WebSocket for live step progress (ComfyUI has no "time left" endpoint).
|
||||
let ws: WebSocket | undefined;
|
||||
if (onProgress && typeof WebSocket !== "undefined") {
|
||||
try {
|
||||
const wsUrl = `${URL_BASE.replace(/^http/, "ws")}/ws?clientId=${clientId}`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (typeof ev.data !== "string") return; // ignore binary preview frames
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg?.type === "progress" && msg.data && typeof msg.data.max === "number") {
|
||||
onProgress({ value: Number(msg.data.value) || 0, max: Number(msg.data.max) || 0 });
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
ws.onerror = () => { /* best-effort; ignore */ };
|
||||
} catch {
|
||||
ws = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) { try { ws?.close(); } catch { /* */ } 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) { try { ws?.close(); } catch { /* */ } 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;
|
||||
}
|
||||
try { ws?.close(); } catch { /* */ }
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import "server-only";
|
||||
import { getDb } from "./db";
|
||||
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.
|
||||
*/
|
||||
|
||||
// pending/generating = working; review = generated but awaiting admin approval (hidden from kids);
|
||||
// ready = approved/auto-approved and visible; failed/blocked = terminal.
|
||||
export type CreatedStatus = "pending" | "generating" | "needs_review" | "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 '-outline'/'-detail'/'-color' 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: everything not yet live (working, awaiting review, or failed). */
|
||||
export function listAdminQueue(): CreatedLessonRow[] {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM created_lessons WHERE status IN ('pending','generating','needs_review','review','failed') ORDER BY created_at DESC")
|
||||
.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 setGenerated(id: number, templateSvg: string, image: string, status: CreatedStatus) {
|
||||
getDb()
|
||||
.prepare("UPDATE created_lessons SET status = ?, template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(status, templateSvg, image, id);
|
||||
}
|
||||
|
||||
/** Change a creation's subject (and the prompt that will be sent on the next generate). */
|
||||
export function updateCreatedSubject(id: number, subject: string, prompt: string) {
|
||||
getDb()
|
||||
.prepare("UPDATE created_lessons SET subject = ?, prompt = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(subject, prompt, id);
|
||||
}
|
||||
|
||||
/** Approve a creation: make it live (ready) and mark it approved. */
|
||||
export function approveCreated(id: number) {
|
||||
getDb()
|
||||
.prepare("UPDATE created_lessons SET status = 'ready', moderation = 'approved', updated_at = datetime('now') WHERE id = ?")
|
||||
.run(id);
|
||||
}
|
||||
|
||||
export function promoteCreated(id: number) {
|
||||
getDb().prepare("UPDATE created_lessons SET promoted = 1, updated_at = datetime('now') WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// Live generation progress (in-memory; single self-hosted node). Cleared when a run finishes.
|
||||
export interface CreatedProgress { value: number; max: number; startedAt: number; updatedAt: number }
|
||||
const progressStore = new Map<number, CreatedProgress>();
|
||||
export function getCreatedProgress(id: number): CreatedProgress | undefined {
|
||||
return progressStore.get(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");
|
||||
progressStore.set(id, { value: 0, max: 0, startedAt: Date.now(), updatedAt: Date.now() });
|
||||
const { generateLineArt } = await import("./comfyui");
|
||||
const { pngToLessonArt } = await import("./vectorize");
|
||||
const png = await generateLineArt(row.subject, (p) => {
|
||||
const prev = progressStore.get(id);
|
||||
progressStore.set(id, { value: p.value, max: p.max, startedAt: prev?.startedAt ?? Date.now(), updatedAt: Date.now() });
|
||||
});
|
||||
const art = await pngToLessonArt(png); // { outline, details, full }
|
||||
// Auto-approved (allowlist) or admin-approved → live; anything else is quarantined for review.
|
||||
const fresh = getCreatedById(id);
|
||||
const needsReview = fresh?.moderation === "review" || fresh?.moderation === "needs_review";
|
||||
setGenerated(id, JSON.stringify(art), `data:image/png;base64,${png.toString("base64")}`, needsReview ? "review" : "ready");
|
||||
} catch (e) {
|
||||
updateCreatedStatus(id, "failed", e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
progressStore.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredArt { outline: string; details: string; full: string; outlineStrokes: string[]; detailStrokes: string[] }
|
||||
|
||||
/** The stored line art. Tolerates legacy rows (bare SVG string, or pre-stroke JSON) by falling back. */
|
||||
function lessonArt(row: CreatedLessonRow): StoredArt {
|
||||
const raw = row.template_svg || "";
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
if (a && typeof a === "object" && "full" in a) {
|
||||
const outline = a.outline || a.full || "";
|
||||
return {
|
||||
outline,
|
||||
details: a.details || "",
|
||||
full: a.full || "",
|
||||
// Legacy rows lack stroke arrays → fall back to the filled path (fades in rather than draws).
|
||||
outlineStrokes: Array.isArray(a.outlineStrokes) && a.outlineStrokes.length ? a.outlineStrokes : outline ? [outline] : [],
|
||||
detailStrokes: Array.isArray(a.detailStrokes) ? a.detailStrokes : [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* legacy: a plain SVG string */
|
||||
}
|
||||
return { outline: raw, details: "", full: raw, outlineStrokes: raw ? [raw] : [], detailStrokes: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the playable lessons from a ready row, mirroring Early Beginner's 3-phase shape:
|
||||
* Outline (trace the main shape) → Details (add the inner lines) → Color it! (full template).
|
||||
* The Details phase is skipped when the art has no separable interior detail.
|
||||
*/
|
||||
export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] {
|
||||
const base = 900000 + row.id * 10;
|
||||
const { outline, full, outlineStrokes, detailStrokes } = lessonArt(row);
|
||||
const lower = row.subject.toLowerCase();
|
||||
const common = {
|
||||
level: row.level,
|
||||
emoji: row.emoji,
|
||||
subjectKey: row.slug,
|
||||
subjectName: row.subject,
|
||||
subjectEmoji: row.emoji,
|
||||
order: 0,
|
||||
badgeName: `${row.subject} Artist`,
|
||||
badgeKey: "",
|
||||
};
|
||||
// Each stroked chunk is one step, so the pen draws the shape a few strokes at a time.
|
||||
const toSteps = (strokes: string[], label: string) =>
|
||||
strokes.map((el, i) => ({ n: i + 1, title: `${label} ${i + 1}`, instruction: "Watch the line, then trace it!", tip: "", lines: [el] }));
|
||||
|
||||
const lessons: Lesson[] = [];
|
||||
lessons.push({
|
||||
...common,
|
||||
sublevel: base + 1,
|
||||
slug: `${row.slug}-outline`,
|
||||
title: `${row.subject} · Outline`,
|
||||
subject: `${lower} outline`,
|
||||
intro: `Trace the outline of your ${lower}, one line at a time.`,
|
||||
phase: "outline",
|
||||
baseSvg: "",
|
||||
steps: toSteps(outlineStrokes, "Outline"),
|
||||
});
|
||||
if (detailStrokes.length > 0) {
|
||||
lessons.push({
|
||||
...common,
|
||||
sublevel: base + 2,
|
||||
slug: `${row.slug}-detail`,
|
||||
title: `${row.subject} · Details`,
|
||||
subject: `${lower} details`,
|
||||
intro: `Now add the details inside your ${lower}.`,
|
||||
phase: "detail",
|
||||
baseSvg: outline, // faint filled outline as a reference under the new detail lines
|
||||
steps: toSteps(detailStrokes, "Detail"),
|
||||
});
|
||||
}
|
||||
lessons.push({
|
||||
...common,
|
||||
sublevel: base + 3,
|
||||
slug: `${row.slug}-color`,
|
||||
title: `${row.subject} · Color it!`,
|
||||
subject: `color the ${lower}`,
|
||||
intro: `Bring your ${lower} to life! Color it in.`,
|
||||
phase: "color",
|
||||
baseSvg: full,
|
||||
steps: [],
|
||||
});
|
||||
return lessons;
|
||||
}
|
||||
|
||||
/** Resolve a lesson-page slug (…-outline / …-detail / …-color) to its created lesson + its phase siblings. */
|
||||
export function resolveCreatedLesson(slug: string): { lessons: Lesson[]; index: number; row: CreatedLessonRow } | null {
|
||||
const m = slug.match(/^(.*)-(outline|detail|color)$/);
|
||||
if (!m) return null;
|
||||
const row = getCreatedBySlug(m[1]);
|
||||
// Built once generated: 'ready' (live) or 'review' (admin preview before approval).
|
||||
if (!row || !row.template_svg || (row.status !== "ready" && row.status !== "review")) return null;
|
||||
const lessons = buildCreatedLessons(row);
|
||||
const index = lessons.findIndex((l) => l.slug === slug);
|
||||
if (index < 0) return null;
|
||||
return { lessons, index, row };
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
/* AUTO-GENERATED by gen_animals.py — Early Beginner animals (original cute doodles). */
|
||||
import type { Lesson, LessonStep } from "./curriculum";
|
||||
|
||||
const LION_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The mane`, instruction:`Draw a big circle for the fluffy mane.`, lines:[`<circle cx="200" cy="150" r="86" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The face`, instruction:`Draw a round face inside.`, lines:[`<circle cx="200" cy="150" r="60" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The ears`, instruction:`Add two little ears.`, lines:[`<circle cx="152" cy="108" r="16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="248" cy="108" r="16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const LION_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Eyes & nose`, instruction:`Add eyes and a nose.`, lines:[`<circle cx="182" cy="144" r="6" fill="#2b2440"/>`,`<circle cx="218" cy="144" r="6" fill="#2b2440"/>`,`<path d="M192 164 L208 164 L200 174 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Big smile`, instruction:`Add the mouth.`, lines:[`<path d="M200 174 L200 184" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 184 q-12 10 -22 2" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 184 q12 10 22 2" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Mane fluff`, instruction:`Add fluffy lines around the mane.`, lines:[`<path d="M200 64 L200 50" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M258 82 L270 72" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M142 82 L130 72" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M286 150 L300 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M114 150 L100 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M258 218 L270 228" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M142 218 L130 228" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 236 L200 250" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const LION_OUTLINE_SVG = LION_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const LION_DETAIL_SVG = LION_OUTLINE_SVG + "\n" + LION_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const LION_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"lion", subjectName:`Lion`, subjectEmoji:`🦁`, emoji:`🦁`, order:20, sublevel:201, slug:"lion-outline", title:`Lion \u00b7 Outline`, subject:`lion outline`, intro:`Let\u2019s draw Lion\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Lion Legend`, phase:"outline", steps:LION_OUTLINE_STEPS };
|
||||
export const LION_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"lion", subjectName:`Lion`, subjectEmoji:`🦁`, emoji:`🦁`, order:20, sublevel:202, slug:"lion-detail", title:`Lion \u00b7 Details`, subject:`lion details`, intro:`Now add the details that bring your lion to life.`, badgeKey:"early-beginner-20-lion", badgeName:`Lion Legend`, phase:"detail", baseSvg:LION_OUTLINE_SVG, steps:LION_DETAIL_STEPS };
|
||||
export const LION_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"lion", subjectName:`Lion`, subjectEmoji:`🦁`, emoji:`🦁`, order:20, sublevel:203, slug:"lion-extra", title:`Lion \u00b7 Color it!`, subject:`color the lion`, intro:`Bring your lion to life! Color it in.`, badgeKey:"", badgeName:`Lion Legend`, phase:"extra", baseSvg:LION_DETAIL_SVG, steps:[] };
|
||||
|
||||
const ELEPHANT_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw a big round body.`, lines:[`<ellipse cx="214" cy="172" rx="82" ry="58" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The head`, instruction:`Add a round head on the left.`, lines:[`<circle cx="150" cy="150" r="52" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The trunk`, instruction:`Curl a trunk down from the head.`, lines:[`<path d="M118 158 C 96 180 100 220 120 232 C 132 236 140 226 132 216 C 122 206 126 188 142 178" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The legs`, instruction:`Add chunky legs.`, lines:[`<path d="M172 224 L172 254" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M214 226 L214 256" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M256 220 L256 250" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ELEPHANT_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The ear`, instruction:`Add a big floppy ear.`, lines:[`<path d="M150 110 C 108 116 108 180 152 176" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Eye & tusk`, instruction:`Add an eye and a tusk.`, lines:[`<circle cx="138" cy="146" r="5" fill="#2b2440"/>`,`<path d="M128 198 q-10 12 0 20" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The tail`, instruction:`Add a little tail.`, lines:[`<path d="M292 170 q22 8 14 34" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ELEPHANT_OUTLINE_SVG = ELEPHANT_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const ELEPHANT_DETAIL_SVG = ELEPHANT_OUTLINE_SVG + "\n" + ELEPHANT_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const ELEPHANT_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"elephant", subjectName:`Elephant`, subjectEmoji:`🐘`, emoji:`🐘`, order:21, sublevel:211, slug:"elephant-outline", title:`Elephant \u00b7 Outline`, subject:`elephant outline`, intro:`Let\u2019s draw Elephant\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Elephant Pal`, phase:"outline", steps:ELEPHANT_OUTLINE_STEPS };
|
||||
export const ELEPHANT_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"elephant", subjectName:`Elephant`, subjectEmoji:`🐘`, emoji:`🐘`, order:21, sublevel:212, slug:"elephant-detail", title:`Elephant \u00b7 Details`, subject:`elephant details`, intro:`Now add the details that bring your elephant to life.`, badgeKey:"early-beginner-21-elephant", badgeName:`Elephant Pal`, phase:"detail", baseSvg:ELEPHANT_OUTLINE_SVG, steps:ELEPHANT_DETAIL_STEPS };
|
||||
export const ELEPHANT_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"elephant", subjectName:`Elephant`, subjectEmoji:`🐘`, emoji:`🐘`, order:21, sublevel:213, slug:"elephant-extra", title:`Elephant \u00b7 Color it!`, subject:`color the elephant`, intro:`Bring your elephant to life! Color it in.`, badgeKey:"", badgeName:`Elephant Pal`, phase:"extra", baseSvg:ELEPHANT_DETAIL_SVG, steps:[] };
|
||||
|
||||
const GIRAFFE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw an oval body.`, lines:[`<ellipse cx="212" cy="202" rx="60" ry="38" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The neck`, instruction:`Draw a long tall neck.`, lines:[`<path d="M152 188 L140 102 L174 102 L188 192 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The head`, instruction:`Add a small head on top.`, lines:[`<ellipse cx="152" cy="94" rx="30" ry="20" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The legs`, instruction:`Add four long legs.`, lines:[`<path d="M178 236 L178 264" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M206 236 L206 264" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M236 232 L236 262" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M252 226 L252 258" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const GIRAFFE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Horns & ear`, instruction:`Add little horns and an ear.`, lines:[`<path d="M142 78 L140 66" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M162 78 L164 66" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="140" cy="64" r="4" fill="#2b2440"/>`,`<circle cx="164" cy="64" r="4" fill="#2b2440"/>`,`<path d="M128 92 q-12 -2 -14 8" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The face`, instruction:`Add an eye and a snout.`, lines:[`<circle cx="142" cy="90" r="4" fill="#2b2440"/>`,`<path d="M126 100 q-8 6 -2 12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Spots`, instruction:`Add some spots.`, lines:[`<circle cx="206" cy="196" r="7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="228" cy="206" r="6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="196" cy="214" r="6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="152" cy="150" r="6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="158" cy="176" r="5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const GIRAFFE_OUTLINE_SVG = GIRAFFE_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const GIRAFFE_DETAIL_SVG = GIRAFFE_OUTLINE_SVG + "\n" + GIRAFFE_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const GIRAFFE_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"giraffe", subjectName:`Giraffe`, subjectEmoji:`🦒`, emoji:`🦒`, order:22, sublevel:221, slug:"giraffe-outline", title:`Giraffe \u00b7 Outline`, subject:`giraffe outline`, intro:`Let\u2019s draw Giraffe\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Tall Giraffe`, phase:"outline", steps:GIRAFFE_OUTLINE_STEPS };
|
||||
export const GIRAFFE_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"giraffe", subjectName:`Giraffe`, subjectEmoji:`🦒`, emoji:`🦒`, order:22, sublevel:222, slug:"giraffe-detail", title:`Giraffe \u00b7 Details`, subject:`giraffe details`, intro:`Now add the details that bring your giraffe to life.`, badgeKey:"early-beginner-22-giraffe", badgeName:`Tall Giraffe`, phase:"detail", baseSvg:GIRAFFE_OUTLINE_SVG, steps:GIRAFFE_DETAIL_STEPS };
|
||||
export const GIRAFFE_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"giraffe", subjectName:`Giraffe`, subjectEmoji:`🦒`, emoji:`🦒`, order:22, sublevel:223, slug:"giraffe-extra", title:`Giraffe \u00b7 Color it!`, subject:`color the giraffe`, intro:`Bring your giraffe to life! Color it in.`, badgeKey:"", badgeName:`Tall Giraffe`, phase:"extra", baseSvg:GIRAFFE_DETAIL_SVG, steps:[] };
|
||||
|
||||
const ZEBRA_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw an oval body.`, lines:[`<ellipse cx="206" cy="176" rx="78" ry="46" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The head`, instruction:`Add a head up to the left.`, lines:[`<ellipse cx="118" cy="148" rx="32" ry="22" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The neck`, instruction:`Join the head to the body.`, lines:[`<path d="M138 138 L170 130 L182 176 L150 180 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The legs`, instruction:`Add four legs.`, lines:[`<path d="M172 218 L172 254" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M202 220 L202 256" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M236 216 L236 252" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M256 212 L256 248" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ZEBRA_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add an eye, snout and ear.`, lines:[`<circle cx="110" cy="144" r="4" fill="#2b2440"/>`,`<path d="M92 152 q-8 4 -2 12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M120 128 l4 -12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Mane & tail`, instruction:`Add a spiky mane and a tail.`, lines:[`<path d="M140 120 l6 -10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M150 122 l6 -10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M284 162 q18 6 12 30" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Stripes`, instruction:`Add bold stripes.`, lines:[`<path d="M180 136 L176 216" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M205 132 L205 220" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M230 138 L234 214" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ZEBRA_OUTLINE_SVG = ZEBRA_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const ZEBRA_DETAIL_SVG = ZEBRA_OUTLINE_SVG + "\n" + ZEBRA_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const ZEBRA_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"zebra", subjectName:`Zebra`, subjectEmoji:`🦓`, emoji:`🦓`, order:23, sublevel:231, slug:"zebra-outline", title:`Zebra \u00b7 Outline`, subject:`zebra outline`, intro:`Let\u2019s draw Zebra\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Stripe Star`, phase:"outline", steps:ZEBRA_OUTLINE_STEPS };
|
||||
export const ZEBRA_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"zebra", subjectName:`Zebra`, subjectEmoji:`🦓`, emoji:`🦓`, order:23, sublevel:232, slug:"zebra-detail", title:`Zebra \u00b7 Details`, subject:`zebra details`, intro:`Now add the details that bring your zebra to life.`, badgeKey:"early-beginner-23-zebra", badgeName:`Stripe Star`, phase:"detail", baseSvg:ZEBRA_OUTLINE_SVG, steps:ZEBRA_DETAIL_STEPS };
|
||||
export const ZEBRA_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"zebra", subjectName:`Zebra`, subjectEmoji:`🦓`, emoji:`🦓`, order:23, sublevel:233, slug:"zebra-extra", title:`Zebra \u00b7 Color it!`, subject:`color the zebra`, intro:`Bring your zebra to life! Color it in.`, badgeKey:"", badgeName:`Stripe Star`, phase:"extra", baseSvg:ZEBRA_DETAIL_SVG, steps:[] };
|
||||
|
||||
const RHINO_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw a big oval body.`, lines:[`<ellipse cx="208" cy="176" rx="82" ry="52" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The head`, instruction:`Add a big head on the left.`, lines:[`<ellipse cx="120" cy="170" rx="46" ry="38" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The horn`, instruction:`Add a pointy horn.`, lines:[`<path d="M86 152 L74 120 L102 148 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The legs`, instruction:`Add sturdy legs.`, lines:[`<path d="M168 224 L168 254" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M208 226 L208 256" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 222 L250 252" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const RHINO_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Eye & ear`, instruction:`Add an eye and an ear.`, lines:[`<circle cx="120" cy="152" r="4" fill="#2b2440"/>`,`<path d="M140 132 q6 -12 16 -8" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Small horn & mouth`, instruction:`Add a second horn and mouth.`, lines:[`<path d="M106 152 q6 -10 14 -6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M84 180 q-10 4 -4 10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The tail`, instruction:`Add a little tail.`, lines:[`<path d="M288 170 q20 6 14 30" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const RHINO_OUTLINE_SVG = RHINO_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const RHINO_DETAIL_SVG = RHINO_OUTLINE_SVG + "\n" + RHINO_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const RHINO_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"rhino", subjectName:`Rhino`, subjectEmoji:`🦏`, emoji:`🦏`, order:24, sublevel:241, slug:"rhino-outline", title:`Rhino \u00b7 Outline`, subject:`rhino outline`, intro:`Let\u2019s draw Rhino\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Rhino Rumble`, phase:"outline", steps:RHINO_OUTLINE_STEPS };
|
||||
export const RHINO_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"rhino", subjectName:`Rhino`, subjectEmoji:`🦏`, emoji:`🦏`, order:24, sublevel:242, slug:"rhino-detail", title:`Rhino \u00b7 Details`, subject:`rhino details`, intro:`Now add the details that bring your rhino to life.`, badgeKey:"early-beginner-24-rhino", badgeName:`Rhino Rumble`, phase:"detail", baseSvg:RHINO_OUTLINE_SVG, steps:RHINO_DETAIL_STEPS };
|
||||
export const RHINO_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"rhino", subjectName:`Rhino`, subjectEmoji:`🦏`, emoji:`🦏`, order:24, sublevel:243, slug:"rhino-extra", title:`Rhino \u00b7 Color it!`, subject:`color the rhino`, intro:`Bring your rhino to life! Color it in.`, badgeKey:"", badgeName:`Rhino Rumble`, phase:"extra", baseSvg:RHINO_DETAIL_SVG, steps:[] };
|
||||
|
||||
const LEOPARD_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The head`, instruction:`Draw a round head.`, lines:[`<circle cx="200" cy="120" r="46" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add two ears.`, lines:[`<circle cx="168" cy="86" r="16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="232" cy="86" r="16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The body`, instruction:`Add a sitting body.`, lines:[`<ellipse cx="200" cy="212" rx="58" ry="52" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The paws`, instruction:`Add two front paws.`, lines:[`<path d="M172 252 q0 14 14 14" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M228 252 q0 14 -14 14" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const LEOPARD_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add eyes, nose and mouth.`, lines:[`<circle cx="184" cy="118" r="5" fill="#2b2440"/>`,`<circle cx="216" cy="118" r="5" fill="#2b2440"/>`,`<path d="M194 132 L206 132 L200 140 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 140 L200 148" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Whiskers & tail`, instruction:`Add whiskers and a curvy tail.`, lines:[`<path d="M168 140 l-26 -4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M232 140 l26 -4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M256 232 q30 0 26 -34" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Spots`, instruction:`Add leopard spots.`, lines:[`<circle cx="190" cy="200" r="5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="212" cy="206" r="5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="200" cy="226" r="5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="180" cy="222" r="4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="220" cy="224" r="4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const LEOPARD_OUTLINE_SVG = LEOPARD_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const LEOPARD_DETAIL_SVG = LEOPARD_OUTLINE_SVG + "\n" + LEOPARD_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const LEOPARD_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"leopard", subjectName:`Leopard`, subjectEmoji:`🐆`, emoji:`🐆`, order:25, sublevel:251, slug:"leopard-outline", title:`Leopard \u00b7 Outline`, subject:`leopard outline`, intro:`Let\u2019s draw Leopard\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Spotty Leopard`, phase:"outline", steps:LEOPARD_OUTLINE_STEPS };
|
||||
export const LEOPARD_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"leopard", subjectName:`Leopard`, subjectEmoji:`🐆`, emoji:`🐆`, order:25, sublevel:252, slug:"leopard-detail", title:`Leopard \u00b7 Details`, subject:`leopard details`, intro:`Now add the details that bring your leopard to life.`, badgeKey:"early-beginner-25-leopard", badgeName:`Spotty Leopard`, phase:"detail", baseSvg:LEOPARD_OUTLINE_SVG, steps:LEOPARD_DETAIL_STEPS };
|
||||
export const LEOPARD_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"leopard", subjectName:`Leopard`, subjectEmoji:`🐆`, emoji:`🐆`, order:25, sublevel:253, slug:"leopard-extra", title:`Leopard \u00b7 Color it!`, subject:`color the leopard`, intro:`Bring your leopard to life! Color it in.`, badgeKey:"", badgeName:`Spotty Leopard`, phase:"extra", baseSvg:LEOPARD_DETAIL_SVG, steps:[] };
|
||||
|
||||
const TIGER_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The head`, instruction:`Draw a round head.`, lines:[`<circle cx="200" cy="140" r="58" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add two ears.`, lines:[`<circle cx="160" cy="98" r="18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="240" cy="98" r="18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The body`, instruction:`Add a sitting body.`, lines:[`<ellipse cx="200" cy="228" rx="52" ry="40" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TIGER_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add eyes, nose and mouth.`, lines:[`<circle cx="182" cy="138" r="6" fill="#2b2440"/>`,`<circle cx="218" cy="138" r="6" fill="#2b2440"/>`,`<path d="M192 154 L208 154 L200 162 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 162 L200 170" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 170 q-10 8 -18 2" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 170 q10 8 18 2" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Cheeks`, instruction:`Add fluffy cheeks.`, lines:[`<path d="M150 150 q-22 6 -28 18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 150 q22 6 28 18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Stripes`, instruction:`Add tiger stripes.`, lines:[`<path d="M200 84 L200 100" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M168 96 l-8 -12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M232 96 l8 -12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M150 140 l-16 -6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 140 l16 -6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TIGER_OUTLINE_SVG = TIGER_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const TIGER_DETAIL_SVG = TIGER_OUTLINE_SVG + "\n" + TIGER_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const TIGER_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"tiger", subjectName:`Tiger`, subjectEmoji:`🐯`, emoji:`🐯`, order:26, sublevel:261, slug:"tiger-outline", title:`Tiger \u00b7 Outline`, subject:`tiger outline`, intro:`Let\u2019s draw Tiger\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Tiger Power`, phase:"outline", steps:TIGER_OUTLINE_STEPS };
|
||||
export const TIGER_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"tiger", subjectName:`Tiger`, subjectEmoji:`🐯`, emoji:`🐯`, order:26, sublevel:262, slug:"tiger-detail", title:`Tiger \u00b7 Details`, subject:`tiger details`, intro:`Now add the details that bring your tiger to life.`, badgeKey:"early-beginner-26-tiger", badgeName:`Tiger Power`, phase:"detail", baseSvg:TIGER_OUTLINE_SVG, steps:TIGER_DETAIL_STEPS };
|
||||
export const TIGER_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"tiger", subjectName:`Tiger`, subjectEmoji:`🐯`, emoji:`🐯`, order:26, sublevel:263, slug:"tiger-extra", title:`Tiger \u00b7 Color it!`, subject:`color the tiger`, intro:`Bring your tiger to life! Color it in.`, badgeKey:"", badgeName:`Tiger Power`, phase:"extra", baseSvg:TIGER_DETAIL_SVG, steps:[] };
|
||||
|
||||
const MONKEY_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The head`, instruction:`Draw a round head.`, lines:[`<circle cx="200" cy="140" r="56" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add two big round ears.`, lines:[`<circle cx="146" cy="140" r="22" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="254" cy="140" r="22" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The face`, instruction:`Add a face patch.`, lines:[`<ellipse cx="200" cy="158" rx="40" ry="42" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const MONKEY_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The eyes`, instruction:`Add two eyes.`, lines:[`<circle cx="184" cy="140" r="9" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="184" cy="140" r="4" fill="#2b2440"/>`,`<circle cx="216" cy="140" r="9" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="216" cy="140" r="4" fill="#2b2440"/>`] },
|
||||
{ n:2, title:`Nose & mouth`, instruction:`Add a nose and a smile.`, lines:[`<circle cx="192" cy="166" r="3" fill="#2b2440"/>`,`<circle cx="208" cy="166" r="3" fill="#2b2440"/>`,`<path d="M182 180 q18 12 36 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Ear insides`, instruction:`Add the inside of the ears.`, lines:[`<circle cx="146" cy="140" r="11" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="254" cy="140" r="11" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const MONKEY_OUTLINE_SVG = MONKEY_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const MONKEY_DETAIL_SVG = MONKEY_OUTLINE_SVG + "\n" + MONKEY_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const MONKEY_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"monkey", subjectName:`Monkey`, subjectEmoji:`🐵`, emoji:`🐵`, order:27, sublevel:271, slug:"monkey-outline", title:`Monkey \u00b7 Outline`, subject:`monkey outline`, intro:`Let\u2019s draw Monkey\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Cheeky Monkey`, phase:"outline", steps:MONKEY_OUTLINE_STEPS };
|
||||
export const MONKEY_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"monkey", subjectName:`Monkey`, subjectEmoji:`🐵`, emoji:`🐵`, order:27, sublevel:272, slug:"monkey-detail", title:`Monkey \u00b7 Details`, subject:`monkey details`, intro:`Now add the details that bring your monkey to life.`, badgeKey:"early-beginner-27-monkey", badgeName:`Cheeky Monkey`, phase:"detail", baseSvg:MONKEY_OUTLINE_SVG, steps:MONKEY_DETAIL_STEPS };
|
||||
export const MONKEY_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"monkey", subjectName:`Monkey`, subjectEmoji:`🐵`, emoji:`🐵`, order:27, sublevel:273, slug:"monkey-extra", title:`Monkey \u00b7 Color it!`, subject:`color the monkey`, intro:`Bring your monkey to life! Color it in.`, badgeKey:"", badgeName:`Cheeky Monkey`, phase:"extra", baseSvg:MONKEY_DETAIL_SVG, steps:[] };
|
||||
|
||||
const CROCODILE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw a long body and snout.`, lines:[`<path d="M70 196 C 130 176 250 178 330 190 L368 188 L356 204 L368 214 C 250 230 120 230 80 214 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The legs`, instruction:`Add short legs.`, lines:[`<path d="M150 222 l-8 18 l16 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 224 l-8 18 l16 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The tail tip`, instruction:`Point the tail.`, lines:[`<path d="M80 200 L44 186 L52 208 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CROCODILE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The eye`, instruction:`Add a bumpy eye.`, lines:[`<circle cx="120" cy="176" r="7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="120" cy="176" r="4" fill="#2b2440"/>`] },
|
||||
{ n:2, title:`Teeth`, instruction:`Add a toothy grin.`, lines:[`<path d="M110 214 l6 8" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M130 216 l6 8" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M150 216 l6 8" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M170 216 l6 8" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Back ridges`, instruction:`Add spikes on the back.`, lines:[`<path d="M180 178 l8 -10 l8 10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M210 178 l8 -10 l8 10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M240 180 l8 -10 l8 10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CROCODILE_OUTLINE_SVG = CROCODILE_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const CROCODILE_DETAIL_SVG = CROCODILE_OUTLINE_SVG + "\n" + CROCODILE_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const CROCODILE_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"crocodile", subjectName:`Crocodile`, subjectEmoji:`🐊`, emoji:`🐊`, order:28, sublevel:281, slug:"crocodile-outline", title:`Crocodile \u00b7 Outline`, subject:`crocodile outline`, intro:`Let\u2019s draw Crocodile\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Snappy Croc`, phase:"outline", steps:CROCODILE_OUTLINE_STEPS };
|
||||
export const CROCODILE_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"crocodile", subjectName:`Crocodile`, subjectEmoji:`🐊`, emoji:`🐊`, order:28, sublevel:282, slug:"crocodile-detail", title:`Crocodile \u00b7 Details`, subject:`crocodile details`, intro:`Now add the details that bring your crocodile to life.`, badgeKey:"early-beginner-28-crocodile", badgeName:`Snappy Croc`, phase:"detail", baseSvg:CROCODILE_OUTLINE_SVG, steps:CROCODILE_DETAIL_STEPS };
|
||||
export const CROCODILE_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"crocodile", subjectName:`Crocodile`, subjectEmoji:`🐊`, emoji:`🐊`, order:28, sublevel:283, slug:"crocodile-extra", title:`Crocodile \u00b7 Color it!`, subject:`color the crocodile`, intro:`Bring your crocodile to life! Color it in.`, badgeKey:"", badgeName:`Snappy Croc`, phase:"extra", baseSvg:CROCODILE_DETAIL_SVG, steps:[] };
|
||||
|
||||
const FOX_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Draw a pointy fox face.`, lines:[`<path d="M160 138 Q200 92 240 138 Q220 176 200 176 Q180 176 160 138 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add two pointy ears.`, lines:[`<path d="M166 120 L150 80 L190 110 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M234 120 L250 80 L210 110 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The body`, instruction:`Add a sitting body.`, lines:[`<ellipse cx="200" cy="222" rx="46" ry="42" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The tail`, instruction:`Add a bushy tail.`, lines:[`<path d="M242 232 C 290 222 296 182 270 167 C 286 197 262 222 236 224 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FOX_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add eyes and a nose.`, lines:[`<circle cx="184" cy="135" r="5" fill="#2b2440"/>`,`<circle cx="216" cy="135" r="5" fill="#2b2440"/>`,`<path d="M194 150 L206 150 L200 158 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Cheeks`, instruction:`Add fluffy cheeks.`, lines:[`<path d="M176 150 q-14 6 -16 18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M224 150 q14 6 16 18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Ear insides`, instruction:`Color the ears.`, lines:[`<path d="M172 110 L162 88 L182 104 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M228 110 L238 88 L218 104 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FOX_OUTLINE_SVG = FOX_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const FOX_DETAIL_SVG = FOX_OUTLINE_SVG + "\n" + FOX_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const FOX_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"fox", subjectName:`Fox`, subjectEmoji:`🦊`, emoji:`🦊`, order:29, sublevel:291, slug:"fox-outline", title:`Fox \u00b7 Outline`, subject:`fox outline`, intro:`Let\u2019s draw Fox\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Foxy Friend`, phase:"outline", steps:FOX_OUTLINE_STEPS };
|
||||
export const FOX_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"fox", subjectName:`Fox`, subjectEmoji:`🦊`, emoji:`🦊`, order:29, sublevel:292, slug:"fox-detail", title:`Fox \u00b7 Details`, subject:`fox details`, intro:`Now add the details that bring your fox to life.`, badgeKey:"early-beginner-29-fox", badgeName:`Foxy Friend`, phase:"detail", baseSvg:FOX_OUTLINE_SVG, steps:FOX_DETAIL_STEPS };
|
||||
export const FOX_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"fox", subjectName:`Fox`, subjectEmoji:`🦊`, emoji:`🦊`, order:29, sublevel:293, slug:"fox-extra", title:`Fox \u00b7 Color it!`, subject:`color the fox`, intro:`Bring your fox to life! Color it in.`, badgeKey:"", badgeName:`Foxy Friend`, phase:"extra", baseSvg:FOX_DETAIL_SVG, steps:[] };
|
||||
|
||||
const BEAR_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The head`, instruction:`Draw a round head.`, lines:[`<circle cx="200" cy="130" r="52" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add two round ears.`, lines:[`<circle cx="162" cy="96" r="18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="238" cy="96" r="18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The body`, instruction:`Add a cuddly body.`, lines:[`<ellipse cx="200" cy="216" rx="54" ry="46" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BEAR_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The snout`, instruction:`Add a snout and nose.`, lines:[`<ellipse cx="200" cy="150" rx="22" ry="16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="200" cy="142" r="5" fill="#2b2440"/>`] },
|
||||
{ n:2, title:`The eyes`, instruction:`Add two eyes.`, lines:[`<circle cx="182" cy="124" r="5" fill="#2b2440"/>`,`<circle cx="218" cy="124" r="5" fill="#2b2440"/>`] },
|
||||
{ n:3, title:`Ear insides & tummy`, instruction:`Add details.`, lines:[`<circle cx="162" cy="96" r="9" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="238" cy="96" r="9" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 186 q0 22 0 26" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BEAR_OUTLINE_SVG = BEAR_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const BEAR_DETAIL_SVG = BEAR_OUTLINE_SVG + "\n" + BEAR_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const BEAR_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"bear", subjectName:`Bear`, subjectEmoji:`🐻`, emoji:`🐻`, order:30, sublevel:301, slug:"bear-outline", title:`Bear \u00b7 Outline`, subject:`bear outline`, intro:`Let\u2019s draw Bear\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Bear Hug`, phase:"outline", steps:BEAR_OUTLINE_STEPS };
|
||||
export const BEAR_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"bear", subjectName:`Bear`, subjectEmoji:`🐻`, emoji:`🐻`, order:30, sublevel:302, slug:"bear-detail", title:`Bear \u00b7 Details`, subject:`bear details`, intro:`Now add the details that bring your bear to life.`, badgeKey:"early-beginner-30-bear", badgeName:`Bear Hug`, phase:"detail", baseSvg:BEAR_OUTLINE_SVG, steps:BEAR_DETAIL_STEPS };
|
||||
export const BEAR_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"bear", subjectName:`Bear`, subjectEmoji:`🐻`, emoji:`🐻`, order:30, sublevel:303, slug:"bear-extra", title:`Bear \u00b7 Color it!`, subject:`color the bear`, intro:`Bring your bear to life! Color it in.`, badgeKey:"", badgeName:`Bear Hug`, phase:"extra", baseSvg:BEAR_DETAIL_SVG, steps:[] };
|
||||
|
||||
const DEER_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The head`, instruction:`Draw an oval head.`, lines:[`<ellipse cx="200" cy="150" rx="40" ry="46" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add two ears.`, lines:[`<ellipse cx="160" cy="120" rx="15" ry="26" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<ellipse cx="240" cy="120" rx="15" ry="26" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The antlers`, instruction:`Add branchy antlers.`, lines:[`<path d="M178 110 L168 70 L156 84" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M178 110 L172 86" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M222 110 L232 70 L244 84" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M222 110 L228 86" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The body`, instruction:`Add a small body.`, lines:[`<ellipse cx="200" cy="226" rx="40" ry="34" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const DEER_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add eyes and a nose.`, lines:[`<circle cx="186" cy="148" r="5" fill="#2b2440"/>`,`<circle cx="214" cy="148" r="5" fill="#2b2440"/>`,`<path d="M192 170 q8 8 16 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The snout`, instruction:`Add the snout line.`, lines:[`<path d="M200 162 L200 170" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const DEER_OUTLINE_SVG = DEER_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const DEER_DETAIL_SVG = DEER_OUTLINE_SVG + "\n" + DEER_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const DEER_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"deer", subjectName:`Deer`, subjectEmoji:`🦌`, emoji:`🦌`, order:31, sublevel:311, slug:"deer-outline", title:`Deer \u00b7 Outline`, subject:`deer outline`, intro:`Let\u2019s draw Deer\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Deer Dear`, phase:"outline", steps:DEER_OUTLINE_STEPS };
|
||||
export const DEER_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"deer", subjectName:`Deer`, subjectEmoji:`🦌`, emoji:`🦌`, order:31, sublevel:312, slug:"deer-detail", title:`Deer \u00b7 Details`, subject:`deer details`, intro:`Now add the details that bring your deer to life.`, badgeKey:"early-beginner-31-deer", badgeName:`Deer Dear`, phase:"detail", baseSvg:DEER_OUTLINE_SVG, steps:DEER_DETAIL_STEPS };
|
||||
export const DEER_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"deer", subjectName:`Deer`, subjectEmoji:`🦌`, emoji:`🦌`, order:31, sublevel:313, slug:"deer-extra", title:`Deer \u00b7 Color it!`, subject:`color the deer`, intro:`Bring your deer to life! Color it in.`, badgeKey:"", badgeName:`Deer Dear`, phase:"extra", baseSvg:DEER_DETAIL_SVG, steps:[] };
|
||||
|
||||
const RABBIT_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The head`, instruction:`Draw a round head.`, lines:[`<circle cx="200" cy="162" r="44" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add two tall ears.`, lines:[`<ellipse cx="180" cy="90" rx="15" ry="46" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<ellipse cx="220" cy="90" rx="15" ry="46" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The body`, instruction:`Add a round body.`, lines:[`<ellipse cx="200" cy="236" rx="40" ry="28" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const RABBIT_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add eyes, nose and mouth.`, lines:[`<circle cx="186" cy="158" r="5" fill="#2b2440"/>`,`<circle cx="214" cy="158" r="5" fill="#2b2440"/>`,`<path d="M194 172 L206 172 L200 178 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 178 L200 184" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Ears & whiskers`, instruction:`Add ear insides and whiskers.`, lines:[`<ellipse cx="180" cy="92" rx="7" ry="30" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<ellipse cx="220" cy="92" rx="7" ry="30" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M176 174 l-24 -4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M224 174 l24 -4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const RABBIT_OUTLINE_SVG = RABBIT_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const RABBIT_DETAIL_SVG = RABBIT_OUTLINE_SVG + "\n" + RABBIT_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const RABBIT_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"rabbit", subjectName:`Rabbit`, subjectEmoji:`🐰`, emoji:`🐰`, order:32, sublevel:321, slug:"rabbit-outline", title:`Rabbit \u00b7 Outline`, subject:`rabbit outline`, intro:`Let\u2019s draw Rabbit\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Hoppy Rabbit`, phase:"outline", steps:RABBIT_OUTLINE_STEPS };
|
||||
export const RABBIT_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"rabbit", subjectName:`Rabbit`, subjectEmoji:`🐰`, emoji:`🐰`, order:32, sublevel:322, slug:"rabbit-detail", title:`Rabbit \u00b7 Details`, subject:`rabbit details`, intro:`Now add the details that bring your rabbit to life.`, badgeKey:"early-beginner-32-rabbit", badgeName:`Hoppy Rabbit`, phase:"detail", baseSvg:RABBIT_OUTLINE_SVG, steps:RABBIT_DETAIL_STEPS };
|
||||
export const RABBIT_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"rabbit", subjectName:`Rabbit`, subjectEmoji:`🐰`, emoji:`🐰`, order:32, sublevel:323, slug:"rabbit-extra", title:`Rabbit \u00b7 Color it!`, subject:`color the rabbit`, intro:`Bring your rabbit to life! Color it in.`, badgeKey:"", badgeName:`Hoppy Rabbit`, phase:"extra", baseSvg:RABBIT_DETAIL_SVG, steps:[] };
|
||||
|
||||
const HEDGEHOG_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw a round spiky body.`, lines:[`<path d="M96 212 C 92 156 150 126 206 130 C 262 134 292 168 292 212 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The snout`, instruction:`Add a pointy snout.`, lines:[`<path d="M292 200 C 320 196 332 206 326 216 C 320 224 300 222 292 214" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const HEDGEHOG_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The spikes`, instruction:`Add zig-zag spikes.`, lines:[`<path d="M130 168 l10 -16 l9 16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M168 146 l10 -16 l9 16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M206 140 l10 -16 l9 16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M244 150 l10 -16 l9 16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The face`, instruction:`Add an eye and a nose.`, lines:[`<circle cx="300" cy="202" r="4" fill="#2b2440"/>`,`<circle cx="326" cy="212" r="4" fill="#2b2440"/>`] },
|
||||
{ n:3, title:`The feet`, instruction:`Add little feet.`, lines:[`<path d="M150 212 l0 12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 212 l0 12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 212 l0 12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const HEDGEHOG_OUTLINE_SVG = HEDGEHOG_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const HEDGEHOG_DETAIL_SVG = HEDGEHOG_OUTLINE_SVG + "\n" + HEDGEHOG_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const HEDGEHOG_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"hedgehog", subjectName:`Hedgehog`, subjectEmoji:`🦔`, emoji:`🦔`, order:33, sublevel:331, slug:"hedgehog-outline", title:`Hedgehog \u00b7 Outline`, subject:`hedgehog outline`, intro:`Let\u2019s draw Hedgehog\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Prickly Pal`, phase:"outline", steps:HEDGEHOG_OUTLINE_STEPS };
|
||||
export const HEDGEHOG_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"hedgehog", subjectName:`Hedgehog`, subjectEmoji:`🦔`, emoji:`🦔`, order:33, sublevel:332, slug:"hedgehog-detail", title:`Hedgehog \u00b7 Details`, subject:`hedgehog details`, intro:`Now add the details that bring your hedgehog to life.`, badgeKey:"early-beginner-33-hedgehog", badgeName:`Prickly Pal`, phase:"detail", baseSvg:HEDGEHOG_OUTLINE_SVG, steps:HEDGEHOG_DETAIL_STEPS };
|
||||
export const HEDGEHOG_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"hedgehog", subjectName:`Hedgehog`, subjectEmoji:`🦔`, emoji:`🦔`, order:33, sublevel:333, slug:"hedgehog-extra", title:`Hedgehog \u00b7 Color it!`, subject:`color the hedgehog`, intro:`Bring your hedgehog to life! Color it in.`, badgeKey:"", badgeName:`Prickly Pal`, phase:"extra", baseSvg:HEDGEHOG_DETAIL_SVG, steps:[] };
|
||||
|
||||
const OWL_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw a rounded owl body.`, lines:[`<path d="M150 110 C 150 80 250 80 250 110 L250 210 C 250 240 150 240 150 210 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Ear tufts`, instruction:`Add two ear tufts.`, lines:[`<path d="M158 96 L150 72 L176 90 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M242 96 L250 72 L224 90 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The wings`, instruction:`Add two wings.`, lines:[`<path d="M152 140 q-12 40 6 70" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M248 140 q12 40 -6 70" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const OWL_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Big eyes`, instruction:`Add two big eyes.`, lines:[`<circle cx="180" cy="130" r="18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="180" cy="130" r="7" fill="#2b2440"/>`,`<circle cx="220" cy="130" r="18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="220" cy="130" r="7" fill="#2b2440"/>`] },
|
||||
{ n:2, title:`Beak & feet`, instruction:`Add a beak and feet.`, lines:[`<path d="M200 142 L192 154 L208 154 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M186 236 l-6 10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M186 236 l6 8" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M214 236 l6 10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M214 236 l-6 8" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The tummy`, instruction:`Add a tummy curve.`, lines:[`<path d="M180 172 q20 20 40 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const OWL_OUTLINE_SVG = OWL_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const OWL_DETAIL_SVG = OWL_OUTLINE_SVG + "\n" + OWL_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const OWL_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"owl", subjectName:`Owl`, subjectEmoji:`🦉`, emoji:`🦉`, order:34, sublevel:341, slug:"owl-outline", title:`Owl \u00b7 Outline`, subject:`owl outline`, intro:`Let\u2019s draw Owl\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Wise Owl`, phase:"outline", steps:OWL_OUTLINE_STEPS };
|
||||
export const OWL_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"owl", subjectName:`Owl`, subjectEmoji:`🦉`, emoji:`🦉`, order:34, sublevel:342, slug:"owl-detail", title:`Owl \u00b7 Details`, subject:`owl details`, intro:`Now add the details that bring your owl to life.`, badgeKey:"early-beginner-34-owl", badgeName:`Wise Owl`, phase:"detail", baseSvg:OWL_OUTLINE_SVG, steps:OWL_DETAIL_STEPS };
|
||||
export const OWL_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"owl", subjectName:`Owl`, subjectEmoji:`🦉`, emoji:`🦉`, order:34, sublevel:343, slug:"owl-extra", title:`Owl \u00b7 Color it!`, subject:`color the owl`, intro:`Bring your owl to life! Color it in.`, badgeKey:"", badgeName:`Wise Owl`, phase:"extra", baseSvg:OWL_DETAIL_SVG, steps:[] };
|
||||
|
||||
const CAT_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The head`, instruction:`Draw a round head.`, lines:[`<circle cx="200" cy="140" r="50" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add two pointy ears.`, lines:[`<path d="M165 108 L150 72 L192 98 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M235 108 L250 72 L208 98 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The body`, instruction:`Add a sitting body.`, lines:[`<ellipse cx="200" cy="222" rx="46" ry="42" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The tail`, instruction:`Add a curvy tail.`, lines:[`<path d="M244 236 C 288 230 292 188 268 178" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CAT_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add eyes, nose and mouth.`, lines:[`<circle cx="184" cy="138" r="5" fill="#2b2440"/>`,`<circle cx="216" cy="138" r="5" fill="#2b2440"/>`,`<path d="M194 152 L206 152 L200 158 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 158 L200 164" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Whiskers`, instruction:`Add whiskers.`, lines:[`<path d="M170 150 l-28 -6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M170 158 l-28 6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M230 150 l28 -6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M230 158 l28 6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CAT_OUTLINE_SVG = CAT_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const CAT_DETAIL_SVG = CAT_OUTLINE_SVG + "\n" + CAT_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const CAT_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"cat", subjectName:`Cat`, subjectEmoji:`🐱`, emoji:`🐱`, order:35, sublevel:351, slug:"cat-outline", title:`Cat \u00b7 Outline`, subject:`cat outline`, intro:`Let\u2019s draw Cat\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Cool Cat`, phase:"outline", steps:CAT_OUTLINE_STEPS };
|
||||
export const CAT_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"cat", subjectName:`Cat`, subjectEmoji:`🐱`, emoji:`🐱`, order:35, sublevel:352, slug:"cat-detail", title:`Cat \u00b7 Details`, subject:`cat details`, intro:`Now add the details that bring your cat to life.`, badgeKey:"early-beginner-35-cat", badgeName:`Cool Cat`, phase:"detail", baseSvg:CAT_OUTLINE_SVG, steps:CAT_DETAIL_STEPS };
|
||||
export const CAT_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"cat", subjectName:`Cat`, subjectEmoji:`🐱`, emoji:`🐱`, order:35, sublevel:353, slug:"cat-extra", title:`Cat \u00b7 Color it!`, subject:`color the cat`, intro:`Bring your cat to life! Color it in.`, badgeKey:"", badgeName:`Cool Cat`, phase:"extra", baseSvg:CAT_DETAIL_SVG, steps:[] };
|
||||
|
||||
const TURTLE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The shell`, instruction:`Draw a dome shell.`, lines:[`<path d="M118 200 C 118 152 282 152 282 200 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Head & legs`, instruction:`Add a head, legs and tail.`, lines:[`<path d="M282 188 C 312 184 318 200 308 210 C 300 216 286 212 282 204" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M150 200 l-6 18 l14 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 200 l-6 18 l14 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M118 196 q-14 4 -10 16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TURTLE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Shell pattern`, instruction:`Add a shell pattern.`, lines:[`<path d="M200 156 L160 176 L160 198" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 156 L240 176 L240 198" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M160 176 L240 176" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The eye`, instruction:`Add a friendly eye.`, lines:[`<circle cx="300" cy="194" r="4" fill="#2b2440"/>`] },
|
||||
];
|
||||
|
||||
const TURTLE_OUTLINE_SVG = TURTLE_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const TURTLE_DETAIL_SVG = TURTLE_OUTLINE_SVG + "\n" + TURTLE_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const TURTLE_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"turtle", subjectName:`Turtle`, subjectEmoji:`🐢`, emoji:`🐢`, order:36, sublevel:361, slug:"turtle-outline", title:`Turtle \u00b7 Outline`, subject:`turtle outline`, intro:`Let\u2019s draw Turtle\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Turtle Buddy`, phase:"outline", steps:TURTLE_OUTLINE_STEPS };
|
||||
export const TURTLE_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"turtle", subjectName:`Turtle`, subjectEmoji:`🐢`, emoji:`🐢`, order:36, sublevel:362, slug:"turtle-detail", title:`Turtle \u00b7 Details`, subject:`turtle details`, intro:`Now add the details that bring your turtle to life.`, badgeKey:"early-beginner-36-turtle", badgeName:`Turtle Buddy`, phase:"detail", baseSvg:TURTLE_OUTLINE_SVG, steps:TURTLE_DETAIL_STEPS };
|
||||
export const TURTLE_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"turtle", subjectName:`Turtle`, subjectEmoji:`🐢`, emoji:`🐢`, order:36, sublevel:363, slug:"turtle-extra", title:`Turtle \u00b7 Color it!`, subject:`color the turtle`, intro:`Bring your turtle to life! Color it in.`, badgeKey:"", badgeName:`Turtle Buddy`, phase:"extra", baseSvg:TURTLE_DETAIL_SVG, steps:[] };
|
||||
|
||||
const BIRD_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw an egg-shaped body.`, lines:[`<ellipse cx="190" cy="162" rx="52" ry="40" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The head`, instruction:`Add a round head.`, lines:[`<circle cx="245" cy="136" r="26" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`The tail`, instruction:`Add a pointy tail.`, lines:[`<path d="M138 162 L100 146 L110 164 L100 180 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:4, title:`The wing`, instruction:`Add a wing.`, lines:[`<path d="M180 152 q30 10 44 34" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BIRD_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Beak & eye`, instruction:`Add a beak and an eye.`, lines:[`<path d="M271 133 L294 128 L272 144 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="250" cy="131" r="4" fill="#2b2440"/>`] },
|
||||
{ n:2, title:`The legs`, instruction:`Add two legs.`, lines:[`<path d="M190 200 l-6 18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M205 200 l4 18" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BIRD_OUTLINE_SVG = BIRD_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const BIRD_DETAIL_SVG = BIRD_OUTLINE_SVG + "\n" + BIRD_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const BIRD_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"bird", subjectName:`Bird`, subjectEmoji:`🐦`, emoji:`🐦`, order:37, sublevel:371, slug:"bird-outline", title:`Bird \u00b7 Outline`, subject:`bird outline`, intro:`Let\u2019s draw Bird\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Little Birdie`, phase:"outline", steps:BIRD_OUTLINE_STEPS };
|
||||
export const BIRD_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"bird", subjectName:`Bird`, subjectEmoji:`🐦`, emoji:`🐦`, order:37, sublevel:372, slug:"bird-detail", title:`Bird \u00b7 Details`, subject:`bird details`, intro:`Now add the details that bring your bird to life.`, badgeKey:"early-beginner-37-bird", badgeName:`Little Birdie`, phase:"detail", baseSvg:BIRD_OUTLINE_SVG, steps:BIRD_DETAIL_STEPS };
|
||||
export const BIRD_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"bird", subjectName:`Bird`, subjectEmoji:`🐦`, emoji:`🐦`, order:37, sublevel:373, slug:"bird-extra", title:`Bird \u00b7 Color it!`, subject:`color the bird`, intro:`Bring your bird to life! Color it in.`, badgeKey:"", badgeName:`Little Birdie`, phase:"extra", baseSvg:BIRD_DETAIL_SVG, steps:[] };
|
||||
|
||||
const BUTTERFLY_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw a thin body.`, lines:[`<ellipse cx="200" cy="162" rx="8" ry="44" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Top wings`, instruction:`Add two big top wings.`, lines:[`<path d="M196 140 C 150 100 120 120 130 150 C 138 174 182 168 196 154 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M204 140 C 250 100 280 120 270 150 C 262 174 218 168 204 154 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Bottom wings`, instruction:`Add two bottom wings.`, lines:[`<path d="M196 168 C 160 182 150 216 180 222 C 198 226 200 196 198 180 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M204 168 C 240 182 250 216 220 222 C 202 226 200 196 202 180 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BUTTERFLY_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Antennae`, instruction:`Add two antennae.`, lines:[`<path d="M196 122 q-10 -16 -22 -16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M204 122 q10 -16 22 -16" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Wing dots`, instruction:`Add pretty dots.`, lines:[`<circle cx="160" cy="138" r="7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="240" cy="138" r="7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="176" cy="200" r="6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="224" cy="200" r="6" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BUTTERFLY_OUTLINE_SVG = BUTTERFLY_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const BUTTERFLY_DETAIL_SVG = BUTTERFLY_OUTLINE_SVG + "\n" + BUTTERFLY_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const BUTTERFLY_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"butterfly", subjectName:`Butterfly`, subjectEmoji:`🦋`, emoji:`🦋`, order:38, sublevel:381, slug:"butterfly-outline", title:`Butterfly \u00b7 Outline`, subject:`butterfly outline`, intro:`Let\u2019s draw Butterfly\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Flutter By`, phase:"outline", steps:BUTTERFLY_OUTLINE_STEPS };
|
||||
export const BUTTERFLY_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"butterfly", subjectName:`Butterfly`, subjectEmoji:`🦋`, emoji:`🦋`, order:38, sublevel:382, slug:"butterfly-detail", title:`Butterfly \u00b7 Details`, subject:`butterfly details`, intro:`Now add the details that bring your butterfly to life.`, badgeKey:"early-beginner-38-butterfly", badgeName:`Flutter By`, phase:"detail", baseSvg:BUTTERFLY_OUTLINE_SVG, steps:BUTTERFLY_DETAIL_STEPS };
|
||||
export const BUTTERFLY_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"butterfly", subjectName:`Butterfly`, subjectEmoji:`🦋`, emoji:`🦋`, order:38, sublevel:383, slug:"butterfly-extra", title:`Butterfly \u00b7 Color it!`, subject:`color the butterfly`, intro:`Bring your butterfly to life! Color it in.`, badgeKey:"", badgeName:`Flutter By`, phase:"extra", baseSvg:BUTTERFLY_DETAIL_SVG, steps:[] };
|
||||
|
||||
export const ANIMAL_LESSONS: Lesson[] = [
|
||||
LION_OUTLINE_LESSON,
|
||||
LION_DETAIL_LESSON,
|
||||
LION_EXTRA_LESSON,
|
||||
ELEPHANT_OUTLINE_LESSON,
|
||||
ELEPHANT_DETAIL_LESSON,
|
||||
ELEPHANT_EXTRA_LESSON,
|
||||
GIRAFFE_OUTLINE_LESSON,
|
||||
GIRAFFE_DETAIL_LESSON,
|
||||
GIRAFFE_EXTRA_LESSON,
|
||||
ZEBRA_OUTLINE_LESSON,
|
||||
ZEBRA_DETAIL_LESSON,
|
||||
ZEBRA_EXTRA_LESSON,
|
||||
RHINO_OUTLINE_LESSON,
|
||||
RHINO_DETAIL_LESSON,
|
||||
RHINO_EXTRA_LESSON,
|
||||
LEOPARD_OUTLINE_LESSON,
|
||||
LEOPARD_DETAIL_LESSON,
|
||||
LEOPARD_EXTRA_LESSON,
|
||||
TIGER_OUTLINE_LESSON,
|
||||
TIGER_DETAIL_LESSON,
|
||||
TIGER_EXTRA_LESSON,
|
||||
MONKEY_OUTLINE_LESSON,
|
||||
MONKEY_DETAIL_LESSON,
|
||||
MONKEY_EXTRA_LESSON,
|
||||
CROCODILE_OUTLINE_LESSON,
|
||||
CROCODILE_DETAIL_LESSON,
|
||||
CROCODILE_EXTRA_LESSON,
|
||||
FOX_OUTLINE_LESSON,
|
||||
FOX_DETAIL_LESSON,
|
||||
FOX_EXTRA_LESSON,
|
||||
BEAR_OUTLINE_LESSON,
|
||||
BEAR_DETAIL_LESSON,
|
||||
BEAR_EXTRA_LESSON,
|
||||
DEER_OUTLINE_LESSON,
|
||||
DEER_DETAIL_LESSON,
|
||||
DEER_EXTRA_LESSON,
|
||||
RABBIT_OUTLINE_LESSON,
|
||||
RABBIT_DETAIL_LESSON,
|
||||
RABBIT_EXTRA_LESSON,
|
||||
HEDGEHOG_OUTLINE_LESSON,
|
||||
HEDGEHOG_DETAIL_LESSON,
|
||||
HEDGEHOG_EXTRA_LESSON,
|
||||
OWL_OUTLINE_LESSON,
|
||||
OWL_DETAIL_LESSON,
|
||||
OWL_EXTRA_LESSON,
|
||||
CAT_OUTLINE_LESSON,
|
||||
CAT_DETAIL_LESSON,
|
||||
CAT_EXTRA_LESSON,
|
||||
TURTLE_OUTLINE_LESSON,
|
||||
TURTLE_DETAIL_LESSON,
|
||||
TURTLE_EXTRA_LESSON,
|
||||
BIRD_OUTLINE_LESSON,
|
||||
BIRD_DETAIL_LESSON,
|
||||
BIRD_EXTRA_LESSON,
|
||||
BUTTERFLY_OUTLINE_LESSON,
|
||||
BUTTERFLY_DETAIL_LESSON,
|
||||
BUTTERFLY_EXTRA_LESSON
|
||||
];
|
||||
@@ -0,0 +1,268 @@
|
||||
/* AUTO-GENERATED by gen_beginner.py — DrawIt Beginner level (3 packs x 4 subjects, 4 phases each). */
|
||||
import type { Lesson, LessonStep } from "./curriculum";
|
||||
|
||||
// Faint sun hint shown over the Light & shadow phase (sun up-left → shade the lower-right side).
|
||||
export const LIGHT_HINT_SVG = `<g><circle cx="54" cy="48" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M54 16v-8M54 88v8M22 48h-8M86 48h8M31 25l-6-6M77 25l6-6M31 71l-6 6M77 71l6 6"/></g></g>`;
|
||||
|
||||
const BEACH_BALL_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The ball`, instruction:`Draw one big round circle.`, tip:`Slow and round, like a bubble.`, lines:[`<circle cx="200" cy="152" r="86" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BEACH_BALL_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Center line`, instruction:`Draw a line curving down the middle.`, lines:[`<path d="M200 66 Q210 152 200 238" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Side panels`, instruction:`Add a curve on each side to make panels.`, tip:`Now it looks like a real ball!`, lines:[`<path d="M200 66 C 150 100 150 204 200 238" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 66 C 250 100 250 204 200 238" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BEACH_BALL_CONSTRUCT_SVG = BEACH_BALL_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const BEACH_BALL_FULL_SVG = BEACH_BALL_CONSTRUCT_SVG + "\n" + BEACH_BALL_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const BEACH_BALL_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"beach-ball", subjectName:`Beach Ball`, subjectEmoji:`🏐`, order:1, packKey:"pack-3d", emoji:`🏐`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:111, slug:"beach-ball-shapes", title:`Beach Ball \u00b7 Shapes`, subject:`Beach Ball shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:BEACH_BALL_CONSTRUCT_STEPS };
|
||||
export const BEACH_BALL_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"beach-ball", subjectName:`Beach Ball`, subjectEmoji:`🏐`, order:1, packKey:"pack-3d", emoji:`🏐`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:112, slug:"beach-ball-outline", title:`Beach Ball \u00b7 Outline`, subject:`Beach Ball outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:BEACH_BALL_CONSTRUCT_SVG, steps:BEACH_BALL_OUTLINE_STEPS };
|
||||
export const BEACH_BALL_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"beach-ball", subjectName:`Beach Ball`, subjectEmoji:`🏐`, order:1, packKey:"pack-3d", emoji:`🏐`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:113, slug:"beach-ball-color", title:`Beach Ball \u00b7 Color`, subject:`color the beach ball`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:BEACH_BALL_FULL_SVG, steps:[] };
|
||||
export const BEACH_BALL_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"beach-ball", subjectName:`Beach Ball`, subjectEmoji:`🏐`, order:1, packKey:"pack-3d", emoji:`🏐`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:114, slug:"beach-ball-light", title:`Beach Ball \u00b7 Light & shadow`, subject:`shade the beach ball`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:BEACH_BALL_FULL_SVG, steps:[] };
|
||||
|
||||
const GIFT_BOX_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The front`, instruction:`Draw a square for the front of the box.`, lines:[`<path d="M140 132 L262 132 L262 244 L140 244 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Make it 3-D`, instruction:`Add the top and the side to give it depth.`, tip:`See the box pop out!`, lines:[`<path d="M140 132 L178 98 L300 98 L262 132" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M262 132 L300 98 L300 210 L262 244" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const GIFT_BOX_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The ribbon`, instruction:`Draw a ribbon down the front and over the top.`, lines:[`<path d="M201 132 L201 244" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M201 132 L220 98" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The bow`, instruction:`Add a bow where the ribbons meet.`, lines:[`<path d="M210 96 C 188 74 182 104 210 100" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M210 96 C 232 74 238 104 210 100" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const GIFT_BOX_CONSTRUCT_SVG = GIFT_BOX_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const GIFT_BOX_FULL_SVG = GIFT_BOX_CONSTRUCT_SVG + "\n" + GIFT_BOX_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const GIFT_BOX_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"gift-box", subjectName:`Gift Box`, subjectEmoji:`🎁`, order:2, packKey:"pack-3d", emoji:`🎁`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:121, slug:"gift-box-shapes", title:`Gift Box \u00b7 Shapes`, subject:`Gift Box shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:GIFT_BOX_CONSTRUCT_STEPS };
|
||||
export const GIFT_BOX_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"gift-box", subjectName:`Gift Box`, subjectEmoji:`🎁`, order:2, packKey:"pack-3d", emoji:`🎁`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:122, slug:"gift-box-outline", title:`Gift Box \u00b7 Outline`, subject:`Gift Box outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:GIFT_BOX_CONSTRUCT_SVG, steps:GIFT_BOX_OUTLINE_STEPS };
|
||||
export const GIFT_BOX_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"gift-box", subjectName:`Gift Box`, subjectEmoji:`🎁`, order:2, packKey:"pack-3d", emoji:`🎁`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:123, slug:"gift-box-color", title:`Gift Box \u00b7 Color`, subject:`color the gift box`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:GIFT_BOX_FULL_SVG, steps:[] };
|
||||
export const GIFT_BOX_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"gift-box", subjectName:`Gift Box`, subjectEmoji:`🎁`, order:2, packKey:"pack-3d", emoji:`🎁`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:124, slug:"gift-box-light", title:`Gift Box \u00b7 Light & shadow`, subject:`shade the gift box`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:GIFT_BOX_FULL_SVG, steps:[] };
|
||||
|
||||
const MUG_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The top`, instruction:`Draw a flat oval for the opening.`, tip:`An oval, not a circle.`, lines:[`<ellipse cx="200" cy="112" rx="68" ry="20" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The body`, instruction:`Drop two sides down and curve the bottom.`, tip:`That is a cylinder!`, lines:[`<path d="M132 112 L140 228" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M268 112 L260 228" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M140 228 Q200 250 260 228" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const MUG_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The rim`, instruction:`Draw a smaller oval inside the top.`, lines:[`<ellipse cx="200" cy="112" rx="57" ry="15" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The handle`, instruction:`Add a handle on the side.`, tip:`Ready for cocoa!`, lines:[`<path d="M268 142 C 318 142 318 198 262 196" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const MUG_CONSTRUCT_SVG = MUG_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const MUG_FULL_SVG = MUG_CONSTRUCT_SVG + "\n" + MUG_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const MUG_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"mug", subjectName:`Mug`, subjectEmoji:`☕`, order:3, packKey:"pack-3d", emoji:`☕`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:131, slug:"mug-shapes", title:`Mug \u00b7 Shapes`, subject:`Mug shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:MUG_CONSTRUCT_STEPS };
|
||||
export const MUG_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"mug", subjectName:`Mug`, subjectEmoji:`☕`, order:3, packKey:"pack-3d", emoji:`☕`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:132, slug:"mug-outline", title:`Mug \u00b7 Outline`, subject:`Mug outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:MUG_CONSTRUCT_SVG, steps:MUG_OUTLINE_STEPS };
|
||||
export const MUG_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"mug", subjectName:`Mug`, subjectEmoji:`☕`, order:3, packKey:"pack-3d", emoji:`☕`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:133, slug:"mug-color", title:`Mug \u00b7 Color`, subject:`color the mug`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:MUG_FULL_SVG, steps:[] };
|
||||
export const MUG_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"mug", subjectName:`Mug`, subjectEmoji:`☕`, order:3, packKey:"pack-3d", emoji:`☕`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:134, slug:"mug-light", title:`Mug \u00b7 Light & shadow`, subject:`shade the mug`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:MUG_FULL_SVG, steps:[] };
|
||||
|
||||
const PARTY_HAT_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The cone`, instruction:`Draw two lines down to a wide base.`, lines:[`<path d="M200 62 L152 214" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 62 L248 214" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The base`, instruction:`Curve an oval base at the bottom.`, tip:`A cone!`, lines:[`<path d="M152 214 Q200 236 248 214" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M152 214 Q200 192 248 214" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const PARTY_HAT_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Pom-pom`, instruction:`Add a fluffy ball on the very top.`, lines:[`<circle cx="200" cy="58" r="12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Stripes`, instruction:`Add zig-zag stripes across the hat.`, tip:`Party time!`, lines:[`<path d="M168 150 L184 140 L200 150 L216 140 L232 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M176 180 L192 170 L208 180 L224 170 L240 180" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const PARTY_HAT_CONSTRUCT_SVG = PARTY_HAT_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const PARTY_HAT_FULL_SVG = PARTY_HAT_CONSTRUCT_SVG + "\n" + PARTY_HAT_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const PARTY_HAT_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"party-hat", subjectName:`Party Hat`, subjectEmoji:`🎉`, order:4, packKey:"pack-3d", emoji:`🎉`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:141, slug:"party-hat-shapes", title:`Party Hat \u00b7 Shapes`, subject:`Party Hat shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:PARTY_HAT_CONSTRUCT_STEPS };
|
||||
export const PARTY_HAT_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"party-hat", subjectName:`Party Hat`, subjectEmoji:`🎉`, order:4, packKey:"pack-3d", emoji:`🎉`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:142, slug:"party-hat-outline", title:`Party Hat \u00b7 Outline`, subject:`Party Hat outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:PARTY_HAT_CONSTRUCT_SVG, steps:PARTY_HAT_OUTLINE_STEPS };
|
||||
export const PARTY_HAT_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"party-hat", subjectName:`Party Hat`, subjectEmoji:`🎉`, order:4, packKey:"pack-3d", emoji:`🎉`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:143, slug:"party-hat-color", title:`Party Hat \u00b7 Color`, subject:`color the party hat`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:PARTY_HAT_FULL_SVG, steps:[] };
|
||||
export const PARTY_HAT_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"party-hat", subjectName:`Party Hat`, subjectEmoji:`🎉`, order:4, packKey:"pack-3d", emoji:`🎉`, badgeKey:"", badgeName:`Shape Shifter`, sublevel:144, slug:"party-hat-light", title:`Party Hat \u00b7 Light & shadow`, subject:`shade the party hat`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:PARTY_HAT_FULL_SVG, steps:[] };
|
||||
|
||||
const ICE_CREAM_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The cone`, instruction:`Draw a triangle pointing down.`, lines:[`<path d="M160 168 L200 268 L240 168" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The scoop`, instruction:`Add a round scoop on top.`, tip:`Cone + sphere!`, lines:[`<circle cx="200" cy="138" r="50" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ICE_CREAM_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Waffle lines`, instruction:`Cross some lines on the cone.`, lines:[`<path d="M174 184 L208 250" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M226 184 L192 250" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M196 178 L228 238" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M204 178 L172 238" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Scoop top`, instruction:`Add little bumps on the scoop.`, tip:`Yum!`, lines:[`<path d="M156 122 q14 -16 28 0 q14 -16 28 0 q14 -16 28 0 q14 -16 28 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ICE_CREAM_CONSTRUCT_SVG = ICE_CREAM_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const ICE_CREAM_FULL_SVG = ICE_CREAM_CONSTRUCT_SVG + "\n" + ICE_CREAM_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const ICE_CREAM_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"ice-cream", subjectName:`Ice-Cream Cone`, subjectEmoji:`🍦`, order:5, packKey:"pack-combine", emoji:`🍦`, badgeKey:"", badgeName:`Shape Builder`, sublevel:211, slug:"ice-cream-shapes", title:`Ice-Cream Cone \u00b7 Shapes`, subject:`Ice-Cream Cone shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:ICE_CREAM_CONSTRUCT_STEPS };
|
||||
export const ICE_CREAM_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"ice-cream", subjectName:`Ice-Cream Cone`, subjectEmoji:`🍦`, order:5, packKey:"pack-combine", emoji:`🍦`, badgeKey:"", badgeName:`Shape Builder`, sublevel:212, slug:"ice-cream-outline", title:`Ice-Cream Cone \u00b7 Outline`, subject:`Ice-Cream Cone outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:ICE_CREAM_CONSTRUCT_SVG, steps:ICE_CREAM_OUTLINE_STEPS };
|
||||
export const ICE_CREAM_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"ice-cream", subjectName:`Ice-Cream Cone`, subjectEmoji:`🍦`, order:5, packKey:"pack-combine", emoji:`🍦`, badgeKey:"", badgeName:`Shape Builder`, sublevel:213, slug:"ice-cream-color", title:`Ice-Cream Cone \u00b7 Color`, subject:`color the ice-cream cone`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:ICE_CREAM_FULL_SVG, steps:[] };
|
||||
export const ICE_CREAM_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"ice-cream", subjectName:`Ice-Cream Cone`, subjectEmoji:`🍦`, order:5, packKey:"pack-combine", emoji:`🍦`, badgeKey:"", badgeName:`Shape Builder`, sublevel:214, slug:"ice-cream-light", title:`Ice-Cream Cone \u00b7 Light & shadow`, subject:`shade the ice-cream cone`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:ICE_CREAM_FULL_SVG, steps:[] };
|
||||
|
||||
const SNOWMAN_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Three balls`, instruction:`Stack a big, medium and small circle.`, tip:`Biggest on the bottom.`, lines:[`<circle cx="200" cy="232" r="46" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="200" cy="162" r="36" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="200" cy="108" r="26" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SNOWMAN_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add two eyes and a carrot nose.`, lines:[`<circle cx="192" cy="104" r="3" fill="#2b2440"/>`,`<circle cx="208" cy="104" r="3" fill="#2b2440"/>`,`<path d="M200 112 L222 116 L200 120 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Arms & buttons`, instruction:`Add stick arms and a few buttons.`, lines:[`<path d="M164 158 L128 144" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M236 158 L272 144" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="200" cy="150" r="4" fill="#2b2440"/>`,`<circle cx="200" cy="166" r="4" fill="#2b2440"/>`,`<circle cx="200" cy="182" r="4" fill="#2b2440"/>`] },
|
||||
{ n:3, title:`The hat`, instruction:`Pop a hat on his head.`, tip:`Brrr!`, lines:[`<path d="M178 90 L222 90" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M183 90 L183 70 L217 70 L217 90" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SNOWMAN_CONSTRUCT_SVG = SNOWMAN_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const SNOWMAN_FULL_SVG = SNOWMAN_CONSTRUCT_SVG + "\n" + SNOWMAN_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const SNOWMAN_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"snowman", subjectName:`Snowman`, subjectEmoji:`⛄`, order:6, packKey:"pack-combine", emoji:`⛄`, badgeKey:"", badgeName:`Shape Builder`, sublevel:221, slug:"snowman-shapes", title:`Snowman \u00b7 Shapes`, subject:`Snowman shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:SNOWMAN_CONSTRUCT_STEPS };
|
||||
export const SNOWMAN_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"snowman", subjectName:`Snowman`, subjectEmoji:`⛄`, order:6, packKey:"pack-combine", emoji:`⛄`, badgeKey:"", badgeName:`Shape Builder`, sublevel:222, slug:"snowman-outline", title:`Snowman \u00b7 Outline`, subject:`Snowman outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:SNOWMAN_CONSTRUCT_SVG, steps:SNOWMAN_OUTLINE_STEPS };
|
||||
export const SNOWMAN_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"snowman", subjectName:`Snowman`, subjectEmoji:`⛄`, order:6, packKey:"pack-combine", emoji:`⛄`, badgeKey:"", badgeName:`Shape Builder`, sublevel:223, slug:"snowman-color", title:`Snowman \u00b7 Color`, subject:`color the snowman`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:SNOWMAN_FULL_SVG, steps:[] };
|
||||
export const SNOWMAN_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"snowman", subjectName:`Snowman`, subjectEmoji:`⛄`, order:6, packKey:"pack-combine", emoji:`⛄`, badgeKey:"", badgeName:`Shape Builder`, sublevel:224, slug:"snowman-light", title:`Snowman \u00b7 Light & shadow`, subject:`shade the snowman`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:SNOWMAN_FULL_SVG, steps:[] };
|
||||
|
||||
const HOUSE_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The walls`, instruction:`Draw a square for the walls.`, lines:[`<path d="M140 152 L260 152 L260 252 L140 252 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The roof`, instruction:`Add a triangle roof on top.`, tip:`Box + pyramid!`, lines:[`<path d="M128 152 L200 96 L272 152" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const HOUSE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The door`, instruction:`Draw a door in the middle.`, lines:[`<path d="M184 252 L184 206 L216 206 L216 252" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Windows`, instruction:`Add a window on each side.`, lines:[`<path d="M150 176 L174 176 L174 200 L150 200 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M226 176 L250 176 L250 200 L226 200 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:3, title:`Chimney`, instruction:`Add a chimney on the roof.`, tip:`Home sweet home!`, lines:[`<path d="M236 118 L236 92 L252 92 L252 134" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const HOUSE_CONSTRUCT_SVG = HOUSE_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const HOUSE_FULL_SVG = HOUSE_CONSTRUCT_SVG + "\n" + HOUSE_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const HOUSE_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"house", subjectName:`House`, subjectEmoji:`🏠`, order:7, packKey:"pack-combine", emoji:`🏠`, badgeKey:"", badgeName:`Shape Builder`, sublevel:231, slug:"house-shapes", title:`House \u00b7 Shapes`, subject:`House shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:HOUSE_CONSTRUCT_STEPS };
|
||||
export const HOUSE_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"house", subjectName:`House`, subjectEmoji:`🏠`, order:7, packKey:"pack-combine", emoji:`🏠`, badgeKey:"", badgeName:`Shape Builder`, sublevel:232, slug:"house-outline", title:`House \u00b7 Outline`, subject:`House outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:HOUSE_CONSTRUCT_SVG, steps:HOUSE_OUTLINE_STEPS };
|
||||
export const HOUSE_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"house", subjectName:`House`, subjectEmoji:`🏠`, order:7, packKey:"pack-combine", emoji:`🏠`, badgeKey:"", badgeName:`Shape Builder`, sublevel:233, slug:"house-color", title:`House \u00b7 Color`, subject:`color the house`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:HOUSE_FULL_SVG, steps:[] };
|
||||
export const HOUSE_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"house", subjectName:`House`, subjectEmoji:`🏠`, order:7, packKey:"pack-combine", emoji:`🏠`, badgeKey:"", badgeName:`Shape Builder`, sublevel:234, slug:"house-light", title:`House \u00b7 Light & shadow`, subject:`shade the house`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:HOUSE_FULL_SVG, steps:[] };
|
||||
|
||||
const ROCKET_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw a tall tube for the body.`, lines:[`<path d="M174 112 L226 112 L226 236 L174 236 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Nose & fins`, instruction:`Add a cone on top and a fin each side.`, lines:[`<path d="M174 112 L200 60 L226 112" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M174 202 L150 246 L174 230 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M226 202 L250 246 L226 230 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ROCKET_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The window`, instruction:`Add a round window.`, lines:[`<circle cx="200" cy="148" r="17" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Blast off`, instruction:`Add a band and a flame.`, tip:`Whoosh!`, lines:[`<path d="M174 202 L226 202" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M184 236 Q200 272 216 236" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ROCKET_CONSTRUCT_SVG = ROCKET_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const ROCKET_FULL_SVG = ROCKET_CONSTRUCT_SVG + "\n" + ROCKET_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const ROCKET_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"rocket", subjectName:`Rocket`, subjectEmoji:`🚀`, order:8, packKey:"pack-combine", emoji:`🚀`, badgeKey:"", badgeName:`Shape Builder`, sublevel:241, slug:"rocket-shapes", title:`Rocket \u00b7 Shapes`, subject:`Rocket shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:ROCKET_CONSTRUCT_STEPS };
|
||||
export const ROCKET_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"rocket", subjectName:`Rocket`, subjectEmoji:`🚀`, order:8, packKey:"pack-combine", emoji:`🚀`, badgeKey:"", badgeName:`Shape Builder`, sublevel:242, slug:"rocket-outline", title:`Rocket \u00b7 Outline`, subject:`Rocket outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:ROCKET_CONSTRUCT_SVG, steps:ROCKET_OUTLINE_STEPS };
|
||||
export const ROCKET_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"rocket", subjectName:`Rocket`, subjectEmoji:`🚀`, order:8, packKey:"pack-combine", emoji:`🚀`, badgeKey:"", badgeName:`Shape Builder`, sublevel:243, slug:"rocket-color", title:`Rocket \u00b7 Color`, subject:`color the rocket`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:ROCKET_FULL_SVG, steps:[] };
|
||||
export const ROCKET_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"rocket", subjectName:`Rocket`, subjectEmoji:`🚀`, order:8, packKey:"pack-combine", emoji:`🚀`, badgeKey:"", badgeName:`Shape Builder`, sublevel:244, slug:"rocket-light", title:`Rocket \u00b7 Light & shadow`, subject:`shade the rocket`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:ROCKET_FULL_SVG, steps:[] };
|
||||
|
||||
const ROBOT_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Head & body`, instruction:`Draw a square head and a box body.`, lines:[`<path d="M170 72 L230 72 L230 122 L170 122 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M150 126 L250 126 L250 214 L150 214 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Arms & legs`, instruction:`Add cylinder arms and two legs.`, lines:[`<path d="M122 136 L150 136 L150 196 L122 196 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 136 L278 136 L278 196 L250 196 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M166 214 L186 214 L186 254 L166 254 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M214 214 L234 214 L234 254 L214 254 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const ROBOT_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The face`, instruction:`Add two eyes and a mouth grid.`, lines:[`<circle cx="186" cy="94" r="7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="214" cy="94" r="7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M178 108 L222 108" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M194 104 L194 112" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M210 104 L210 112" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Antenna & buttons`, instruction:`Add an antenna and control buttons.`, tip:`Beep boop!`, lines:[`<path d="M200 72 L200 56" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="200" cy="52" r="5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="176" cy="150" r="5" fill="#2b2440"/>`,`<circle cx="200" cy="150" r="5" fill="#2b2440"/>`,`<circle cx="224" cy="150" r="5" fill="#2b2440"/>`] },
|
||||
];
|
||||
|
||||
const ROBOT_CONSTRUCT_SVG = ROBOT_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const ROBOT_FULL_SVG = ROBOT_CONSTRUCT_SVG + "\n" + ROBOT_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const ROBOT_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"robot", subjectName:`Robot`, subjectEmoji:`🤖`, order:9, packKey:"pack-build", emoji:`🤖`, badgeKey:"", badgeName:`Master Maker`, sublevel:311, slug:"robot-shapes", title:`Robot \u00b7 Shapes`, subject:`Robot shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:ROBOT_CONSTRUCT_STEPS };
|
||||
export const ROBOT_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"robot", subjectName:`Robot`, subjectEmoji:`🤖`, order:9, packKey:"pack-build", emoji:`🤖`, badgeKey:"", badgeName:`Master Maker`, sublevel:312, slug:"robot-outline", title:`Robot \u00b7 Outline`, subject:`Robot outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:ROBOT_CONSTRUCT_SVG, steps:ROBOT_OUTLINE_STEPS };
|
||||
export const ROBOT_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"robot", subjectName:`Robot`, subjectEmoji:`🤖`, order:9, packKey:"pack-build", emoji:`🤖`, badgeKey:"", badgeName:`Master Maker`, sublevel:313, slug:"robot-color", title:`Robot \u00b7 Color`, subject:`color the robot`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:ROBOT_FULL_SVG, steps:[] };
|
||||
export const ROBOT_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"robot", subjectName:`Robot`, subjectEmoji:`🤖`, order:9, packKey:"pack-build", emoji:`🤖`, badgeKey:"", badgeName:`Master Maker`, sublevel:314, slug:"robot-light", title:`Robot \u00b7 Light & shadow`, subject:`shade the robot`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:ROBOT_FULL_SVG, steps:[] };
|
||||
|
||||
const CAR_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw a long box for the body.`, lines:[`<path d="M110 176 L290 176 L290 214 L110 214 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Cabin & wheels`, instruction:`Add a roof box and two round wheels.`, lines:[`<path d="M154 176 L172 140 L244 140 L262 176" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="152" cy="220" r="22" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="256" cy="220" r="22" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CAR_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Windows`, instruction:`Split the cabin into windows.`, lines:[`<path d="M200 140 L200 176" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Details`, instruction:`Add a door line and a headlight.`, tip:`Vroom!`, lines:[`<path d="M205 176 L205 214" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="283" cy="190" r="5" fill="#2b2440"/>`,`<circle cx="152" cy="220" r="7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="256" cy="220" r="7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CAR_CONSTRUCT_SVG = CAR_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const CAR_FULL_SVG = CAR_CONSTRUCT_SVG + "\n" + CAR_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const CAR_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"car", subjectName:`Car`, subjectEmoji:`🚗`, order:10, packKey:"pack-build", emoji:`🚗`, badgeKey:"", badgeName:`Master Maker`, sublevel:321, slug:"car-shapes", title:`Car \u00b7 Shapes`, subject:`Car shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:CAR_CONSTRUCT_STEPS };
|
||||
export const CAR_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"car", subjectName:`Car`, subjectEmoji:`🚗`, order:10, packKey:"pack-build", emoji:`🚗`, badgeKey:"", badgeName:`Master Maker`, sublevel:322, slug:"car-outline", title:`Car \u00b7 Outline`, subject:`Car outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:CAR_CONSTRUCT_SVG, steps:CAR_OUTLINE_STEPS };
|
||||
export const CAR_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"car", subjectName:`Car`, subjectEmoji:`🚗`, order:10, packKey:"pack-build", emoji:`🚗`, badgeKey:"", badgeName:`Master Maker`, sublevel:323, slug:"car-color", title:`Car \u00b7 Color`, subject:`color the car`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:CAR_FULL_SVG, steps:[] };
|
||||
export const CAR_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"car", subjectName:`Car`, subjectEmoji:`🚗`, order:10, packKey:"pack-build", emoji:`🚗`, badgeKey:"", badgeName:`Master Maker`, sublevel:324, slug:"car-light", title:`Car \u00b7 Light & shadow`, subject:`shade the car`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:CAR_FULL_SVG, steps:[] };
|
||||
|
||||
const CASTLE_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Keep & towers`, instruction:`Draw a center wall and two towers.`, lines:[`<path d="M162 150 L238 150 L238 252 L162 252 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M120 130 L160 130 L160 252 L120 252 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M240 130 L280 130 L280 252 L240 252 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Tower roofs`, instruction:`Add a cone roof on each tower.`, lines:[`<path d="M116 130 L140 96 L164 130" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M236 130 L260 96 L284 130" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CASTLE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Battlements`, instruction:`Add square teeth on the center wall.`, lines:[`<path d="M162 150 L162 138 L180 138 L180 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M191 150 L191 138 L209 138 L209 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M220 150 L220 138 L238 138 L238 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Gate & flags`, instruction:`Add a gate and two flags.`, tip:`A mighty castle!`, lines:[`<path d="M186 252 L186 214 Q200 202 214 214 L214 252" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M140 96 L140 80 L156 86 L140 90" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M260 96 L260 80 L276 86 L260 90" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CASTLE_CONSTRUCT_SVG = CASTLE_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const CASTLE_FULL_SVG = CASTLE_CONSTRUCT_SVG + "\n" + CASTLE_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const CASTLE_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"castle", subjectName:`Castle`, subjectEmoji:`🏰`, order:11, packKey:"pack-build", emoji:`🏰`, badgeKey:"", badgeName:`Master Maker`, sublevel:331, slug:"castle-shapes", title:`Castle \u00b7 Shapes`, subject:`Castle shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:CASTLE_CONSTRUCT_STEPS };
|
||||
export const CASTLE_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"castle", subjectName:`Castle`, subjectEmoji:`🏰`, order:11, packKey:"pack-build", emoji:`🏰`, badgeKey:"", badgeName:`Master Maker`, sublevel:332, slug:"castle-outline", title:`Castle \u00b7 Outline`, subject:`Castle outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:CASTLE_CONSTRUCT_SVG, steps:CASTLE_OUTLINE_STEPS };
|
||||
export const CASTLE_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"castle", subjectName:`Castle`, subjectEmoji:`🏰`, order:11, packKey:"pack-build", emoji:`🏰`, badgeKey:"", badgeName:`Master Maker`, sublevel:333, slug:"castle-color", title:`Castle \u00b7 Color`, subject:`color the castle`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:CASTLE_FULL_SVG, steps:[] };
|
||||
export const CASTLE_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"castle", subjectName:`Castle`, subjectEmoji:`🏰`, order:11, packKey:"pack-build", emoji:`🏰`, badgeKey:"", badgeName:`Master Maker`, sublevel:334, slug:"castle-light", title:`Castle \u00b7 Light & shadow`, subject:`shade the castle`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:CASTLE_FULL_SVG, steps:[] };
|
||||
|
||||
const SAILBOAT_CONSTRUCT_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The hull`, instruction:`Draw a boat shape for the bottom.`, lines:[`<path d="M130 214 L270 214 L250 250 L150 250 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Mast & sails`, instruction:`Add a mast and two triangle sails.`, lines:[`<path d="M200 214 L200 100" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M192 110 L192 200 L150 200 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M208 112 L208 200 L252 200 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SAILBOAT_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The flag`, instruction:`Add a flag at the top of the mast.`, lines:[`<path d="M200 100 L200 86 L224 93 L200 100" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The waves`, instruction:`Add waves under the boat.`, tip:`Set sail!`, lines:[`<path d="M120 262 q14 -14 28 0 q14 -14 28 0 q14 -14 28 0 q14 -14 28 0 q14 -14 28 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SAILBOAT_CONSTRUCT_SVG = SAILBOAT_CONSTRUCT_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
const SAILBOAT_FULL_SVG = SAILBOAT_CONSTRUCT_SVG + "\n" + SAILBOAT_OUTLINE_STEPS.flatMap(s=>s.lines||[]).join("\n");
|
||||
export const SAILBOAT_CONSTRUCT_LESSON: Lesson = { level:"beginner", subjectKey:"sailboat", subjectName:`Sailboat`, subjectEmoji:`⛵`, order:12, packKey:"pack-build", emoji:`⛵`, badgeKey:"", badgeName:`Master Maker`, sublevel:341, slug:"sailboat-shapes", title:`Sailboat \u00b7 Shapes`, subject:`Sailboat shapes`, intro:`Block in the basic shapes — draw them light, one at a time.`, phase:"construct", steps:SAILBOAT_CONSTRUCT_STEPS };
|
||||
export const SAILBOAT_OUTLINE_LESSON: Lesson = { level:"beginner", subjectKey:"sailboat", subjectName:`Sailboat`, subjectEmoji:`⛵`, order:12, packKey:"pack-build", emoji:`⛵`, badgeKey:"", badgeName:`Master Maker`, sublevel:342, slug:"sailboat-outline", title:`Sailboat \u00b7 Outline`, subject:`Sailboat outline`, intro:`Turn your shapes into clean line art.`, phase:"outline", baseSvg:SAILBOAT_CONSTRUCT_SVG, steps:SAILBOAT_OUTLINE_STEPS };
|
||||
export const SAILBOAT_COLOR_LESSON: Lesson = { level:"beginner", subjectKey:"sailboat", subjectName:`Sailboat`, subjectEmoji:`⛵`, order:12, packKey:"pack-build", emoji:`⛵`, badgeKey:"", badgeName:`Master Maker`, sublevel:343, slug:"sailboat-color", title:`Sailboat \u00b7 Color`, subject:`color the sailboat`, intro:`Fill it in with flat color. Tip: red + yellow = orange!`, phase:"color", baseSvg:SAILBOAT_FULL_SVG, steps:[] };
|
||||
export const SAILBOAT_LIGHT_LESSON: Lesson = { level:"beginner", subjectKey:"sailboat", subjectName:`Sailboat`, subjectEmoji:`⛵`, order:12, packKey:"pack-build", emoji:`⛵`, badgeKey:"", badgeName:`Master Maker`, sublevel:344, slug:"sailboat-light", title:`Sailboat \u00b7 Light & shadow`, subject:`shade the sailboat`, intro:`The sun is up-left. Softly shade the side facing away from it.`, phase:"light", baseSvg:SAILBOAT_FULL_SVG, steps:[] };
|
||||
|
||||
export const BEGINNER_LESSONS: Lesson[] = [
|
||||
BEACH_BALL_CONSTRUCT_LESSON,
|
||||
BEACH_BALL_OUTLINE_LESSON,
|
||||
BEACH_BALL_COLOR_LESSON,
|
||||
BEACH_BALL_LIGHT_LESSON,
|
||||
GIFT_BOX_CONSTRUCT_LESSON,
|
||||
GIFT_BOX_OUTLINE_LESSON,
|
||||
GIFT_BOX_COLOR_LESSON,
|
||||
GIFT_BOX_LIGHT_LESSON,
|
||||
MUG_CONSTRUCT_LESSON,
|
||||
MUG_OUTLINE_LESSON,
|
||||
MUG_COLOR_LESSON,
|
||||
MUG_LIGHT_LESSON,
|
||||
PARTY_HAT_CONSTRUCT_LESSON,
|
||||
PARTY_HAT_OUTLINE_LESSON,
|
||||
PARTY_HAT_COLOR_LESSON,
|
||||
PARTY_HAT_LIGHT_LESSON,
|
||||
ICE_CREAM_CONSTRUCT_LESSON,
|
||||
ICE_CREAM_OUTLINE_LESSON,
|
||||
ICE_CREAM_COLOR_LESSON,
|
||||
ICE_CREAM_LIGHT_LESSON,
|
||||
SNOWMAN_CONSTRUCT_LESSON,
|
||||
SNOWMAN_OUTLINE_LESSON,
|
||||
SNOWMAN_COLOR_LESSON,
|
||||
SNOWMAN_LIGHT_LESSON,
|
||||
HOUSE_CONSTRUCT_LESSON,
|
||||
HOUSE_OUTLINE_LESSON,
|
||||
HOUSE_COLOR_LESSON,
|
||||
HOUSE_LIGHT_LESSON,
|
||||
ROCKET_CONSTRUCT_LESSON,
|
||||
ROCKET_OUTLINE_LESSON,
|
||||
ROCKET_COLOR_LESSON,
|
||||
ROCKET_LIGHT_LESSON,
|
||||
ROBOT_CONSTRUCT_LESSON,
|
||||
ROBOT_OUTLINE_LESSON,
|
||||
ROBOT_COLOR_LESSON,
|
||||
ROBOT_LIGHT_LESSON,
|
||||
CAR_CONSTRUCT_LESSON,
|
||||
CAR_OUTLINE_LESSON,
|
||||
CAR_COLOR_LESSON,
|
||||
CAR_LIGHT_LESSON,
|
||||
CASTLE_CONSTRUCT_LESSON,
|
||||
CASTLE_OUTLINE_LESSON,
|
||||
CASTLE_COLOR_LESSON,
|
||||
CASTLE_LIGHT_LESSON,
|
||||
SAILBOAT_CONSTRUCT_LESSON,
|
||||
SAILBOAT_OUTLINE_LESSON,
|
||||
SAILBOAT_COLOR_LESSON,
|
||||
SAILBOAT_LIGHT_LESSON
|
||||
];
|
||||
|
||||
export interface Pack { key: string; name: string; emoji: string; blurb: string; level: string; badgeKey: string; badgeName: string; subjectKeys: string[]; }
|
||||
export const BEGINNER_PACKS: Pack[] = [
|
||||
{ key:"pack-3d", name:`Make it 3-D`, emoji:`📦`, blurb:`Turn flat shapes into solid 3-D forms.`, level:"beginner", badgeKey:"beginner-pack-1-3d", badgeName:`Shape Shifter`, subjectKeys:["beach-ball","gift-box","mug","party-hat"] },
|
||||
{ key:"pack-combine", name:`Put it together`, emoji:`🧩`, blurb:`Combine two or three forms into one picture.`, level:"beginner", badgeKey:"beginner-pack-2-combine", badgeName:`Shape Builder`, subjectKeys:["ice-cream","snowman","house","rocket"] },
|
||||
{ key:"pack-build", name:`Build a thing`, emoji:`🏗️`, blurb:`Construct bigger objects from many parts.`, level:"beginner", badgeKey:"beginner-pack-3-build", badgeName:`Master Maker`, subjectKeys:["robot","car","castle","sailboat"] },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/* AUTO-GENERATED by gen_eb2.py — Early Beginner geometric subjects (Simple Shapes, Fun Things, Clothes, Faces). */
|
||||
import type { Lesson, LessonStep } from "./curriculum";
|
||||
|
||||
const SQUARE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The square`, instruction:`Draw four straight sides to make a square.`, tip:`Keep the corners square!`, lines:[`<path d="M132 92 L268 92 L268 228 L132 228 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SQUARE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Happy face`, instruction:`Add two eyes and a big smile.`, tip:`Now it's a friendly square!`, lines:[`<circle cx="176" cy="150" r="8" fill="#2b2440"/>`,`<circle cx="224" cy="150" r="8" fill="#2b2440"/>`,`<path d="M172 182 q28 26 56 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SQUARE_OUTLINE_SVG = SQUARE_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const SQUARE_DETAIL_SVG = SQUARE_OUTLINE_SVG + "\n" + SQUARE_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const SQUARE_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"square", subjectName:`Square`, subjectEmoji:`🟦`, emoji:`🟦`, order:7, sublevel:71, slug:"square-outline", title:`Square \u00b7 Outline`, subject:`square outline`, intro:`Let\u2019s draw Square\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Square Star`, phase:"outline", steps:SQUARE_OUTLINE_STEPS };
|
||||
export const SQUARE_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"square", subjectName:`Square`, subjectEmoji:`🟦`, emoji:`🟦`, order:7, sublevel:72, slug:"square-detail", title:`Square \u00b7 Details`, subject:`square details`, intro:`Now add the details that make your square fun.`, badgeKey:"early-beginner-7-square", badgeName:`Square Star`, phase:"detail", baseSvg:SQUARE_OUTLINE_SVG, steps:SQUARE_DETAIL_STEPS };
|
||||
export const SQUARE_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"square", subjectName:`Square`, subjectEmoji:`🟦`, emoji:`🟦`, order:7, sublevel:73, slug:"square-extra", title:`Square \u00b7 Color it!`, subject:`color the square`, intro:`Bring your square to life! Color it in.`, badgeKey:"", badgeName:`Square Star`, phase:"extra", baseSvg:SQUARE_DETAIL_SVG, steps:[] };
|
||||
|
||||
const TRIANGLE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The triangle`, instruction:`Draw three straight sides that meet at a point.`, lines:[`<path d="M200 80 L290 230 L110 230 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TRIANGLE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Happy face`, instruction:`Add a cute face near the bottom.`, lines:[`<circle cx="178" cy="182" r="7" fill="#2b2440"/>`,`<circle cx="222" cy="182" r="7" fill="#2b2440"/>`,`<path d="M180 200 q20 18 40 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TRIANGLE_OUTLINE_SVG = TRIANGLE_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const TRIANGLE_DETAIL_SVG = TRIANGLE_OUTLINE_SVG + "\n" + TRIANGLE_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const TRIANGLE_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"triangle", subjectName:`Triangle`, subjectEmoji:`🔺`, emoji:`🔺`, order:8, sublevel:81, slug:"triangle-outline", title:`Triangle \u00b7 Outline`, subject:`triangle outline`, intro:`Let\u2019s draw Triangle\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Triangle Ace`, phase:"outline", steps:TRIANGLE_OUTLINE_STEPS };
|
||||
export const TRIANGLE_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"triangle", subjectName:`Triangle`, subjectEmoji:`🔺`, emoji:`🔺`, order:8, sublevel:82, slug:"triangle-detail", title:`Triangle \u00b7 Details`, subject:`triangle details`, intro:`Now add the details that make your triangle fun.`, badgeKey:"early-beginner-8-triangle", badgeName:`Triangle Ace`, phase:"detail", baseSvg:TRIANGLE_OUTLINE_SVG, steps:TRIANGLE_DETAIL_STEPS };
|
||||
export const TRIANGLE_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"triangle", subjectName:`Triangle`, subjectEmoji:`🔺`, emoji:`🔺`, order:8, sublevel:83, slug:"triangle-extra", title:`Triangle \u00b7 Color it!`, subject:`color the triangle`, intro:`Bring your triangle to life! Color it in.`, badgeKey:"", badgeName:`Triangle Ace`, phase:"extra", baseSvg:TRIANGLE_DETAIL_SVG, steps:[] };
|
||||
|
||||
const HEART_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The heart`, instruction:`Draw two bumps on top that swoop to a point.`, tip:`Slow on the bumps!`, lines:[`<path d="M200 224 C 112 168 126 92 174 92 C 196 92 200 112 200 124 C 200 112 204 92 226 92 C 274 92 288 168 200 224 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const HEART_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Shine & smile`, instruction:`Add a shine line and a little smile.`, lines:[`<path d="M162 124 q-8 18 4 34" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M184 168 q16 12 32 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const HEART_OUTLINE_SVG = HEART_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const HEART_DETAIL_SVG = HEART_OUTLINE_SVG + "\n" + HEART_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const HEART_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"heart", subjectName:`Heart`, subjectEmoji:`❤️`, emoji:`❤️`, order:9, sublevel:91, slug:"heart-outline", title:`Heart \u00b7 Outline`, subject:`heart outline`, intro:`Let\u2019s draw Heart\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Heart Hero`, phase:"outline", steps:HEART_OUTLINE_STEPS };
|
||||
export const HEART_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"heart", subjectName:`Heart`, subjectEmoji:`❤️`, emoji:`❤️`, order:9, sublevel:92, slug:"heart-detail", title:`Heart \u00b7 Details`, subject:`heart details`, intro:`Now add the details that make your heart fun.`, badgeKey:"early-beginner-9-heart", badgeName:`Heart Hero`, phase:"detail", baseSvg:HEART_OUTLINE_SVG, steps:HEART_DETAIL_STEPS };
|
||||
export const HEART_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"heart", subjectName:`Heart`, subjectEmoji:`❤️`, emoji:`❤️`, order:9, sublevel:93, slug:"heart-extra", title:`Heart \u00b7 Color it!`, subject:`color the heart`, intro:`Bring your heart to life! Color it in.`, badgeKey:"", badgeName:`Heart Hero`, phase:"extra", baseSvg:HEART_DETAIL_SVG, steps:[] };
|
||||
|
||||
const BOW_TIE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The two wings`, instruction:`Draw a triangle on each side meeting in the middle.`, lines:[`<path d="M194 150 L138 118 L138 182 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M206 150 L262 118 L262 182 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The knot`, instruction:`Add a little square knot in the middle.`, lines:[`<path d="M192 136 L208 136 L208 164 L192 164 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BOW_TIE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Polka dots`, instruction:`Add fold lines and some dots.`, lines:[`<path d="M150 130 L150 170" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 130 L250 170" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="166" cy="144" r="5" fill="#2b2440"/>`,`<circle cx="170" cy="162" r="4" fill="#2b2440"/>`,`<circle cx="234" cy="144" r="5" fill="#2b2440"/>`,`<circle cx="230" cy="162" r="4" fill="#2b2440"/>`] },
|
||||
];
|
||||
|
||||
const BOW_TIE_OUTLINE_SVG = BOW_TIE_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const BOW_TIE_DETAIL_SVG = BOW_TIE_OUTLINE_SVG + "\n" + BOW_TIE_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const BOW_TIE_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"bow-tie", subjectName:`Bow Tie`, subjectEmoji:`🎀`, emoji:`🎀`, order:10, sublevel:101, slug:"bow-tie-outline", title:`Bow Tie \u00b7 Outline`, subject:`bow tie outline`, intro:`Let\u2019s draw Bow Tie\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Dapper Bow`, phase:"outline", steps:BOW_TIE_OUTLINE_STEPS };
|
||||
export const BOW_TIE_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"bow-tie", subjectName:`Bow Tie`, subjectEmoji:`🎀`, emoji:`🎀`, order:10, sublevel:102, slug:"bow-tie-detail", title:`Bow Tie \u00b7 Details`, subject:`bow tie details`, intro:`Now add the details that make your bow tie fun.`, badgeKey:"early-beginner-10-bow-tie", badgeName:`Dapper Bow`, phase:"detail", baseSvg:BOW_TIE_OUTLINE_SVG, steps:BOW_TIE_DETAIL_STEPS };
|
||||
export const BOW_TIE_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"bow-tie", subjectName:`Bow Tie`, subjectEmoji:`🎀`, emoji:`🎀`, order:10, sublevel:103, slug:"bow-tie-extra", title:`Bow Tie \u00b7 Color it!`, subject:`color the bow tie`, intro:`Bring your bow tie to life! Color it in.`, badgeKey:"", badgeName:`Dapper Bow`, phase:"extra", baseSvg:BOW_TIE_DETAIL_SVG, steps:[] };
|
||||
|
||||
const FOOTBALL_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The ball`, instruction:`Draw a pointed oval.`, tip:`Pointy on each end.`, lines:[`<path d="M118 150 C 150 108 250 108 282 150 C 250 192 150 192 118 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FOOTBALL_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Laces`, instruction:`Add the laces and stitches.`, lines:[`<path d="M178 150 L222 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M186 142 L186 158" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 140 L200 160" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M214 142 L214 158" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M132 150 L150 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M250 150 L268 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FOOTBALL_OUTLINE_SVG = FOOTBALL_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const FOOTBALL_DETAIL_SVG = FOOTBALL_OUTLINE_SVG + "\n" + FOOTBALL_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const FOOTBALL_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"football", subjectName:`Football`, subjectEmoji:`🏈`, emoji:`🏈`, order:11, sublevel:111, slug:"football-outline", title:`Football \u00b7 Outline`, subject:`football outline`, intro:`Let\u2019s draw Football\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Touchdown`, phase:"outline", steps:FOOTBALL_OUTLINE_STEPS };
|
||||
export const FOOTBALL_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"football", subjectName:`Football`, subjectEmoji:`🏈`, emoji:`🏈`, order:11, sublevel:112, slug:"football-detail", title:`Football \u00b7 Details`, subject:`football details`, intro:`Now add the details that make your football fun.`, badgeKey:"early-beginner-11-football", badgeName:`Touchdown`, phase:"detail", baseSvg:FOOTBALL_OUTLINE_SVG, steps:FOOTBALL_DETAIL_STEPS };
|
||||
export const FOOTBALL_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"football", subjectName:`Football`, subjectEmoji:`🏈`, emoji:`🏈`, order:11, sublevel:113, slug:"football-extra", title:`Football \u00b7 Color it!`, subject:`color the football`, intro:`Bring your football to life! Color it in.`, badgeKey:"", badgeName:`Touchdown`, phase:"extra", baseSvg:FOOTBALL_DETAIL_SVG, steps:[] };
|
||||
|
||||
const BASKETBALL_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The ball`, instruction:`Draw one big round circle.`, lines:[`<circle cx="200" cy="150" r="82" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BASKETBALL_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The seams`, instruction:`Add the lines across the ball.`, lines:[`<path d="M200 70 L200 230" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M118 150 L282 150" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M142 94 Q176 150 142 206" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M258 94 Q224 150 258 206" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const BASKETBALL_OUTLINE_SVG = BASKETBALL_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const BASKETBALL_DETAIL_SVG = BASKETBALL_OUTLINE_SVG + "\n" + BASKETBALL_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const BASKETBALL_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"basketball", subjectName:`Basketball`, subjectEmoji:`🏀`, emoji:`🏀`, order:12, sublevel:121, slug:"basketball-outline", title:`Basketball \u00b7 Outline`, subject:`basketball outline`, intro:`Let\u2019s draw Basketball\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Hoop Star`, phase:"outline", steps:BASKETBALL_OUTLINE_STEPS };
|
||||
export const BASKETBALL_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"basketball", subjectName:`Basketball`, subjectEmoji:`🏀`, emoji:`🏀`, order:12, sublevel:122, slug:"basketball-detail", title:`Basketball \u00b7 Details`, subject:`basketball details`, intro:`Now add the details that make your basketball fun.`, badgeKey:"early-beginner-12-basketball", badgeName:`Hoop Star`, phase:"detail", baseSvg:BASKETBALL_OUTLINE_SVG, steps:BASKETBALL_DETAIL_STEPS };
|
||||
export const BASKETBALL_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"basketball", subjectName:`Basketball`, subjectEmoji:`🏀`, emoji:`🏀`, order:12, sublevel:123, slug:"basketball-extra", title:`Basketball \u00b7 Color it!`, subject:`color the basketball`, intro:`Bring your basketball to life! Color it in.`, badgeKey:"", badgeName:`Hoop Star`, phase:"extra", baseSvg:BASKETBALL_DETAIL_SVG, steps:[] };
|
||||
|
||||
const CUP_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The cup`, instruction:`Draw a cup that is wider at the top.`, tip:`Flat top — no oval yet!`, lines:[`<path d="M152 100 L248 100 L236 222 L164 222 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The lid`, instruction:`Add a lid on top.`, lines:[`<path d="M146 100 L254 100 L250 82 L150 82 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CUP_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Straw & stripe`, instruction:`Add a straw and a wavy stripe.`, lines:[`<path d="M206 82 L226 50" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M162 150 q20 -10 38 0 q18 10 38 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const CUP_OUTLINE_SVG = CUP_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const CUP_DETAIL_SVG = CUP_OUTLINE_SVG + "\n" + CUP_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const CUP_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"cup", subjectName:`Cup`, subjectEmoji:`🥤`, emoji:`🥤`, order:13, sublevel:131, slug:"cup-outline", title:`Cup \u00b7 Outline`, subject:`cup outline`, intro:`Let\u2019s draw Cup\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Cup Champ`, phase:"outline", steps:CUP_OUTLINE_STEPS };
|
||||
export const CUP_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"cup", subjectName:`Cup`, subjectEmoji:`🥤`, emoji:`🥤`, order:13, sublevel:132, slug:"cup-detail", title:`Cup \u00b7 Details`, subject:`cup details`, intro:`Now add the details that make your cup fun.`, badgeKey:"early-beginner-13-cup", badgeName:`Cup Champ`, phase:"detail", baseSvg:CUP_OUTLINE_SVG, steps:CUP_DETAIL_STEPS };
|
||||
export const CUP_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"cup", subjectName:`Cup`, subjectEmoji:`🥤`, emoji:`🥤`, order:13, sublevel:133, slug:"cup-extra", title:`Cup \u00b7 Color it!`, subject:`color the cup`, intro:`Bring your cup to life! Color it in.`, badgeKey:"", badgeName:`Cup Champ`, phase:"extra", baseSvg:CUP_DETAIL_SVG, steps:[] };
|
||||
|
||||
const TROPHY_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The cup`, instruction:`Draw the trophy bowl.`, lines:[`<path d="M162 92 L238 92 L230 150 C 230 176 170 176 170 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Stem & base`, instruction:`Add the stem and a base.`, lines:[`<path d="M194 172 L206 172 L206 200 L194 200 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M168 200 L232 200 L238 222 L162 222 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TROPHY_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Handles & star`, instruction:`Add two handles and a number 1.`, tip:`You're a winner!`, lines:[`<path d="M162 102 C 132 102 132 146 164 146" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M238 102 C 268 102 268 146 236 146" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 110 L200 140" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M200 110 L192 118" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TROPHY_OUTLINE_SVG = TROPHY_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const TROPHY_DETAIL_SVG = TROPHY_OUTLINE_SVG + "\n" + TROPHY_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const TROPHY_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"trophy", subjectName:`Trophy`, subjectEmoji:`🏆`, emoji:`🏆`, order:14, sublevel:141, slug:"trophy-outline", title:`Trophy \u00b7 Outline`, subject:`trophy outline`, intro:`Let\u2019s draw Trophy\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Trophy Winner`, phase:"outline", steps:TROPHY_OUTLINE_STEPS };
|
||||
export const TROPHY_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"trophy", subjectName:`Trophy`, subjectEmoji:`🏆`, emoji:`🏆`, order:14, sublevel:142, slug:"trophy-detail", title:`Trophy \u00b7 Details`, subject:`trophy details`, intro:`Now add the details that make your trophy fun.`, badgeKey:"early-beginner-14-trophy", badgeName:`Trophy Winner`, phase:"detail", baseSvg:TROPHY_OUTLINE_SVG, steps:TROPHY_DETAIL_STEPS };
|
||||
export const TROPHY_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"trophy", subjectName:`Trophy`, subjectEmoji:`🏆`, emoji:`🏆`, order:14, sublevel:143, slug:"trophy-extra", title:`Trophy \u00b7 Color it!`, subject:`color the trophy`, intro:`Bring your trophy to life! Color it in.`, badgeKey:"", badgeName:`Trophy Winner`, phase:"extra", baseSvg:TROPHY_DETAIL_SVG, steps:[] };
|
||||
|
||||
const SHIRT_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The body`, instruction:`Draw the shirt body with sleeves.`, lines:[`<path d="M160 110 L240 110 L282 146 L252 176 L240 160 L240 236 L160 236 L160 160 L148 176 L118 146 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The neck`, instruction:`Add a round neckline.`, lines:[`<path d="M180 110 q20 22 40 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SHIRT_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Collar & pocket`, instruction:`Add a collar and a little pocket.`, lines:[`<path d="M184 113 L200 128 L216 113" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M176 168 L196 168 L196 188 L176 188 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SHIRT_OUTLINE_SVG = SHIRT_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const SHIRT_DETAIL_SVG = SHIRT_OUTLINE_SVG + "\n" + SHIRT_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const SHIRT_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"shirt", subjectName:`Shirt`, subjectEmoji:`👕`, emoji:`👕`, order:15, sublevel:151, slug:"shirt-outline", title:`Shirt \u00b7 Outline`, subject:`shirt outline`, intro:`Let\u2019s draw Shirt\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Cool Shirt`, phase:"outline", steps:SHIRT_OUTLINE_STEPS };
|
||||
export const SHIRT_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"shirt", subjectName:`Shirt`, subjectEmoji:`👕`, emoji:`👕`, order:15, sublevel:152, slug:"shirt-detail", title:`Shirt \u00b7 Details`, subject:`shirt details`, intro:`Now add the details that make your shirt fun.`, badgeKey:"early-beginner-15-shirt", badgeName:`Cool Shirt`, phase:"detail", baseSvg:SHIRT_OUTLINE_SVG, steps:SHIRT_DETAIL_STEPS };
|
||||
export const SHIRT_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"shirt", subjectName:`Shirt`, subjectEmoji:`👕`, emoji:`👕`, order:15, sublevel:153, slug:"shirt-extra", title:`Shirt \u00b7 Color it!`, subject:`color the shirt`, intro:`Bring your shirt to life! Color it in.`, badgeKey:"", badgeName:`Cool Shirt`, phase:"extra", baseSvg:SHIRT_DETAIL_SVG, steps:[] };
|
||||
|
||||
const DRESS_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The top`, instruction:`Draw the dress top.`, lines:[`<path d="M168 112 L232 112 L248 152 L230 162 L170 162 L152 152 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The skirt`, instruction:`Add a flowy skirt.`, lines:[`<path d="M170 162 L130 236 L270 236 L230 162 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const DRESS_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Straps & waist`, instruction:`Add straps and a waistline.`, lines:[`<path d="M182 112 L188 96" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M218 112 L212 96" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M170 162 L230 162" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const DRESS_OUTLINE_SVG = DRESS_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const DRESS_DETAIL_SVG = DRESS_OUTLINE_SVG + "\n" + DRESS_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const DRESS_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"dress", subjectName:`Dress`, subjectEmoji:`👗`, emoji:`👗`, order:16, sublevel:161, slug:"dress-outline", title:`Dress \u00b7 Outline`, subject:`dress outline`, intro:`Let\u2019s draw Dress\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Pretty Dress`, phase:"outline", steps:DRESS_OUTLINE_STEPS };
|
||||
export const DRESS_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"dress", subjectName:`Dress`, subjectEmoji:`👗`, emoji:`👗`, order:16, sublevel:162, slug:"dress-detail", title:`Dress \u00b7 Details`, subject:`dress details`, intro:`Now add the details that make your dress fun.`, badgeKey:"early-beginner-16-dress", badgeName:`Pretty Dress`, phase:"detail", baseSvg:DRESS_OUTLINE_SVG, steps:DRESS_DETAIL_STEPS };
|
||||
export const DRESS_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"dress", subjectName:`Dress`, subjectEmoji:`👗`, emoji:`👗`, order:16, sublevel:163, slug:"dress-extra", title:`Dress \u00b7 Color it!`, subject:`color the dress`, intro:`Bring your dress to life! Color it in.`, badgeKey:"", badgeName:`Pretty Dress`, phase:"extra", baseSvg:DRESS_DETAIL_SVG, steps:[] };
|
||||
|
||||
const SOCKS_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The sock`, instruction:`Draw an L-shaped sock.`, lines:[`<path d="M168 80 L212 80 L212 178 L252 178 L252 222 L168 222 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SOCKS_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Cuff & stripes`, instruction:`Add a folded cuff and stripes.`, lines:[`<path d="M168 104 L212 104" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M172 116 L208 116" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M172 128 L208 128" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M232 178 L232 222" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const SOCKS_OUTLINE_SVG = SOCKS_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const SOCKS_DETAIL_SVG = SOCKS_OUTLINE_SVG + "\n" + SOCKS_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const SOCKS_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"socks", subjectName:`Socks`, subjectEmoji:`🧦`, emoji:`🧦`, order:17, sublevel:171, slug:"socks-outline", title:`Socks \u00b7 Outline`, subject:`socks outline`, intro:`Let\u2019s draw Socks\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Sock Star`, phase:"outline", steps:SOCKS_OUTLINE_STEPS };
|
||||
export const SOCKS_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"socks", subjectName:`Socks`, subjectEmoji:`🧦`, emoji:`🧦`, order:17, sublevel:172, slug:"socks-detail", title:`Socks \u00b7 Details`, subject:`socks details`, intro:`Now add the details that make your socks fun.`, badgeKey:"early-beginner-17-socks", badgeName:`Sock Star`, phase:"detail", baseSvg:SOCKS_OUTLINE_SVG, steps:SOCKS_DETAIL_STEPS };
|
||||
export const SOCKS_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"socks", subjectName:`Socks`, subjectEmoji:`🧦`, emoji:`🧦`, order:17, sublevel:173, slug:"socks-extra", title:`Socks \u00b7 Color it!`, subject:`color the socks`, intro:`Bring your socks to life! Color it in.`, badgeKey:"", badgeName:`Sock Star`, phase:"extra", baseSvg:SOCKS_DETAIL_SVG, steps:[] };
|
||||
|
||||
const EYES_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Two eyes`, instruction:`Draw two almond shapes, one eye apart.`, lines:[`<path d="M120 150 Q160 122 200 150 Q160 178 120 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M210 150 Q250 122 290 150 Q250 178 210 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const EYES_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Look alive`, instruction:`Add round eyeballs and dots, plus eyebrows.`, tip:`Eyes sit halfway!`, lines:[`<circle cx="160" cy="150" r="14" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="160" cy="150" r="7" fill="#2b2440"/>`,`<circle cx="250" cy="150" r="14" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="250" cy="150" r="7" fill="#2b2440"/>`,`<path d="M120 118 Q160 106 200 118" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M210 118 Q250 106 290 118" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const EYES_OUTLINE_SVG = EYES_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const EYES_DETAIL_SVG = EYES_OUTLINE_SVG + "\n" + EYES_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const EYES_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"eyes", subjectName:`Eyes`, subjectEmoji:`👀`, emoji:`👀`, order:18, sublevel:181, slug:"eyes-outline", title:`Eyes \u00b7 Outline`, subject:`eyes outline`, intro:`Let\u2019s draw Eyes\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Bright Eyes`, phase:"outline", steps:EYES_OUTLINE_STEPS };
|
||||
export const EYES_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"eyes", subjectName:`Eyes`, subjectEmoji:`👀`, emoji:`👀`, order:18, sublevel:182, slug:"eyes-detail", title:`Eyes \u00b7 Details`, subject:`eyes details`, intro:`Now add the details that make your eyes fun.`, badgeKey:"early-beginner-18-eyes", badgeName:`Bright Eyes`, phase:"detail", baseSvg:EYES_OUTLINE_SVG, steps:EYES_DETAIL_STEPS };
|
||||
export const EYES_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"eyes", subjectName:`Eyes`, subjectEmoji:`👀`, emoji:`👀`, order:18, sublevel:183, slug:"eyes-extra", title:`Eyes \u00b7 Color it!`, subject:`color the eyes`, intro:`Bring your eyes to life! Color it in.`, badgeKey:"", badgeName:`Bright Eyes`, phase:"extra", baseSvg:EYES_DETAIL_SVG, steps:[] };
|
||||
|
||||
const FACE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`The head`, instruction:`Draw a big round head.`, lines:[`<circle cx="200" cy="152" r="95" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`The ears`, instruction:`Add an ear on each side.`, lines:[`<path d="M108 150 q-20 2 -16 24 q3 16 18 12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M292 150 q20 2 16 24 q-3 16 -18 12" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FACE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n:1, title:`Eyes & nose`, instruction:`Add two eyes and a little nose.`, tip:`Eyes in the middle!`, lines:[`<circle cx="170" cy="140" r="11" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="170" cy="140" r="5" fill="#2b2440"/>`,`<circle cx="230" cy="140" r="11" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<circle cx="230" cy="140" r="5" fill="#2b2440"/>`,`<path d="M200 150 L200 174 q-8 4 -14 -1" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n:2, title:`Smile & hair`, instruction:`Add a big smile and some hair.`, lines:[`<path d="M166 190 q34 28 68 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,`<path d="M118 108 Q200 44 282 108" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FACE_OUTLINE_SVG = FACE_OUTLINE_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
const FACE_DETAIL_SVG = FACE_OUTLINE_SVG + "\n" + FACE_DETAIL_STEPS.flatMap(x=>x.lines||[]).join("\n");
|
||||
export const FACE_OUTLINE_LESSON: Lesson = { level:"early-beginner", subjectKey:"face", subjectName:`Face`, subjectEmoji:`🙂`, emoji:`🙂`, order:19, sublevel:191, slug:"face-outline", title:`Face \u00b7 Outline`, subject:`face outline`, intro:`Let\u2019s draw Face\u2019s outline, one line at a time. Watch each line, then trace it!`, badgeKey:"", badgeName:`Friendly Face`, phase:"outline", steps:FACE_OUTLINE_STEPS };
|
||||
export const FACE_DETAIL_LESSON: Lesson = { level:"early-beginner", subjectKey:"face", subjectName:`Face`, subjectEmoji:`🙂`, emoji:`🙂`, order:19, sublevel:192, slug:"face-detail", title:`Face \u00b7 Details`, subject:`face details`, intro:`Now add the details that make your face fun.`, badgeKey:"early-beginner-19-face", badgeName:`Friendly Face`, phase:"detail", baseSvg:FACE_OUTLINE_SVG, steps:FACE_DETAIL_STEPS };
|
||||
export const FACE_EXTRA_LESSON: Lesson = { level:"early-beginner", subjectKey:"face", subjectName:`Face`, subjectEmoji:`🙂`, emoji:`🙂`, order:19, sublevel:193, slug:"face-extra", title:`Face \u00b7 Color it!`, subject:`color the face`, intro:`Bring your face to life! Color it in.`, badgeKey:"", badgeName:`Friendly Face`, phase:"extra", baseSvg:FACE_DETAIL_SVG, steps:[] };
|
||||
|
||||
export const EB2_LESSONS: Lesson[] = [
|
||||
SQUARE_OUTLINE_LESSON,
|
||||
SQUARE_DETAIL_LESSON,
|
||||
SQUARE_EXTRA_LESSON,
|
||||
TRIANGLE_OUTLINE_LESSON,
|
||||
TRIANGLE_DETAIL_LESSON,
|
||||
TRIANGLE_EXTRA_LESSON,
|
||||
HEART_OUTLINE_LESSON,
|
||||
HEART_DETAIL_LESSON,
|
||||
HEART_EXTRA_LESSON,
|
||||
BOW_TIE_OUTLINE_LESSON,
|
||||
BOW_TIE_DETAIL_LESSON,
|
||||
BOW_TIE_EXTRA_LESSON,
|
||||
FOOTBALL_OUTLINE_LESSON,
|
||||
FOOTBALL_DETAIL_LESSON,
|
||||
FOOTBALL_EXTRA_LESSON,
|
||||
BASKETBALL_OUTLINE_LESSON,
|
||||
BASKETBALL_DETAIL_LESSON,
|
||||
BASKETBALL_EXTRA_LESSON,
|
||||
CUP_OUTLINE_LESSON,
|
||||
CUP_DETAIL_LESSON,
|
||||
CUP_EXTRA_LESSON,
|
||||
TROPHY_OUTLINE_LESSON,
|
||||
TROPHY_DETAIL_LESSON,
|
||||
TROPHY_EXTRA_LESSON,
|
||||
SHIRT_OUTLINE_LESSON,
|
||||
SHIRT_DETAIL_LESSON,
|
||||
SHIRT_EXTRA_LESSON,
|
||||
DRESS_OUTLINE_LESSON,
|
||||
DRESS_DETAIL_LESSON,
|
||||
DRESS_EXTRA_LESSON,
|
||||
SOCKS_OUTLINE_LESSON,
|
||||
SOCKS_DETAIL_LESSON,
|
||||
SOCKS_EXTRA_LESSON,
|
||||
EYES_OUTLINE_LESSON,
|
||||
EYES_DETAIL_LESSON,
|
||||
EYES_EXTRA_LESSON,
|
||||
FACE_OUTLINE_LESSON,
|
||||
FACE_DETAIL_LESSON,
|
||||
FACE_EXTRA_LESSON
|
||||
];
|
||||
|
||||
+252
-191
@@ -1,191 +1,252 @@
|
||||
/**
|
||||
* DrawIt curriculum. Each Early Beginner subject is a 3-lesson set: Outline -> Details -> Color it.
|
||||
* Outline/Detail steps use per-step `lines` (coloring-book strokes drawn one at a time). The Detail
|
||||
* lesson awards the subject badge (it is gated behind Outline). `mode "shade"` = shading studio.
|
||||
*/
|
||||
export interface LevelMeta { key: string; name: string; emoji: string; blurb: string; }
|
||||
export const LEVELS: LevelMeta[] = [
|
||||
{ key: "early-beginner", name: "Early Beginner", emoji: "\u{1F331}", blurb: "First lines and simple shapes. Perfect for brand-new artists." },
|
||||
{ key: "beginner", name: "Beginner", emoji: "\u270F\uFE0F", blurb: "Combine shapes into friendly characters and objects." },
|
||||
{ key: "learner", name: "Learner", emoji: "\u{1F3A8}", blurb: "Add details, shading, and your own ideas." },
|
||||
{ key: "advanced-learned", name: "Advanced Learner", emoji: "\u{1F680}", blurb: "Proportion, perspective, and more complex scenes." },
|
||||
{ key: "superb", name: "Superb", emoji: "\u{1F31F}", blurb: "Confident, expressive drawing in your own style." },
|
||||
];
|
||||
export function getLevel(key: string): LevelMeta | undefined { return LEVELS.find((l) => l.key === key); }
|
||||
export type StepKind = "construct" | "outline" | "color" | "shade" | "detail";
|
||||
export type Phase = "outline" | "detail" | "extra";
|
||||
export interface LessonStep { n: number; title: string; instruction: string; tip?: string; kind?: StepKind; svg?: string; lines?: string[]; }
|
||||
export interface Lesson {
|
||||
level: string; sublevel: number; slug: string; title: string; subject: string; emoji: string; intro: string;
|
||||
badgeKey: string; badgeName: string; mode?: "draw" | "shade"; phase?: Phase; baseSvg?: string;
|
||||
subjectKey?: string; subjectName?: string; subjectEmoji?: string; order?: number; steps: LessonStep[];
|
||||
}
|
||||
|
||||
|
||||
const FISH_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The body", instruction: "Draw the big rounded body — loop all the way around.", tip: "Slow and smooth, like an egg.", lines: [`<path d="M85 150 C 95 95, 180 78, 252 96 C 292 107, 306 128, 306 150 C 306 172, 292 193, 252 204 C 180 222, 95 205, 85 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "The tail", instruction: "Add the fan-shaped tail at the back.", lines: [`<path d="M300 150 C 332 130, 360 112, 390 100 C 374 130, 374 170, 390 200 C 360 188, 332 170, 300 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The fins", instruction: "Draw a fin on the back, then one under the belly.", lines: [`<path d="M150 92 C 168 62, 212 60, 240 84" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M150 178 C 168 208, 206 210, 228 196" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FISH_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The eye", instruction: "Draw a round eye, then a little dot inside.", lines: [`<path d="M147 134 a15 15 0 1 0 0.1 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<circle cx="129" cy="136" r="6" fill="#2b2440"/>`] },
|
||||
{ n: 2, title: "The mouth", instruction: "Add a small smile at the front.", lines: [`<path d="M90 162 q15 13 32 4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The gill", instruction: "Add a curved gill line behind the head.", lines: [`<path d="M160 104 q-16 46 0 92" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 4, title: "Stripes", instruction: "Add a couple of curved stripes across the body.", tip: "Now it's ready to color!", lines: [`<path d="M205 96 q-20 54 4 110" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M240 100 q-16 50 4 100" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FISH_OUTLINE_SVG = FISH_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const FISH_DETAIL_SVG = FISH_OUTLINE_SVG + "\n" + FISH_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const FISH_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:11, slug:"fish-outline", title:"Fish \u00b7 Outline", subject:"fish outline", emoji:"🐟", intro:"Let\u2019s draw Fish\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Fish Friend", phase:"outline", subjectKey:"fish", subjectName:"Fish", subjectEmoji:"🐟", order:1, steps: FISH_OUTLINE_STEPS };
|
||||
|
||||
export const FISH_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:12, slug:"fish-detail", title:"Fish \u00b7 Details", subject:"fish details", emoji:"🐟", intro:"Now add the details that bring your fish to life.", badgeKey:"early-beginner-1-fish", badgeName:"Fish Friend", phase:"detail", baseSvg: FISH_OUTLINE_SVG, subjectKey:"fish", subjectName:"Fish", subjectEmoji:"🐟", order:1, steps: FISH_DETAIL_STEPS };
|
||||
|
||||
export const FISH_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:13, slug:"fish-extra", title:"Fish \u00b7 Color it!", subject:"color the fish", emoji:"\u{1F3A8}", intro:"Bring your fish to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Fish Friend", phase:"extra", baseSvg: FISH_DETAIL_SVG, subjectKey:"fish", subjectName:"Fish", subjectEmoji:"🐟", order:1, steps: [] };
|
||||
|
||||
const PANDA_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The head", instruction: "Draw one big round face.", tip: "Nice and round.", lines: [`<ellipse cx="200" cy="162" rx="92" ry="84" fill="none" stroke="#2b2440" stroke-width="4"/>`] },
|
||||
{ n: 2, title: "The ears", instruction: "Add two round ears on top.", lines: [`<circle cx="142" cy="92" r="30" fill="none" stroke="#2b2440" stroke-width="4"/>`, `<circle cx="258" cy="92" r="30" fill="none" stroke="#2b2440" stroke-width="4"/>`] },
|
||||
{ n: 3, title: "Eye patches", instruction: "Draw a leaf-shaped patch for each eye.", lines: [`<path d="M150 132 C 136 148, 140 178, 164 184 C 186 188, 196 166, 189 146 C 183 132, 164 124, 150 132 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M250 132 C 264 148, 260 178, 236 184 C 214 188, 204 166, 211 146 C 217 132, 236 124, 250 132 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const PANDA_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The eyes", instruction: "Add a circle and a dot inside each patch.", lines: [`<circle cx="168" cy="156" r="9" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="170" cy="158" r="4" fill="#2b2440"/>`, `<circle cx="232" cy="156" r="9" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="230" cy="158" r="4" fill="#2b2440"/>`] },
|
||||
{ n: 2, title: "The nose", instruction: "Draw a little rounded nose.", lines: [`<path d="M186 184 Q200 178 214 184 Q208 198 200 200 Q192 198 186 184 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The mouth", instruction: "Add a line down and two smile curves.", lines: [`<path d="M200 200 L200 210" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M200 210 q-13 12 -26 5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M200 210 q13 12 26 5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const PANDA_OUTLINE_SVG = PANDA_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const PANDA_DETAIL_SVG = PANDA_OUTLINE_SVG + "\n" + PANDA_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const PANDA_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:21, slug:"panda-outline", title:"Panda \u00b7 Outline", subject:"panda outline", emoji:"🐼", intro:"Let\u2019s draw Panda\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Panda Pal", phase:"outline", subjectKey:"panda", subjectName:"Panda", subjectEmoji:"🐼", order:2, steps: PANDA_OUTLINE_STEPS };
|
||||
|
||||
export const PANDA_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:22, slug:"panda-detail", title:"Panda \u00b7 Details", subject:"panda details", emoji:"🐼", intro:"Now add the details that bring your panda to life.", badgeKey:"early-beginner-2-panda", badgeName:"Panda Pal", phase:"detail", baseSvg: PANDA_OUTLINE_SVG, subjectKey:"panda", subjectName:"Panda", subjectEmoji:"🐼", order:2, steps: PANDA_DETAIL_STEPS };
|
||||
|
||||
export const PANDA_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:23, slug:"panda-extra", title:"Panda \u00b7 Color it!", subject:"color the panda", emoji:"\u{1F3A8}", intro:"Bring your panda to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Panda Pal", phase:"extra", baseSvg: PANDA_DETAIL_SVG, subjectKey:"panda", subjectName:"Panda", subjectEmoji:"🐼", order:2, steps: [] };
|
||||
|
||||
const FLOWER_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The center", instruction: "Draw a circle in the middle.", tip: "Leave room for petals.", lines: [`<circle cx="200" cy="118" r="34" fill="none" stroke="#2b2440" stroke-width="4"/>`] },
|
||||
{ n: 2, title: "Petals", instruction: "Add petals on top, bottom, left, and right.", lines: [`<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(0 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(90 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(180 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(270 200 118)"/>`] },
|
||||
{ n: 3, title: "More petals", instruction: "Tuck four more petals into the gaps.", lines: [`<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(45 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(135 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(225 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(315 200 118)"/>`] },
|
||||
{ n: 4, title: "The stem", instruction: "Draw a stem going down from the flower.", lines: [`<path d="M195 152 C 191 186, 205 214, 197 256 L203 256 C 211 214, 199 186, 205 152 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 5, title: "The leaves", instruction: "Add a leaf on each side of the stem.", lines: [`<path d="M200 198 C 168 184, 138 192, 150 218 C 162 240, 196 226, 200 198 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M200 226 C 232 214, 262 222, 250 246 C 238 266, 204 252, 200 226 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FLOWER_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "Seeds", instruction: "Dot some seeds in the middle.", lines: [`<circle cx="192" cy="112" r="4" fill="#2b2440"/>`, `<circle cx="208" cy="114" r="4" fill="#2b2440"/>`, `<circle cx="200" cy="124" r="4" fill="#2b2440"/>`, `<circle cx="190" cy="126" r="3" fill="#2b2440"/>`, `<circle cx="210" cy="126" r="3" fill="#2b2440"/>`] },
|
||||
{ n: 2, title: "Leaf veins", instruction: "Add a line down each leaf.", lines: [`<path d="M170 210 q20 4 28 14" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M232 240 q-18 2 -28 -10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "Center ring", instruction: "Draw a little ring inside the middle.", lines: [`<circle cx="200" cy="118" r="18" fill="none" stroke="#2b2440" stroke-width="3"/>`] },
|
||||
];
|
||||
|
||||
const FLOWER_OUTLINE_SVG = FLOWER_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const FLOWER_DETAIL_SVG = FLOWER_OUTLINE_SVG + "\n" + FLOWER_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const FLOWER_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:31, slug:"flower-outline", title:"Flower \u00b7 Outline", subject:"flower outline", emoji:"🌸", intro:"Let\u2019s draw Flower\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Flower Power", phase:"outline", subjectKey:"flower", subjectName:"Flower", subjectEmoji:"🌸", order:3, steps: FLOWER_OUTLINE_STEPS };
|
||||
|
||||
export const FLOWER_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:32, slug:"flower-detail", title:"Flower \u00b7 Details", subject:"flower details", emoji:"🌸", intro:"Now add the details that bring your flower to life.", badgeKey:"early-beginner-3-flower", badgeName:"Flower Power", phase:"detail", baseSvg: FLOWER_OUTLINE_SVG, subjectKey:"flower", subjectName:"Flower", subjectEmoji:"🌸", order:3, steps: FLOWER_DETAIL_STEPS };
|
||||
|
||||
export const FLOWER_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:33, slug:"flower-extra", title:"Flower \u00b7 Color it!", subject:"color the flower", emoji:"\u{1F3A8}", intro:"Bring your flower to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Flower Power", phase:"extra", baseSvg: FLOWER_DETAIL_SVG, subjectKey:"flower", subjectName:"Flower", subjectEmoji:"🌸", order:3, steps: [] };
|
||||
|
||||
const UNICORN_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The head", instruction: "Draw a rounded head.", lines: [`<path d="M120 152 C 120 96, 165 72, 200 72 C 235 72, 280 96, 280 152 C 280 202, 246 236, 200 240 C 154 236, 120 202, 120 152 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "The ears", instruction: "Add a pointy ear on each side.", lines: [`<path d="M150 98 C 138 72, 132 58, 150 60 C 166 64, 174 84, 180 96 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M250 98 C 262 72, 268 58, 250 60 C 234 64, 226 84, 220 96 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The horn", instruction: "Draw a tall horn on top.", lines: [`<path d="M200 80 L188 18 L212 80 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 4, title: "The mane", instruction: "Draw the flowing mane and a curl on the forehead.", lines: [`<path d="M256 112 C 320 122, 320 200, 268 250 C 292 196, 280 150, 250 142 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M176 92 C 165 110, 175 135, 196 130 C 184 118, 188 102, 200 96 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const UNICORN_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The eyes", instruction: "Add two big eyes.", lines: [`<ellipse cx="172" cy="158" rx="7" ry="10" fill="none" stroke="#2b2440" stroke-width="4"/>`, `<ellipse cx="228" cy="158" rx="7" ry="10" fill="none" stroke="#2b2440" stroke-width="4"/>`] },
|
||||
{ n: 2, title: "Nose & mouth", instruction: "Add two nostril dots and a smile.", lines: [`<circle cx="188" cy="210" r="3" fill="#2b2440"/>`, `<circle cx="214" cy="210" r="3" fill="#2b2440"/>`, `<path d="M186 224 q14 12 28 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "Horn swirls", instruction: "Add little swirl lines on the horn.", lines: [`<path d="M193 66 l14 -5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M196 50 l11 -4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M199 36 l8 -3" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 4, title: "Mane lines", instruction: "Add flowing lines in the mane.", lines: [`<path d="M268 130 C 300 150, 296 200, 262 232" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M258 140 C 286 160, 282 200, 256 226" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const UNICORN_OUTLINE_SVG = UNICORN_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const UNICORN_DETAIL_SVG = UNICORN_OUTLINE_SVG + "\n" + UNICORN_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const UNICORN_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:41, slug:"unicorn-outline", title:"Unicorn \u00b7 Outline", subject:"unicorn outline", emoji:"🦄", intro:"Let\u2019s draw Unicorn\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Unicorn Magic", phase:"outline", subjectKey:"unicorn", subjectName:"Unicorn", subjectEmoji:"🦄", order:4, steps: UNICORN_OUTLINE_STEPS };
|
||||
|
||||
export const UNICORN_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:42, slug:"unicorn-detail", title:"Unicorn \u00b7 Details", subject:"unicorn details", emoji:"🦄", intro:"Now add the details that bring your unicorn to life.", badgeKey:"early-beginner-4-unicorn", badgeName:"Unicorn Magic", phase:"detail", baseSvg: UNICORN_OUTLINE_SVG, subjectKey:"unicorn", subjectName:"Unicorn", subjectEmoji:"🦄", order:4, steps: UNICORN_DETAIL_STEPS };
|
||||
|
||||
export const UNICORN_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:43, slug:"unicorn-extra", title:"Unicorn \u00b7 Color it!", subject:"color the unicorn", emoji:"\u{1F3A8}", intro:"Bring your unicorn to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Unicorn Magic", phase:"extra", baseSvg: UNICORN_DETAIL_SVG, subjectKey:"unicorn", subjectName:"Unicorn", subjectEmoji:"🦄", order:4, steps: [] };
|
||||
|
||||
const TREE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The trunk", instruction: "Draw a tall trunk, wider at the bottom.", lines: [`<path d="M178 262 C 176 222, 184 200, 190 176 L210 176 C 216 200, 224 222, 222 262 C 214 266, 186 266, 178 262 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "Branches", instruction: "Add two branches reaching up.", lines: [`<path d="M198 180 C 190 152, 168 144, 152 134" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M204 180 C 214 152, 236 146, 252 136" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The treetop", instruction: "Draw a big bumpy treetop.", tip: "Nice and lumpy!", lines: [`<path d="M120 150 C 100 122, 122 96, 150 100 C 152 74, 200 62, 216 88 C 246 72, 286 92, 274 122 C 300 134, 292 168, 262 166 C 250 188, 150 188, 138 166 C 110 170, 104 152, 120 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TREE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "Apples", instruction: "Add a few round apples in the leaves.", lines: [`<circle cx="168" cy="118" r="7" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="228" cy="104" r="7" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="204" cy="150" r="7" fill="none" stroke="#2b2440" stroke-width="3"/>`] },
|
||||
{ n: 2, title: "Grass", instruction: "Add wavy grass along the bottom.", lines: [`<path d="M120 262 q14 -16 28 0 q14 -16 28 0 q14 -16 28 0 q14 -16 28 0 q14 -16 28 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "Bark", instruction: "Add a couple of bark lines on the trunk.", lines: [`<path d="M194 200 q4 24 0 56" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M206 200 q-3 24 0 54" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TREE_OUTLINE_SVG = TREE_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const TREE_DETAIL_SVG = TREE_OUTLINE_SVG + "\n" + TREE_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const TREE_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:51, slug:"tree-outline", title:"Tree \u00b7 Outline", subject:"tree outline", emoji:"🌳", intro:"Let\u2019s draw Tree\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Tree Top", phase:"outline", subjectKey:"tree", subjectName:"Tree", subjectEmoji:"🌳", order:5, steps: TREE_OUTLINE_STEPS };
|
||||
|
||||
export const TREE_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:52, slug:"tree-detail", title:"Tree \u00b7 Details", subject:"tree details", emoji:"🌳", intro:"Now add the details that bring your tree to life.", badgeKey:"early-beginner-5-tree", badgeName:"Tree Top", phase:"detail", baseSvg: TREE_OUTLINE_SVG, subjectKey:"tree", subjectName:"Tree", subjectEmoji:"🌳", order:5, steps: TREE_DETAIL_STEPS };
|
||||
|
||||
export const TREE_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:53, slug:"tree-extra", title:"Tree \u00b7 Color it!", subject:"color the tree", emoji:"\u{1F3A8}", intro:"Bring your tree to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Tree Top", phase:"extra", baseSvg: TREE_DETAIL_SVG, subjectKey:"tree", subjectName:"Tree", subjectEmoji:"🌳", order:5, steps: [] };
|
||||
|
||||
const DINO_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The body", instruction: "Draw a big round body.", lines: [`<path d="M118 200 C 118 166, 160 150, 210 150 C 270 150, 302 170, 302 200 C 302 230, 262 246, 205 246 C 155 246, 118 230, 118 200 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "The neck", instruction: "Sweep a long neck up from the left.", lines: [`<path d="M150 168 C 122 122, 112 80, 124 55 C 130 44, 148 46, 152 58 C 146 86, 162 124, 180 160 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The head", instruction: "Add a small head on top of the neck.", lines: [`<path d="M100 46 C 100 32, 120 28, 138 34 C 152 39, 154 56, 142 64 C 126 72, 104 64, 98 54 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 4, title: "The tail", instruction: "Draw a long tail swooping to the right.", lines: [`<path d="M298 198 C 338 184, 372 176, 398 168 C 380 192, 360 206, 304 220 C 300 213, 298 206, 298 198 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 5, title: "The legs", instruction: "Add two sturdy legs underneath.", lines: [`<path d="M168 242 C 165 264, 166 278, 180 280 C 193 280, 192 264, 190 242 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M232 244 C 229 266, 230 280, 244 282 C 257 282, 256 266, 254 244 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const DINO_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "Back plates", instruction: "Add triangle plates along the back.", lines: [`<path d="M150 150 l10 -16 l12 16 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M186 146 l11 -17 l12 17 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M224 147 l11 -16 l12 16 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M262 152 l10 -15 l12 16 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "Spots", instruction: "Add a few spots on the body.", lines: [`<circle cx="205" cy="188" r="9" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="240" cy="200" r="7" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="180" cy="212" r="6" fill="none" stroke="#2b2440" stroke-width="3"/>`] },
|
||||
{ n: 3, title: "The face", instruction: "Add an eye and a friendly smile.", lines: [`<circle cx="118" cy="46" r="4" fill="#2b2440"/>`, `<path d="M108 56 q12 10 24 3" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const DINO_OUTLINE_SVG = DINO_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const DINO_DETAIL_SVG = DINO_OUTLINE_SVG + "\n" + DINO_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const DINO_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:61, slug:"dinosaur-outline", title:"Dinosaur \u00b7 Outline", subject:"dinosaur outline", emoji:"🦕", intro:"Let\u2019s draw Dinosaur\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Dino Master", phase:"outline", subjectKey:"dinosaur", subjectName:"Dinosaur", subjectEmoji:"🦕", order:6, steps: DINO_OUTLINE_STEPS };
|
||||
|
||||
export const DINO_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:62, slug:"dinosaur-detail", title:"Dinosaur \u00b7 Details", subject:"dinosaur details", emoji:"🦕", intro:"Now add the details that bring your dinosaur to life.", badgeKey:"early-beginner-6-dinosaur", badgeName:"Dino Master", phase:"detail", baseSvg: DINO_OUTLINE_SVG, subjectKey:"dinosaur", subjectName:"Dinosaur", subjectEmoji:"🦕", order:6, steps: DINO_DETAIL_STEPS };
|
||||
|
||||
export const DINO_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:63, slug:"dinosaur-extra", title:"Dinosaur \u00b7 Color it!", subject:"color the dinosaur", emoji:"\u{1F3A8}", intro:"Bring your dinosaur to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Dino Master", phase:"extra", baseSvg: DINO_DETAIL_SVG, subjectKey:"dinosaur", subjectName:"Dinosaur", subjectEmoji:"🦕", order:6, steps: [] };
|
||||
|
||||
const SHADE_STEPS: LessonStep[] = [
|
||||
{ n: 1, kind: "shade", title: "How to hold your pencil", instruction: "Watch the demo above. Near the tip + press hard = DARK; high up + soft = LIGHT.", tip: "Try it in the air first!", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/>` },
|
||||
{ n: 2, kind: "shade", title: "Find the light", instruction: "The sun is up-left. The side facing it stays brightest — that bright spot is the highlight.", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><circle cx="178" cy="118" r="22" fill="none" stroke="#4f7cff" stroke-width="3" stroke-dasharray="6 6"/>` },
|
||||
{ n: 3, kind: "shade", title: "Soft shade all over", instruction: "Pick a light tone and softly shade the whole ball — leave the bright spot white.", tip: "Hold high and go easy.", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><circle cx="178" cy="118" r="22" fill="none" stroke="#4f7cff" stroke-width="3" stroke-dasharray="6 6"/>` },
|
||||
{ n: 4, kind: "shade", title: "Build up the shadow", instruction: "Pick a darker tone and press harder on the side away from the sun. Build it up slowly.", tip: "More passes = darker.", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><path d="M250 96 A84 84 0 0 1 250 204 A60 70 0 0 0 250 96 Z" fill="#2b2440" opacity="0.18"/>` },
|
||||
{ n: 5, kind: "shade", title: "Cast shadow & finish", instruction: "Shade the ground under the ball on the shadow side. Keep the highlight clean!", tip: "🌗", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><path d="M250 96 A84 84 0 0 1 250 204 A60 70 0 0 0 250 96 Z" fill="#2b2440" opacity="0.18"/><ellipse cx="262" cy="236" rx="78" ry="15" fill="#2b2440" opacity="0.18"/>` },
|
||||
];
|
||||
|
||||
export const SHADE_BALL_LESSON: Lesson = { level:"learner", sublevel:1, slug:"shade-ball", title:"Light & Shadow: Shade a Ball", subject:"a shaded ball", emoji:"\u{1F317}", intro:"Make a flat circle look like a round ball using light and shadow!", badgeKey:"learner-1-shading", badgeName:"Shading Star", mode:"shade", subjectKey:"shade-ball", subjectName:"Shade a Ball", subjectEmoji:"\u{1F317}", order:1, steps: SHADE_STEPS };
|
||||
|
||||
export const LESSONS: Lesson[] = [FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON, FISH_EXTRA_LESSON, PANDA_OUTLINE_LESSON, PANDA_DETAIL_LESSON, PANDA_EXTRA_LESSON, FLOWER_OUTLINE_LESSON, FLOWER_DETAIL_LESSON, FLOWER_EXTRA_LESSON, UNICORN_OUTLINE_LESSON, UNICORN_DETAIL_LESSON, UNICORN_EXTRA_LESSON, TREE_OUTLINE_LESSON, TREE_DETAIL_LESSON, TREE_EXTRA_LESSON, DINO_OUTLINE_LESSON, DINO_DETAIL_LESSON, DINO_EXTRA_LESSON, SHADE_BALL_LESSON];
|
||||
|
||||
export function getLesson(level: string, sublevel: number): Lesson | undefined { return LESSONS.find((l) => l.level === level && l.sublevel === sublevel); }
|
||||
export function getLessonBySlug(slug: string): Lesson | undefined { return LESSONS.find((l) => l.slug === slug); }
|
||||
export function cumulativeSvg(lesson: Lesson, upTo: number): string { return lesson.steps.filter((s) => s.n <= upTo).map((s) => s.svg || "").join("\n"); }
|
||||
export function cumulativeLines(lesson: Lesson, upTo: number): string { return lesson.steps.filter((s) => s.n <= upTo).flatMap((s) => s.lines || []).join("\n"); }
|
||||
export function getSubjects(level: string): { key: string; name: string; emoji: string; order: number; lessons: Lesson[] }[] {
|
||||
const inLevel = LESSONS.filter((l) => l.level === level);
|
||||
const map = new Map<string, { key: string; name: string; emoji: string; order: number; lessons: Lesson[] }>();
|
||||
for (const l of inLevel) {
|
||||
const key = l.subjectKey || l.slug;
|
||||
if (!map.has(key)) map.set(key, { key, name: l.subjectName || l.title, emoji: l.subjectEmoji || l.emoji, order: l.order ?? l.sublevel, lessons: [] });
|
||||
map.get(key)!.lessons.push(l);
|
||||
}
|
||||
const rank: Record<string, number> = { outline: 0, detail: 1, extra: 2 };
|
||||
for (const s of map.values()) s.lessons.sort((a, b) => (rank[a.phase ?? ""] ?? 0) - (rank[b.phase ?? ""] ?? 0));
|
||||
return [...map.values()].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
/**
|
||||
* DrawIt curriculum. Each Early Beginner subject is a 3-lesson set: Outline -> Details -> Color it.
|
||||
* Outline/Detail steps use per-step `lines` (coloring-book strokes drawn one at a time). The Detail
|
||||
* lesson awards the subject badge (it is gated behind Outline). `mode "shade"` = shading studio.
|
||||
*/
|
||||
export interface LevelMeta { key: string; name: string; emoji: string; blurb: string; }
|
||||
export const LEVELS: LevelMeta[] = [
|
||||
{ key: "early-beginner", name: "Early Beginner", emoji: "\u{1F331}", blurb: "First lines and simple shapes. Perfect for brand-new artists." },
|
||||
{ key: "beginner", name: "Beginner", emoji: "\u270F\uFE0F", blurb: "Combine shapes into friendly characters and objects." },
|
||||
{ key: "learner", name: "Learner", emoji: "\u{1F3A8}", blurb: "Add details, shading, and your own ideas." },
|
||||
{ key: "advanced-learned", name: "Advanced Learner", emoji: "\u{1F680}", blurb: "Proportion, perspective, and more complex scenes." },
|
||||
{ key: "superb", name: "Superb", emoji: "\u{1F31F}", blurb: "Confident, expressive drawing in your own style." },
|
||||
];
|
||||
export function getLevel(key: string): LevelMeta | undefined { return LEVELS.find((l) => l.key === key); }
|
||||
|
||||
// Levels the "Create" feature can target. Add a level key here once it's ready for AI-created lessons;
|
||||
// it will then appear in the Create level picker automatically. (Only Early Beginner + Beginner today.)
|
||||
export const CREATABLE_LEVELS = ["early-beginner", "beginner"];
|
||||
export function creatableLevels(): LevelMeta[] { return LEVELS.filter((l) => CREATABLE_LEVELS.includes(l.key)); }
|
||||
export type StepKind = "construct" | "outline" | "color" | "shade" | "detail";
|
||||
// Early Beginner uses outline/detail/extra; Beginner uses construct/outline/color/light.
|
||||
export type Phase = "construct" | "outline" | "detail" | "color" | "light" | "extra";
|
||||
export interface LessonStep { n: number; title: string; instruction: string; tip?: string; kind?: StepKind; svg?: string; lines?: string[]; }
|
||||
export interface Lesson {
|
||||
level: string; sublevel: number; slug: string; title: string; subject: string; emoji: string; intro: string;
|
||||
badgeKey: string; badgeName: string; mode?: "draw" | "shade"; phase?: Phase; baseSvg?: string;
|
||||
subjectKey?: string; subjectName?: string; subjectEmoji?: string; order?: number; packKey?: string; steps: LessonStep[];
|
||||
}
|
||||
|
||||
|
||||
const FISH_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The body", instruction: "Draw the big rounded body — loop all the way around.", tip: "Slow and smooth, like an egg.", lines: [`<path d="M85 150 C 95 95, 180 78, 252 96 C 292 107, 306 128, 306 150 C 306 172, 292 193, 252 204 C 180 222, 95 205, 85 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "The tail", instruction: "Add the fan-shaped tail at the back.", lines: [`<path d="M300 150 C 332 130, 360 112, 390 100 C 374 130, 374 170, 390 200 C 360 188, 332 170, 300 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The fins", instruction: "Draw a fin on the back, then one under the belly.", lines: [`<path d="M150 92 C 168 62, 212 60, 240 84" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M150 178 C 168 208, 206 210, 228 196" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FISH_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The eye", instruction: "Draw a round eye, then a little dot inside.", lines: [`<path d="M147 134 a15 15 0 1 0 0.1 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<circle cx="129" cy="136" r="6" fill="#2b2440"/>`] },
|
||||
{ n: 2, title: "The mouth", instruction: "Add a small smile at the front.", lines: [`<path d="M90 162 q15 13 32 4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The gill", instruction: "Add a curved gill line behind the head.", lines: [`<path d="M160 104 q-16 46 0 92" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 4, title: "Stripes", instruction: "Add a couple of curved stripes across the body.", tip: "Now it's ready to color!", lines: [`<path d="M205 96 q-20 54 4 110" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M240 100 q-16 50 4 100" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FISH_OUTLINE_SVG = FISH_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const FISH_DETAIL_SVG = FISH_OUTLINE_SVG + "\n" + FISH_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const FISH_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:11, slug:"fish-outline", title:"Fish \u00b7 Outline", subject:"fish outline", emoji:"🐟", intro:"Let\u2019s draw Fish\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Fish Friend", phase:"outline", subjectKey:"fish", subjectName:"Fish", subjectEmoji:"🐟", order:1, steps: FISH_OUTLINE_STEPS };
|
||||
|
||||
export const FISH_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:12, slug:"fish-detail", title:"Fish \u00b7 Details", subject:"fish details", emoji:"🐟", intro:"Now add the details that bring your fish to life.", badgeKey:"early-beginner-1-fish", badgeName:"Fish Friend", phase:"detail", baseSvg: FISH_OUTLINE_SVG, subjectKey:"fish", subjectName:"Fish", subjectEmoji:"🐟", order:1, steps: FISH_DETAIL_STEPS };
|
||||
|
||||
export const FISH_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:13, slug:"fish-extra", title:"Fish \u00b7 Color it!", subject:"color the fish", emoji:"\u{1F3A8}", intro:"Bring your fish to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Fish Friend", phase:"extra", baseSvg: FISH_DETAIL_SVG, subjectKey:"fish", subjectName:"Fish", subjectEmoji:"🐟", order:1, steps: [] };
|
||||
|
||||
const PANDA_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The head", instruction: "Draw one big round face.", tip: "Nice and round.", lines: [`<ellipse cx="200" cy="162" rx="92" ry="84" fill="none" stroke="#2b2440" stroke-width="4"/>`] },
|
||||
{ n: 2, title: "The ears", instruction: "Add two round ears on top.", lines: [`<circle cx="142" cy="92" r="30" fill="none" stroke="#2b2440" stroke-width="4"/>`, `<circle cx="258" cy="92" r="30" fill="none" stroke="#2b2440" stroke-width="4"/>`] },
|
||||
{ n: 3, title: "Eye patches", instruction: "Draw a leaf-shaped patch for each eye.", lines: [`<path d="M150 132 C 136 148, 140 178, 164 184 C 186 188, 196 166, 189 146 C 183 132, 164 124, 150 132 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M250 132 C 264 148, 260 178, 236 184 C 214 188, 204 166, 211 146 C 217 132, 236 124, 250 132 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const PANDA_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The eyes", instruction: "Add a circle and a dot inside each patch.", lines: [`<circle cx="168" cy="156" r="9" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="170" cy="158" r="4" fill="#2b2440"/>`, `<circle cx="232" cy="156" r="9" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="230" cy="158" r="4" fill="#2b2440"/>`] },
|
||||
{ n: 2, title: "The nose", instruction: "Draw a little rounded nose.", lines: [`<path d="M186 184 Q200 178 214 184 Q208 198 200 200 Q192 198 186 184 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The mouth", instruction: "Add a line down and two smile curves.", lines: [`<path d="M200 200 L200 210" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M200 210 q-13 12 -26 5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M200 210 q13 12 26 5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const PANDA_OUTLINE_SVG = PANDA_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const PANDA_DETAIL_SVG = PANDA_OUTLINE_SVG + "\n" + PANDA_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const PANDA_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:21, slug:"panda-outline", title:"Panda \u00b7 Outline", subject:"panda outline", emoji:"🐼", intro:"Let\u2019s draw Panda\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Panda Pal", phase:"outline", subjectKey:"panda", subjectName:"Panda", subjectEmoji:"🐼", order:2, steps: PANDA_OUTLINE_STEPS };
|
||||
|
||||
export const PANDA_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:22, slug:"panda-detail", title:"Panda \u00b7 Details", subject:"panda details", emoji:"🐼", intro:"Now add the details that bring your panda to life.", badgeKey:"early-beginner-2-panda", badgeName:"Panda Pal", phase:"detail", baseSvg: PANDA_OUTLINE_SVG, subjectKey:"panda", subjectName:"Panda", subjectEmoji:"🐼", order:2, steps: PANDA_DETAIL_STEPS };
|
||||
|
||||
export const PANDA_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:23, slug:"panda-extra", title:"Panda \u00b7 Color it!", subject:"color the panda", emoji:"\u{1F3A8}", intro:"Bring your panda to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Panda Pal", phase:"extra", baseSvg: PANDA_DETAIL_SVG, subjectKey:"panda", subjectName:"Panda", subjectEmoji:"🐼", order:2, steps: [] };
|
||||
|
||||
const FLOWER_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The center", instruction: "Draw a circle in the middle.", tip: "Leave room for petals.", lines: [`<circle cx="200" cy="118" r="34" fill="none" stroke="#2b2440" stroke-width="4"/>`] },
|
||||
{ n: 2, title: "Petals", instruction: "Add petals on top, bottom, left, and right.", lines: [`<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(0 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(90 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(180 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(270 200 118)"/>`] },
|
||||
{ n: 3, title: "More petals", instruction: "Tuck four more petals into the gaps.", lines: [`<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(45 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(135 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(225 200 118)"/>`, `<path d="M200 86 C 180 78, 176 52, 200 40 C 224 52, 220 78, 200 86 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linejoin="round" transform="rotate(315 200 118)"/>`] },
|
||||
{ n: 4, title: "The stem", instruction: "Draw a stem going down from the flower.", lines: [`<path d="M195 152 C 191 186, 205 214, 197 256 L203 256 C 211 214, 199 186, 205 152 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 5, title: "The leaves", instruction: "Add a leaf on each side of the stem.", lines: [`<path d="M200 198 C 168 184, 138 192, 150 218 C 162 240, 196 226, 200 198 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M200 226 C 232 214, 262 222, 250 246 C 238 266, 204 252, 200 226 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const FLOWER_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "Seeds", instruction: "Dot some seeds in the middle.", lines: [`<circle cx="192" cy="112" r="4" fill="#2b2440"/>`, `<circle cx="208" cy="114" r="4" fill="#2b2440"/>`, `<circle cx="200" cy="124" r="4" fill="#2b2440"/>`, `<circle cx="190" cy="126" r="3" fill="#2b2440"/>`, `<circle cx="210" cy="126" r="3" fill="#2b2440"/>`] },
|
||||
{ n: 2, title: "Leaf veins", instruction: "Add a line down each leaf.", lines: [`<path d="M170 210 q20 4 28 14" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M232 240 q-18 2 -28 -10" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "Center ring", instruction: "Draw a little ring inside the middle.", lines: [`<circle cx="200" cy="118" r="18" fill="none" stroke="#2b2440" stroke-width="3"/>`] },
|
||||
];
|
||||
|
||||
const FLOWER_OUTLINE_SVG = FLOWER_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const FLOWER_DETAIL_SVG = FLOWER_OUTLINE_SVG + "\n" + FLOWER_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const FLOWER_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:31, slug:"flower-outline", title:"Flower \u00b7 Outline", subject:"flower outline", emoji:"🌸", intro:"Let\u2019s draw Flower\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Flower Power", phase:"outline", subjectKey:"flower", subjectName:"Flower", subjectEmoji:"🌸", order:3, steps: FLOWER_OUTLINE_STEPS };
|
||||
|
||||
export const FLOWER_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:32, slug:"flower-detail", title:"Flower \u00b7 Details", subject:"flower details", emoji:"🌸", intro:"Now add the details that bring your flower to life.", badgeKey:"early-beginner-3-flower", badgeName:"Flower Power", phase:"detail", baseSvg: FLOWER_OUTLINE_SVG, subjectKey:"flower", subjectName:"Flower", subjectEmoji:"🌸", order:3, steps: FLOWER_DETAIL_STEPS };
|
||||
|
||||
export const FLOWER_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:33, slug:"flower-extra", title:"Flower \u00b7 Color it!", subject:"color the flower", emoji:"\u{1F3A8}", intro:"Bring your flower to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Flower Power", phase:"extra", baseSvg: FLOWER_DETAIL_SVG, subjectKey:"flower", subjectName:"Flower", subjectEmoji:"🌸", order:3, steps: [] };
|
||||
|
||||
const UNICORN_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The head", instruction: "Draw a rounded head.", lines: [`<path d="M120 152 C 120 96, 165 72, 200 72 C 235 72, 280 96, 280 152 C 280 202, 246 236, 200 240 C 154 236, 120 202, 120 152 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "The ears", instruction: "Add a pointy ear on each side.", lines: [`<path d="M150 98 C 138 72, 132 58, 150 60 C 166 64, 174 84, 180 96 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M250 98 C 262 72, 268 58, 250 60 C 234 64, 226 84, 220 96 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The horn", instruction: "Draw a tall horn on top.", lines: [`<path d="M200 80 L188 18 L212 80 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 4, title: "The mane", instruction: "Draw the flowing mane and a curl on the forehead.", lines: [`<path d="M256 112 C 320 122, 320 200, 268 250 C 292 196, 280 150, 250 142 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M176 92 C 165 110, 175 135, 196 130 C 184 118, 188 102, 200 96 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const UNICORN_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The eyes", instruction: "Add two big eyes.", lines: [`<ellipse cx="172" cy="158" rx="7" ry="10" fill="none" stroke="#2b2440" stroke-width="4"/>`, `<ellipse cx="228" cy="158" rx="7" ry="10" fill="none" stroke="#2b2440" stroke-width="4"/>`] },
|
||||
{ n: 2, title: "Nose & mouth", instruction: "Add two nostril dots and a smile.", lines: [`<circle cx="188" cy="210" r="3" fill="#2b2440"/>`, `<circle cx="214" cy="210" r="3" fill="#2b2440"/>`, `<path d="M186 224 q14 12 28 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "Horn swirls", instruction: "Add little swirl lines on the horn.", lines: [`<path d="M193 66 l14 -5" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M196 50 l11 -4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M199 36 l8 -3" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 4, title: "Mane lines", instruction: "Add flowing lines in the mane.", lines: [`<path d="M268 130 C 300 150, 296 200, 262 232" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M258 140 C 286 160, 282 200, 256 226" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const UNICORN_OUTLINE_SVG = UNICORN_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const UNICORN_DETAIL_SVG = UNICORN_OUTLINE_SVG + "\n" + UNICORN_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const UNICORN_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:41, slug:"unicorn-outline", title:"Unicorn \u00b7 Outline", subject:"unicorn outline", emoji:"🦄", intro:"Let\u2019s draw Unicorn\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Unicorn Magic", phase:"outline", subjectKey:"unicorn", subjectName:"Unicorn", subjectEmoji:"🦄", order:4, steps: UNICORN_OUTLINE_STEPS };
|
||||
|
||||
export const UNICORN_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:42, slug:"unicorn-detail", title:"Unicorn \u00b7 Details", subject:"unicorn details", emoji:"🦄", intro:"Now add the details that bring your unicorn to life.", badgeKey:"early-beginner-4-unicorn", badgeName:"Unicorn Magic", phase:"detail", baseSvg: UNICORN_OUTLINE_SVG, subjectKey:"unicorn", subjectName:"Unicorn", subjectEmoji:"🦄", order:4, steps: UNICORN_DETAIL_STEPS };
|
||||
|
||||
export const UNICORN_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:43, slug:"unicorn-extra", title:"Unicorn \u00b7 Color it!", subject:"color the unicorn", emoji:"\u{1F3A8}", intro:"Bring your unicorn to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Unicorn Magic", phase:"extra", baseSvg: UNICORN_DETAIL_SVG, subjectKey:"unicorn", subjectName:"Unicorn", subjectEmoji:"🦄", order:4, steps: [] };
|
||||
|
||||
const TREE_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The trunk", instruction: "Draw a tall trunk, wider at the bottom.", lines: [`<path d="M178 262 C 176 222, 184 200, 190 176 L210 176 C 216 200, 224 222, 222 262 C 214 266, 186 266, 178 262 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "Branches", instruction: "Add two branches reaching up.", lines: [`<path d="M198 180 C 190 152, 168 144, 152 134" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M204 180 C 214 152, 236 146, 252 136" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The treetop", instruction: "Draw a big bumpy treetop.", tip: "Nice and lumpy!", lines: [`<path d="M120 150 C 100 122, 122 96, 150 100 C 152 74, 200 62, 216 88 C 246 72, 286 92, 274 122 C 300 134, 292 168, 262 166 C 250 188, 150 188, 138 166 C 110 170, 104 152, 120 150 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TREE_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "Apples", instruction: "Add a few round apples in the leaves.", lines: [`<circle cx="168" cy="118" r="7" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="228" cy="104" r="7" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="204" cy="150" r="7" fill="none" stroke="#2b2440" stroke-width="3"/>`] },
|
||||
{ n: 2, title: "Grass", instruction: "Add wavy grass along the bottom.", lines: [`<path d="M120 262 q14 -16 28 0 q14 -16 28 0 q14 -16 28 0 q14 -16 28 0 q14 -16 28 0" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "Bark", instruction: "Add a couple of bark lines on the trunk.", lines: [`<path d="M194 200 q4 24 0 56" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M206 200 q-3 24 0 54" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const TREE_OUTLINE_SVG = TREE_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const TREE_DETAIL_SVG = TREE_OUTLINE_SVG + "\n" + TREE_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const TREE_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:51, slug:"tree-outline", title:"Tree \u00b7 Outline", subject:"tree outline", emoji:"🌳", intro:"Let\u2019s draw Tree\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Tree Top", phase:"outline", subjectKey:"tree", subjectName:"Tree", subjectEmoji:"🌳", order:5, steps: TREE_OUTLINE_STEPS };
|
||||
|
||||
export const TREE_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:52, slug:"tree-detail", title:"Tree \u00b7 Details", subject:"tree details", emoji:"🌳", intro:"Now add the details that bring your tree to life.", badgeKey:"early-beginner-5-tree", badgeName:"Tree Top", phase:"detail", baseSvg: TREE_OUTLINE_SVG, subjectKey:"tree", subjectName:"Tree", subjectEmoji:"🌳", order:5, steps: TREE_DETAIL_STEPS };
|
||||
|
||||
export const TREE_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:53, slug:"tree-extra", title:"Tree \u00b7 Color it!", subject:"color the tree", emoji:"\u{1F3A8}", intro:"Bring your tree to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Tree Top", phase:"extra", baseSvg: TREE_DETAIL_SVG, subjectKey:"tree", subjectName:"Tree", subjectEmoji:"🌳", order:5, steps: [] };
|
||||
|
||||
const DINO_OUTLINE_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "The body", instruction: "Draw a big round body.", lines: [`<path d="M118 200 C 118 166, 160 150, 210 150 C 270 150, 302 170, 302 200 C 302 230, 262 246, 205 246 C 155 246, 118 230, 118 200 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "The neck", instruction: "Sweep a long neck up from the left.", lines: [`<path d="M150 168 C 122 122, 112 80, 124 55 C 130 44, 148 46, 152 58 C 146 86, 162 124, 180 160 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 3, title: "The head", instruction: "Add a small head on top of the neck.", lines: [`<path d="M100 46 C 100 32, 120 28, 138 34 C 152 39, 154 56, 142 64 C 126 72, 104 64, 98 54 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 4, title: "The tail", instruction: "Draw a long tail swooping to the right.", lines: [`<path d="M298 198 C 338 184, 372 176, 398 168 C 380 192, 360 206, 304 220 C 300 213, 298 206, 298 198 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 5, title: "The legs", instruction: "Add two sturdy legs underneath.", lines: [`<path d="M168 242 C 165 264, 166 278, 180 280 C 193 280, 192 264, 190 242 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M232 244 C 229 266, 230 280, 244 282 C 257 282, 256 266, 254 244 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const DINO_DETAIL_STEPS: LessonStep[] = [
|
||||
{ n: 1, title: "Back plates", instruction: "Add triangle plates along the back.", lines: [`<path d="M150 150 l10 -16 l12 16 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M186 146 l11 -17 l12 17 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M224 147 l11 -16 l12 16 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="M262 152 l10 -15 l12 16 Z" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
{ n: 2, title: "Spots", instruction: "Add a few spots on the body.", lines: [`<circle cx="205" cy="188" r="9" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="240" cy="200" r="7" fill="none" stroke="#2b2440" stroke-width="3"/>`, `<circle cx="180" cy="212" r="6" fill="none" stroke="#2b2440" stroke-width="3"/>`] },
|
||||
{ n: 3, title: "The face", instruction: "Add an eye and a friendly smile.", lines: [`<circle cx="118" cy="46" r="4" fill="#2b2440"/>`, `<path d="M108 56 q12 10 24 3" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`] },
|
||||
];
|
||||
|
||||
const DINO_OUTLINE_SVG = DINO_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
const DINO_DETAIL_SVG = DINO_OUTLINE_SVG + "\n" + DINO_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n");
|
||||
|
||||
export const DINO_OUTLINE_LESSON: Lesson = { level:"early-beginner", sublevel:61, slug:"dinosaur-outline", title:"Dinosaur \u00b7 Outline", subject:"dinosaur outline", emoji:"🦕", intro:"Let\u2019s draw Dinosaur\u2019s outline, one line at a time. Watch each line, then trace it!", badgeKey:"", badgeName:"Dino Master", phase:"outline", subjectKey:"dinosaur", subjectName:"Dinosaur", subjectEmoji:"🦕", order:6, steps: DINO_OUTLINE_STEPS };
|
||||
|
||||
export const DINO_DETAIL_LESSON: Lesson = { level:"early-beginner", sublevel:62, slug:"dinosaur-detail", title:"Dinosaur \u00b7 Details", subject:"dinosaur details", emoji:"🦕", intro:"Now add the details that bring your dinosaur to life.", badgeKey:"early-beginner-6-dinosaur", badgeName:"Dino Master", phase:"detail", baseSvg: DINO_OUTLINE_SVG, subjectKey:"dinosaur", subjectName:"Dinosaur", subjectEmoji:"🦕", order:6, steps: DINO_DETAIL_STEPS };
|
||||
|
||||
export const DINO_EXTRA_LESSON: Lesson = { level:"early-beginner", sublevel:63, slug:"dinosaur-extra", title:"Dinosaur \u00b7 Color it!", subject:"color the dinosaur", emoji:"\u{1F3A8}", intro:"Bring your dinosaur to life! Color it in, and try shading and layering.", badgeKey:"", badgeName:"Dino Master", phase:"extra", baseSvg: DINO_DETAIL_SVG, subjectKey:"dinosaur", subjectName:"Dinosaur", subjectEmoji:"🦕", order:6, steps: [] };
|
||||
|
||||
const SHADE_STEPS: LessonStep[] = [
|
||||
{ n: 1, kind: "shade", title: "How to hold your pencil", instruction: "Watch the demo above. Near the tip + press hard = DARK; high up + soft = LIGHT.", tip: "Try it in the air first!", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/>` },
|
||||
{ n: 2, kind: "shade", title: "Find the light", instruction: "The sun is up-left. The side facing it stays brightest — that bright spot is the highlight.", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><circle cx="178" cy="118" r="22" fill="none" stroke="#4f7cff" stroke-width="3" stroke-dasharray="6 6"/>` },
|
||||
{ n: 3, kind: "shade", title: "Soft shade all over", instruction: "Pick a light tone and softly shade the whole ball — leave the bright spot white.", tip: "Hold high and go easy.", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><circle cx="178" cy="118" r="22" fill="none" stroke="#4f7cff" stroke-width="3" stroke-dasharray="6 6"/>` },
|
||||
{ n: 4, kind: "shade", title: "Build up the shadow", instruction: "Pick a darker tone and press harder on the side away from the sun. Build it up slowly.", tip: "More passes = darker.", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><path d="M250 96 A84 84 0 0 1 250 204 A60 70 0 0 0 250 96 Z" fill="#2b2440" opacity="0.18"/>` },
|
||||
{ n: 5, kind: "shade", title: "Cast shadow & finish", instruction: "Shade the ground under the ball on the shadow side. Keep the highlight clean!", tip: "🌗", svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><path d="M250 96 A84 84 0 0 1 250 204 A60 70 0 0 0 250 96 Z" fill="#2b2440" opacity="0.18"/><ellipse cx="262" cy="236" rx="78" ry="15" fill="#2b2440" opacity="0.18"/>` },
|
||||
];
|
||||
|
||||
export const SHADE_BALL_LESSON: Lesson = { level:"learner", sublevel:1, slug:"shade-ball", title:"Light & Shadow: Shade a Ball", subject:"a shaded ball", emoji:"\u{1F317}", intro:"Make a flat circle look like a round ball using light and shadow!", badgeKey:"learner-1-shading", badgeName:"Shading Star", mode:"shade", subjectKey:"shade-ball", subjectName:"Shade a Ball", subjectEmoji:"\u{1F317}", order:1, steps: SHADE_STEPS };
|
||||
|
||||
import { BEGINNER_LESSONS, BEGINNER_PACKS, type Pack } from "./curriculum.beginner";
|
||||
import { EB2_LESSONS } from "./curriculum.eb2";
|
||||
import { ANIMAL_LESSONS } from "./curriculum.animals";
|
||||
export { BEGINNER_PACKS, type Pack } from "./curriculum.beginner";
|
||||
export { LIGHT_HINT_SVG } from "./curriculum.beginner";
|
||||
|
||||
const EARLY_BEGINNER_LESSONS: Lesson[] = [FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON, FISH_EXTRA_LESSON, PANDA_OUTLINE_LESSON, PANDA_DETAIL_LESSON, PANDA_EXTRA_LESSON, FLOWER_OUTLINE_LESSON, FLOWER_DETAIL_LESSON, FLOWER_EXTRA_LESSON, UNICORN_OUTLINE_LESSON, UNICORN_DETAIL_LESSON, UNICORN_EXTRA_LESSON, TREE_OUTLINE_LESSON, TREE_DETAIL_LESSON, TREE_EXTRA_LESSON, DINO_OUTLINE_LESSON, DINO_DETAIL_LESSON, DINO_EXTRA_LESSON, ...EB2_LESSONS, ...ANIMAL_LESSONS];
|
||||
export const LESSONS: Lesson[] = [...EARLY_BEGINNER_LESSONS, ...BEGINNER_LESSONS, SHADE_BALL_LESSON];
|
||||
|
||||
export function getLesson(level: string, sublevel: number): Lesson | undefined { return LESSONS.find((l) => l.level === level && l.sublevel === sublevel); }
|
||||
export function getLessonBySlug(slug: string): Lesson | undefined { return LESSONS.find((l) => l.slug === slug); }
|
||||
export function cumulativeSvg(lesson: Lesson, upTo: number): string { return lesson.steps.filter((s) => s.n <= upTo).map((s) => s.svg || "").join("\n"); }
|
||||
export function cumulativeLines(lesson: Lesson, upTo: number): string { return lesson.steps.filter((s) => s.n <= upTo).flatMap((s) => s.lines || []).join("\n"); }
|
||||
export function getSubjects(level: string): { key: string; name: string; emoji: string; order: number; lessons: Lesson[] }[] {
|
||||
const inLevel = LESSONS.filter((l) => l.level === level);
|
||||
const map = new Map<string, { key: string; name: string; emoji: string; order: number; lessons: Lesson[] }>();
|
||||
for (const l of inLevel) {
|
||||
const key = l.subjectKey || l.slug;
|
||||
if (!map.has(key)) map.set(key, { key, name: l.subjectName || l.title, emoji: l.subjectEmoji || l.emoji, order: l.order ?? l.sublevel, lessons: [] });
|
||||
map.get(key)!.lessons.push(l);
|
||||
}
|
||||
// Covers both Early Beginner (outline/detail/extra) and Beginner (construct/outline/color/light).
|
||||
const rank: Record<string, number> = { construct: 0, outline: 1, detail: 2, color: 3, extra: 3, light: 4 };
|
||||
for (const s of map.values()) s.lessons.sort((a, b) => (rank[a.phase ?? ""] ?? 0) - (rank[b.phase ?? ""] ?? 0));
|
||||
return [...map.values()].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** Themed groups for browsing a level's subjects (Early Beginner). Keyed by subjectKey. */
|
||||
export interface SubjectGroup { key: string; name: string; emoji: string; subjectKeys: string[]; }
|
||||
export const EARLY_BEGINNER_GROUPS: SubjectGroup[] = [
|
||||
{ key: "shapes", name: "Simple Shapes", emoji: "⭐", subjectKeys: ["square", "triangle", "heart"] },
|
||||
{ key: "classics", name: "Animals & Nature", emoji: "\u{1F41F}", subjectKeys: ["fish", "panda", "flower", "unicorn", "tree", "dinosaur"] },
|
||||
{ key: "safari", name: "Safari Animals", emoji: "\u{1F981}", subjectKeys: ["lion", "elephant", "giraffe", "zebra", "rhino", "leopard", "tiger", "monkey", "crocodile"] },
|
||||
{ key: "woodland", name: "Woodland Critters", emoji: "\u{1F98A}", subjectKeys: ["fox", "bear", "deer", "rabbit", "hedgehog", "owl", "cat", "turtle"] },
|
||||
{ key: "air", name: "Up in the Air", emoji: "\u{1F98B}", subjectKeys: ["bird", "butterfly"] },
|
||||
{ key: "fun", name: "Fun Things", emoji: "\u{1F380}", subjectKeys: ["bow-tie", "football", "basketball", "cup", "trophy"] },
|
||||
{ key: "wear", name: "Things to Wear", emoji: "\u{1F455}", subjectKeys: ["shirt", "dress", "socks"] },
|
||||
{ key: "faces", name: "Faces & Features", emoji: "\u{1F642}", subjectKeys: ["eyes", "face"] },
|
||||
];
|
||||
|
||||
export type GroupedSubjects = { key: string; name: string; emoji: string; subjects: ReturnType<typeof getSubjects> }[];
|
||||
/** Bucket a level's subjects into ordered themed groups; anything unlisted falls into a "More" group. */
|
||||
export function getGroupedSubjects(level: string): GroupedSubjects {
|
||||
const subjects = getSubjects(level);
|
||||
const byKey = new Map(subjects.map((s) => [s.key, s]));
|
||||
const used = new Set<string>();
|
||||
const cfg = level === "early-beginner" ? EARLY_BEGINNER_GROUPS : [];
|
||||
const groups: GroupedSubjects = cfg
|
||||
.map((g) => {
|
||||
const subs = g.subjectKeys.map((k) => byKey.get(k)).filter(Boolean) as ReturnType<typeof getSubjects>;
|
||||
subs.forEach((s) => used.add(s.key));
|
||||
return { key: g.key, name: g.name, emoji: g.emoji, subjects: subs };
|
||||
})
|
||||
.filter((g) => g.subjects.length > 0);
|
||||
const rest = subjects.filter((s) => !used.has(s.key));
|
||||
if (rest.length) groups.push({ key: "more", name: "More", emoji: "✨", subjects: rest });
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** All badge definitions: per-subject (Early Beginner) + per-pack (Beginner). */
|
||||
export interface BadgeDef { badgeKey: string; name: string; emoji: string; groupKey: string; }
|
||||
export function badgeDefs(): BadgeDef[] {
|
||||
const fromLessons = LESSONS.filter((l) => l.badgeKey).map((l) => ({ badgeKey: l.badgeKey, name: l.badgeName, emoji: l.emoji, groupKey: `${l.level}-${l.sublevel}` }));
|
||||
const fromPacks = BEGINNER_PACKS.map((p) => ({ badgeKey: p.badgeKey, name: p.badgeName, emoji: p.emoji, groupKey: "" }));
|
||||
return [...fromLessons, ...fromPacks];
|
||||
}
|
||||
export function getPackByBadge(badgeKey: string): Pack | undefined { return BEGINNER_PACKS.find((p) => p.badgeKey === badgeKey); }
|
||||
/** The pack a lesson belongs to (Beginner only), via its packKey. */
|
||||
export function getPackForLesson(lesson: Lesson): Pack | undefined { return lesson.packKey ? BEGINNER_PACKS.find((p) => p.key === lesson.packKey) : undefined; }
|
||||
/** Every lesson that belongs to a pack (all phases of all its subjects). */
|
||||
export function lessonsInPack(pack: Pack): Lesson[] { return LESSONS.filter((l) => l.level === pack.level && l.subjectKey && pack.subjectKeys.includes(l.subjectKey)); }
|
||||
/** Number of completed steps needed to count a lesson as done. */
|
||||
export function lessonStepCount(l: Lesson): number { return l.steps.length || 1; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { LESSONS, lessonStepCount, lessonsInPack, type Lesson, type Pack } from "./curriculum";
|
||||
import { getCompletedSteps } from "./progress";
|
||||
|
||||
// A child must finish this many Early Beginner lessons before the Beginner level opens,
|
||||
// so they get a feel for how DrawIt works first.
|
||||
export const BEGINNER_UNLOCK_THRESHOLD = 5;
|
||||
|
||||
export function isLessonComplete(userId: number, l: Lesson): boolean {
|
||||
return getCompletedSteps(userId, l.level, l.sublevel).length >= lessonStepCount(l);
|
||||
}
|
||||
|
||||
/** How many lessons in a level the user has completed. */
|
||||
export function countCompletedLessons(userId: number, level: string): number {
|
||||
return LESSONS.filter((l) => l.level === level).filter((l) => isLessonComplete(userId, l)).length;
|
||||
}
|
||||
|
||||
/** Levels other than Early Beginner are gated; Beginner opens after 5 Early Beginner lessons. */
|
||||
export function isLevelUnlocked(userId: number | null, level: string): boolean {
|
||||
if (level === "early-beginner") return true;
|
||||
if (!userId) return false;
|
||||
if (level === "beginner") return countCompletedLessons(userId, "early-beginner") >= BEGINNER_UNLOCK_THRESHOLD;
|
||||
return true; // other levels have no lessons yet; leave them open
|
||||
}
|
||||
|
||||
/** All phases of all subjects in a pack are complete. */
|
||||
export function isPackComplete(userId: number, pack: Pack): boolean {
|
||||
const ls = lessonsInPack(pack);
|
||||
return ls.length > 0 && ls.every((l) => isLessonComplete(userId, l));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import "server-only";
|
||||
import { trace } from "potrace";
|
||||
|
||||
/**
|
||||
* Turn a raster coloring-book PNG (black lines on white) into simple line art split into the
|
||||
* Early-Beginner phases: a big-shapes OUTLINE, the leftover DETAILS, and the FULL image (color template).
|
||||
*
|
||||
* Key correctness points:
|
||||
* - potrace traces dark pixels into one filled path that uses `fill-rule: evenodd` so the thin lines
|
||||
* read correctly (interior "holes" are subtracted). We must keep each phase as ONE path — splitting
|
||||
* a path into separate filled subpaths makes the outer contour fill in solid (the old "solid blob").
|
||||
* - Outline vs details comes from two passes: a high `turdSize` pass drops small features (doors,
|
||||
* windows…) leaving the main outline; details = everything in the full pass that isn't in the outline.
|
||||
*/
|
||||
|
||||
const STROKE = "#2b2440";
|
||||
const VW = 400;
|
||||
const VH = 300;
|
||||
|
||||
interface Parsed {
|
||||
subs: string[]; // subpath "d" strings, each starting with M/m
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
function traceToSvg(png: Buffer, options: Record<string, unknown>): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
trace(png, { threshold: 128, ...options }, (err: Error | null, svg: string) => (err ? reject(err) : resolve(svg)));
|
||||
});
|
||||
}
|
||||
|
||||
function parse(svg: string): Parsed {
|
||||
const wM = svg.match(/width="(\d+(?:\.\d+)?)"/);
|
||||
const hM = svg.match(/height="(\d+(?:\.\d+)?)"/);
|
||||
const w = wM ? parseFloat(wM[1]) : VW;
|
||||
const h = hM ? parseFloat(hM[1]) : VH;
|
||||
const ds = [...svg.matchAll(/\bd="([^"]+)"/g)].map((m) => m[1]);
|
||||
const combined = ds.join(" ");
|
||||
const subs = combined
|
||||
.split(/(?=[Mm])/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return { subs, w, h };
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
export interface LessonArt {
|
||||
outline: string; // one filled <path> — the main outline (big shapes), for guides/reference
|
||||
details: string; // one filled <path> — interior details (may be "")
|
||||
full: string; // one filled <path> — the whole line art (color template)
|
||||
// Stroked, chunked versions for the "watch it drawn" animation (stroke-dashoffset pen effect).
|
||||
outlineStrokes: string[];
|
||||
detailStrokes: string[];
|
||||
}
|
||||
|
||||
export async function pngToLessonArt(png: Buffer): Promise<LessonArt> {
|
||||
// Full pass: keep almost everything (drop only tiny speckles).
|
||||
const full = parse(await traceToSvg(png, { turdSize: 12 }));
|
||||
if (full.subs.length === 0) throw new Error("Vectorize produced no paths (image may be blank or solid).");
|
||||
|
||||
// Outline pass: a turdSize scaled to the image drops small interior features, leaving the big outline.
|
||||
const outlineTurd = Math.max(200, Math.round((full.w * full.h) / 400));
|
||||
const outline = parse(await traceToSvg(png, { turdSize: outlineTurd }));
|
||||
|
||||
// Fit the source image into the 400×300 lesson canvas, centered (same transform for every phase so they align).
|
||||
const s = Math.min(VW / full.w, VH / full.h);
|
||||
const tx = round((VW - full.w * s) / 2);
|
||||
const ty = round((VH - full.h * s) / 2);
|
||||
const transform = `translate(${tx} ${ty}) scale(${round(s)})`;
|
||||
const wrap = (subs: string[]) =>
|
||||
subs.length ? `<path d="${subs.join(" ")}" transform="${transform}" fill="${STROKE}" fill-rule="evenodd"/>` : "";
|
||||
|
||||
// Stroke width chosen so it renders ~4px on the 400×300 canvas after the transform scale.
|
||||
const sw = Math.max(2, round(4 / s));
|
||||
// Split contours into a few stroked elements so the pen draws the shape in a handful of strokes.
|
||||
const chunkStroke = (subs: string[], maxSteps: number): string[] => {
|
||||
if (subs.length === 0) return [];
|
||||
const n = Math.min(maxSteps, subs.length);
|
||||
const per = Math.ceil(subs.length / n);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < subs.length; i += per) {
|
||||
const d = subs.slice(i, i + per).join(" ");
|
||||
out.push(`<path d="${d}" transform="${transform}" fill="none" stroke="${STROKE}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"/>`);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
let outlineSubs = outline.subs;
|
||||
const outlineSet = new Set(outlineSubs);
|
||||
let detailSubs = full.subs.filter((d) => !outlineSet.has(d));
|
||||
|
||||
// If the outline pass kept nothing (or everything), fall back so we always have a usable outline.
|
||||
if (outlineSubs.length === 0 || detailSubs.length === 0) {
|
||||
outlineSubs = full.subs;
|
||||
detailSubs = [];
|
||||
}
|
||||
|
||||
return {
|
||||
outline: wrap(outlineSubs),
|
||||
details: wrap(detailSubs),
|
||||
full: wrap(full.subs),
|
||||
outlineStrokes: chunkStroke(outlineSubs, 4),
|
||||
detailStrokes: chunkStroke(detailSubs, 3),
|
||||
};
|
||||
}
|
||||
Vendored
+8
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user