How DrawIt works
-
-
-
π£ Step by step
-
Each lesson breaks a picture into tiny, doable steps using only simple shapes.
-
-
-
π
Badges & awards
-
Finish a lesson to earn a shareable badge and a printable award certificate (PDF).
-
-
-
π Private by design
-
No analytics, no trackers, no data sold or shared. Just drawing.
-
+
+
βοΈ Outline β Details Trace the coloring-book outline, then add the details that bring it to life.
+
π¨ Color it in Finish with the coloring studio β color, shade, and make it your own.
+
π Private by design No analytics, no trackers, no data sold or shared. Just drawing.
-
-
- DrawIt Β· Free & open source Β· No data collected. Made for young artists. π¨
-
+
DrawIt Β· Free & open source Β· No data collected. Made for young artists. π¨
>
);
}
-function lessonCard(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 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 cornerTag(kind: "free" | "lock"): React.CSSProperties {
- return {
- position: "absolute",
- top: 12,
- right: 12,
- fontWeight: 800,
- fontSize: "0.8rem",
- padding: kind === "free" ? "3px 10px" : "3px 8px",
- borderRadius: 999,
- background: kind === "free" ? "#e8f7ee" : "#f0ebf7",
- color: kind === "free" ? "#1f7a45" : "var(--ink-soft)",
- };
+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)" };
}
diff --git a/src/components/ColoringRunner.tsx b/src/components/ColoringRunner.tsx
new file mode 100644
index 0000000..ccbd405
--- /dev/null
+++ b/src/components/ColoringRunner.tsx
@@ -0,0 +1,180 @@
+"use client";
+import { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL } from "./drawingPersistence";
+
+interface Meta {
+ level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string;
+}
+const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a", "#a06bff", "#ffffff", "#c4c8cc"];
+const SIZES = [10, 22, 40];
+interface Point { x: number; y: number }
+interface Stroke { color: string; size: number; alpha: number; erase: boolean; points: Point[] }
+
+export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned }: {
+ meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean;
+}) {
+ const [done, setDone] = useState(false);
+ const [finalImage, setFinalImage] = useState
(null);
+ const [uploadMsg, setUploadMsg] = useState("");
+ const uploadRef = useRef(null);
+
+ const canvasRef = useRef(null);
+ const stageRef = useRef(null);
+ const strokesRef = useRef([]);
+ const drawingRef = useRef(null);
+ const baseImgRef = useRef(null);
+ const saveTimer = useRef | undefined>(undefined);
+ const [color, setColor] = useState(COLORS[1]);
+ const [size, setSize] = useState(SIZES[1]);
+ const [opacity, setOpacity] = useState(1);
+ const [erasing, setErasing] = useState(false);
+ const [, force] = useState(0);
+
+ const redraw = useCallback(() => {
+ const c = canvasRef.current; if (!c) return;
+ const ctx = c.getContext("2d"); if (!ctx) return;
+ ctx.clearRect(0, 0, c.width, c.height);
+ ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
+ if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, c.width, c.height);
+ ctx.lineCap = "round"; ctx.lineJoin = "round";
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ for (const s of strokesRef.current) {
+ if (!s.points.length) continue;
+ ctx.globalCompositeOperation = s.erase ? "destination-out" : "source-over";
+ ctx.globalAlpha = s.erase ? 1 : s.alpha;
+ ctx.strokeStyle = s.color; ctx.lineWidth = s.size * dpr;
+ ctx.beginPath(); ctx.moveTo(s.points[0].x * c.width, s.points[0].y * c.height);
+ if (s.points.length === 1) ctx.lineTo(s.points[0].x * c.width + 0.1, s.points[0].y * c.height + 0.1);
+ else for (let i = 1; i < s.points.length; i++) ctx.lineTo(s.points[i].x * c.width, s.points[i].y * c.height);
+ ctx.stroke();
+ }
+ ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
+ }, []);
+ const resize = useCallback(() => {
+ const c = canvasRef.current, st = stageRef.current; if (!c || !st) return;
+ const r = st.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ c.width = Math.round(r.width * dpr); c.height = Math.round(r.height * dpr); redraw();
+ }, [redraw]);
+ useEffect(() => { resize(); window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize); }, [resize]);
+
+ useEffect(() => {
+ if (!loggedIn) return; let alive = true;
+ loadProgressDrawing(meta.level, meta.sublevel).then((img) => { if (!img || !alive) return; const im = new Image(); im.onload = () => { baseImgRef.current = im; redraw(); }; im.src = img; });
+ return () => { alive = false; };
+ }, [loggedIn, meta.level, meta.sublevel, redraw]);
+
+ function hasContent() { return strokesRef.current.length > 0 || !!baseImgRef.current; }
+ function flushSave() { if (!loggedIn) return; const c = canvasRef.current; if (!c || !hasContent()) return; saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c)); }
+ function scheduleSave() { if (!loggedIn) return; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(flushSave, 1200); }
+
+ function pe(e: React.PointerEvent): Point { const r = canvasRef.current!.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; }
+ function onDown(e: React.PointerEvent) {
+ (e.target as Element).setPointerCapture?.(e.pointerId);
+ const pressure = e.pressure && e.pressure > 0 ? e.pressure : 1;
+ const s: Stroke = { color, size, alpha: Math.min(1, opacity * (0.6 + pressure * 0.4)), erase: erasing, points: [pe(e)] };
+ drawingRef.current = s; strokesRef.current.push(s);
+ }
+ function onMove(e: React.PointerEvent) { const s = drawingRef.current; if (!s) return; s.points.push(pe(e)); redraw(); }
+ function onUp() { drawingRef.current = null; force((n) => n + 1); scheduleSave(); }
+ function clearCanvas() { strokesRef.current = []; baseImgRef.current = null; redraw(); force((n) => n + 1); if (loggedIn) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(canvasRef.current!)); }
+ function undo() { strokesRef.current.pop(); redraw(); force((n) => n + 1); scheduleSave(); }
+
+ async function finish() {
+ const c = canvasRef.current;
+ if (c && hasContent()) { const data = exportCanvasToDataURL(c); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); }
+ setDone(true);
+ }
+ async function onUpload(e: React.ChangeEvent) {
+ const file = e.target.files?.[0]; if (!file) return;
+ if (!loggedIn) { setUploadMsg("Log in to save to your gallery."); return; }
+ setUploadMsg("Uploadingβ¦");
+ try { const data = await fileToDataURL(file); const ok = await saveFinalDrawing(meta.level, meta.sublevel, "upload", data); setUploadMsg(ok ? "Saved to your gallery! πΌοΈ" : "Couldn't save that file."); }
+ catch { setUploadMsg("Couldn't read that image."); }
+ finally { if (uploadRef.current) uploadRef.current.value = ""; }
+ }
+
+ if (done) {
+ return (
+
+
+
π¨ Beautiful!
+
You colored your {meta.title.replace(" Β· Color it!", "")}.
+ {finalImage && (
+
+ )}
+
+
+
uploadRef.current?.click()}>π· Upload a paper drawing
+ {uploadMsg &&
{uploadMsg}
}
+
+
+ {loggedIn && πΌοΈ My drawings}
+ { setDone(false); setFinalImage(null); setUploadMsg(""); }}>βΊ Keep coloring
+ Back to lessons
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
{meta.levelName} Β· Color it!
+
{meta.emoji} {meta.title}
+
+
Report a problem
+
+
{meta.intro}
+
More tools (pencil, marker, fill bucket, layers, custom colors) are coming in the next update β for now, color away!
+
+
+
+
+
+ {/* coloring-book lines on top so colors fill underneath */}
+
+
+
+
+ {COLORS.map((c) => (
+ { setColor(c); setErasing(false); }} aria-label={`Color ${c}`}
+ style={{ width: 30, height: 30, borderRadius: "50%", background: c, border: !erasing && color === c ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)", cursor: "pointer" }} />
+ ))}
+ setErasing(true)} title="Eraser" style={{ minHeight: 30, padding: "4px 12px", borderRadius: 8, background: erasing ? "var(--primary)" : "#fff", color: erasing ? "#fff" : "var(--ink)", border: "2px solid var(--line)", cursor: "pointer", fontWeight: 700, fontSize: "0.85rem" }}>π§½ Erase
+
+
+ {SIZES.map((s) => (
+ setSize(s)} aria-label={`Size ${s}`}
+ style={{ width: 34, height: 34, borderRadius: "50%", background: size === s ? "var(--primary)" : "#fff", border: "2px solid var(--line)", cursor: "pointer", display: "grid", placeItems: "center" }}>
+
+
+ ))}
+
+ Opacity
+ setOpacity(Number(e.target.value))} style={{ verticalAlign: "middle", marginLeft: 6 }} />
+
+ βΆ Undo
+ π Clear
+
+ {loggedIn &&
Lower the opacity and go over an area a few times to shade and layer. Saved automatically.
}
+
+
+
+
Color your picture however you like β then save it to your gallery.
+
β I'm done β save it
+ {!loggedIn &&
Log in to keep your drawing.
}
+
+
+
+ );
+}
+
+const toolBtn: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" };
diff --git a/src/components/LineDraw.tsx b/src/components/LineDraw.tsx
new file mode 100644
index 0000000..7cfb486
--- /dev/null
+++ b/src/components/LineDraw.tsx
@@ -0,0 +1,91 @@
+"use client";
+import { useEffect, useRef } from "react";
+
+/**
+ * Draws a set of SVG line elements one at a time (stroke reveal), slowly, for the "video".
+ * Re-runs whenever `playKey` changes. Filled bits (e.g. a pupil dot) just fade in on their turn.
+ */
+export default function LineDraw({
+ lines,
+ playKey,
+ onDone,
+}: {
+ lines: string[];
+ playKey: number;
+ onDone?: () => void;
+}) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const g = ref.current;
+ if (!g) return;
+ const els = Array.from(
+ g.querySelectorAll("path,line,polyline,polygon,circle,ellipse")
+ ) as SVGGeometryElement[];
+ let cancelled = false;
+ const timers: ReturnType[] = [];
+ const len = (el: SVGGeometryElement) => {
+ try {
+ return el.getTotalLength();
+ } catch {
+ return 0;
+ }
+ };
+ const isFilled = (el: SVGGeometryElement) => {
+ const f = el.getAttribute("fill");
+ return !!f && f !== "none";
+ };
+
+ // Reset: stroked lines hidden via dash offset; filled bits hidden via opacity.
+ els.forEach((el) => {
+ const s = el as unknown as { style: CSSStyleDeclaration };
+ s.style.transition = "none";
+ const L = len(el);
+ if (!isFilled(el) && L) {
+ s.style.strokeDasharray = String(L);
+ s.style.strokeDashoffset = String(L);
+ s.style.opacity = "1";
+ } else {
+ s.style.opacity = "0";
+ }
+ });
+
+ // Play sequentially
+ let acc = 0;
+ els.forEach((el) => {
+ const s = el as unknown as { style: CSSStyleDeclaration };
+ const filled = isFilled(el);
+ const L = len(el);
+ const dur = filled ? 280 : Math.max(900, Math.min(2600, Math.round(L * 7)));
+ timers.push(
+ setTimeout(() => {
+ if (cancelled) return;
+ if (!filled && L) {
+ s.style.transition = `stroke-dashoffset ${dur}ms ease-in-out`;
+ s.style.strokeDashoffset = "0";
+ } else {
+ s.style.transition = "opacity 280ms ease";
+ s.style.opacity = "1";
+ }
+ }, acc)
+ );
+ acc += dur + 220;
+ });
+ timers.push(setTimeout(() => { if (!cancelled) onDone?.(); }, acc + 60));
+
+ return () => {
+ cancelled = true;
+ timers.forEach(clearTimeout);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [playKey]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/TraceRunner.tsx b/src/components/TraceRunner.tsx
new file mode 100644
index 0000000..e06339f
--- /dev/null
+++ b/src/components/TraceRunner.tsx
@@ -0,0 +1,237 @@
+"use client";
+import { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt";
+import LineDraw from "./LineDraw";
+import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL } from "./drawingPersistence";
+
+interface StepData { n: number; title: string; instruction: string; tip: string; lines: string[] }
+interface Meta {
+ level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string;
+ badgeKey: string; badgeName: string; baseSvg: string; phaseLabel: string;
+}
+
+const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a", "#ffffff", "#c4c8cc"];
+const SIZES = [4, 8, 16];
+interface Point { x: number; y: number }
+interface Stroke { color: string; size: number; points: Point[] }
+
+export default function TraceRunner({ meta, steps, loggedIn, username, completedSteps, alreadyEarned }: {
+ meta: Meta; steps: StepData[]; loggedIn: boolean; username: string; completedSteps: number[]; alreadyEarned: boolean;
+}) {
+ const lastIndex = steps.length - 1;
+ const [idx, setIdx] = useState(Math.min(completedSteps.length, lastIndex));
+ const [finished, setFinished] = useState(alreadyEarned);
+ const [earned, setEarned] = useState(alreadyEarned);
+ const [playKey, setPlayKey] = useState(0);
+ const [playing, setPlaying] = useState(true);
+ const [finalImage, setFinalImage] = useState(null);
+ const [uploadMsg, setUploadMsg] = useState("");
+ const uploadRef = useRef(null);
+ const step = steps[idx];
+
+ // faint guide = base outline + every line up to & including the current step
+ const guideUpTo = steps.slice(0, idx + 1).flatMap((s) => s.lines).join("\n");
+
+ const canvasRef = useRef(null);
+ const stageRef = useRef(null);
+ const strokesRef = useRef([]);
+ const drawingRef = useRef(null);
+ const baseImgRef = useRef(null);
+ const saveTimer = useRef | undefined>(undefined);
+ const [color, setColor] = useState(COLORS[0]);
+ const [size, setSize] = useState(SIZES[1]);
+ const [, force] = useState(0);
+
+ const redraw = useCallback(() => {
+ const c = canvasRef.current; if (!c) return;
+ const ctx = c.getContext("2d"); if (!ctx) return;
+ ctx.clearRect(0, 0, c.width, c.height);
+ if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, c.width, c.height);
+ ctx.lineCap = "round"; ctx.lineJoin = "round";
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ for (const s of strokesRef.current) {
+ if (!s.points.length) continue;
+ ctx.strokeStyle = s.color; ctx.lineWidth = s.size * dpr;
+ ctx.beginPath(); ctx.moveTo(s.points[0].x * c.width, s.points[0].y * c.height);
+ if (s.points.length === 1) ctx.lineTo(s.points[0].x * c.width + 0.1, s.points[0].y * c.height + 0.1);
+ else for (let i = 1; i < s.points.length; i++) ctx.lineTo(s.points[i].x * c.width, s.points[i].y * c.height);
+ ctx.stroke();
+ }
+ }, []);
+ const resize = useCallback(() => {
+ const c = canvasRef.current, st = stageRef.current; if (!c || !st) return;
+ const r = st.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ c.width = Math.round(r.width * dpr); c.height = Math.round(r.height * dpr); redraw();
+ }, [redraw]);
+ useEffect(() => { resize(); window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize); }, [resize]);
+
+ useEffect(() => {
+ if (!loggedIn || alreadyEarned) return; let alive = true;
+ loadProgressDrawing(meta.level, meta.sublevel).then((img) => { if (!img || !alive) return; const im = new Image(); im.onload = () => { baseImgRef.current = im; redraw(); }; im.src = img; });
+ return () => { alive = false; };
+ }, [loggedIn, alreadyEarned, meta.level, meta.sublevel, redraw]);
+
+ // Auto-play the line video whenever the step changes
+ useEffect(() => { setPlaying(true); setPlayKey((k) => k + 1); }, [idx]);
+
+ function hasContent() { return strokesRef.current.length > 0 || !!baseImgRef.current; }
+ function flushSave() { if (!loggedIn) return; const c = canvasRef.current; if (!c || !hasContent()) return; saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c)); }
+ function scheduleSave() { if (!loggedIn) return; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(flushSave, 1200); }
+
+ function pe(e: React.PointerEvent): Point { const r = canvasRef.current!.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; }
+ function onDown(e: React.PointerEvent) { if (finished) return; (e.target as Element).setPointerCapture?.(e.pointerId); const s: Stroke = { color, size, points: [pe(e)] }; drawingRef.current = s; strokesRef.current.push(s); }
+ function onMove(e: React.PointerEvent) { const s = drawingRef.current; if (!s) return; s.points.push(pe(e)); redraw(); }
+ function onUp() { drawingRef.current = null; force((n) => n + 1); scheduleSave(); }
+ function clearCanvas() { strokesRef.current = []; baseImgRef.current = null; redraw(); force((n) => n + 1); if (loggedIn) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(canvasRef.current!)); }
+ function undo() { strokesRef.current.pop(); redraw(); force((n) => n + 1); scheduleSave(); }
+
+ async function saveStep(stepN: number, badgeKey?: string) {
+ if (!loggedIn) return;
+ try { await fetch("/api/progress", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ level: meta.level, sublevel: meta.sublevel, step: stepN, badgeKey }) }); } catch { /* */ }
+ }
+ function next() { saveStep(step.n); flushSave(); if (idx < lastIndex) setIdx(idx + 1); else finish(); }
+ function prev() { flushSave(); if (idx > 0) setIdx(idx - 1); }
+ async function finish() {
+ await saveStep(step.n, meta.badgeKey || undefined);
+ const c = canvasRef.current;
+ if (c && hasContent()) { const data = exportCanvasToDataURL(c); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); }
+ setEarned(loggedIn && !!meta.badgeKey);
+ setFinished(true);
+ }
+ async function onUpload(e: React.ChangeEvent) {
+ const file = e.target.files?.[0]; if (!file) return;
+ if (!loggedIn) { setUploadMsg("Log in to save your drawing to your gallery."); return; }
+ setUploadMsg("Uploadingβ¦");
+ try { const data = await fileToDataURL(file); const ok = await saveFinalDrawing(meta.level, meta.sublevel, "upload", data); setUploadMsg(ok ? "Saved to your gallery! πΌοΈ" : "Couldn't save that file."); }
+ catch { setUploadMsg("Couldn't read that image."); }
+ finally { if (uploadRef.current) uploadRef.current.value = ""; }
+ }
+
+ async function rasterizeBadge(): Promise {
+ const blob = new Blob([badgeFullSvg(meta.badgeName, meta.emoji)], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob);
+ try { const img = await loadImage(url); const c = document.createElement("canvas"); c.width = 600; c.height = 600; c.getContext("2d")!.drawImage(img, 0, 0, 600, 600); return await new Promise((res) => c.toBlob((b) => res(b), "image/png")); }
+ finally { URL.revokeObjectURL(url); }
+ }
+ async function shareBadge() {
+ const blob = await rasterizeBadge(); if (!blob) return; const file = new File([blob], "drawit-badge.png", { type: "image/png" });
+ const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean };
+ if (nav.share && nav.canShare && nav.canShare({ files: [file] })) { try { await nav.share({ files: [file], title: "My DrawIt badge!", text: `I earned the ${meta.badgeName} badge on DrawIt!` }); return; } catch { /* */ } }
+ downloadBlob(blob, "drawit-badge.png");
+ }
+ async function downloadBadge() { const b = await rasterizeBadge(); if (b) downloadBlob(b, "drawit-badge.png"); }
+
+ if (finished) {
+ return (
+
+
+
π Great work!
+
You finished {meta.title} {earned ? <> and earned the {meta.badgeName} badge!> : "."}
+ {earned &&
}
+ {earned && (
+
+ )}
+ {finalImage && (
+
+ )}
+
+
Drew it on paper?
+
Take a photo and add it to your gallery.
+
+
uploadRef.current?.click()}>π· Upload my paper drawing
+ {uploadMsg &&
{uploadMsg}
}
+
+
+ {loggedIn && πΌοΈ My drawings}
+ { clearCanvas(); setFinalImage(null); setUploadMsg(""); setIdx(0); setFinished(false); }}>βΊ Try again
+ Back to lessons
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
{meta.levelName} Β· {meta.phaseLabel}
+
{meta.emoji} {meta.title}
+
+
Report a problem
+
+ {idx === 0 &&
{meta.intro}
}
+
+
+ {steps.map((s, i) => (
+ { flushSave(); setIdx(i); }} aria-label={`Go to step ${s.n}`}
+ style={{ width: 30, height: 30, borderRadius: "50%", border: "none", cursor: "pointer", fontWeight: 800, color: i <= idx ? "#fff" : "var(--ink-soft)", background: i < idx ? "var(--leaf)" : i === idx ? "var(--primary)" : "#e6e1ee" }}>
+ {i < idx ? "β" : s.n}
+
+ ))}
+
+
+
+
+
+ {/* faint trace target (outline base + lines so far) */}
+
+ {/* the slowly-drawn video for the current step */}
+ {playing && setPlaying(false)} />}
+
+
+
+
+ { setPlaying(true); setPlayKey((k) => k + 1); }}>βΆ Watch this step again
+
+
+
+ {COLORS.map((c) => (
+ setColor(c)} aria-label={`Color ${c}`}
+ style={{ width: 32, height: 32, borderRadius: "50%", background: c, border: color === c ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)", cursor: "pointer" }} />
+ ))}
+
+ {SIZES.map((s) => (
+ setSize(s)} aria-label={`Brush ${s}`}
+ style={{ width: 34, height: 34, borderRadius: "50%", background: size === s ? "var(--primary)" : "#fff", border: "2px solid var(--line)", cursor: "pointer", display: "grid", placeItems: "center" }}>
+
+
+ ))}
+
+
+ βΆ Undo
+ π Clear
+
+ {loggedIn &&
Your drawing is saved automatically.
}
+
+
+
+
Step {step.n}: {step.title}
+
{step.instruction}
+ {step.tip &&
π‘ {step.tip}
}
+
+ β Back
+ {idx < lastIndex ? "Next step β" : meta.badgeKey ? "π Finish & get my badge" : "β Finish outline"}
+
+ {!loggedIn &&
Log in to save your progress.
}
+
+
+
+ );
+}
+
+const toolBtn: React.CSSProperties = { minHeight: 40, padding: "8px 14px", fontSize: "0.9rem", boxShadow: "none" };
+function loadImage(src: string): Promise { return new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = rej; i.src = src; }); }
+function downloadBlob(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }
diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts
index 8dfb1b0..0c5adfc 100644
--- a/src/lib/curriculum.ts
+++ b/src/lib/curriculum.ts
@@ -17,31 +17,43 @@ export const LEVELS: LevelMeta[] = [
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; }
+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"; steps: LessonStep[]; }
+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_STEPS: LessonStep[] = [
- { n: 1, kind: "construct", title: "The body", instruction: "Draw a big rounded body β fatter in the middle and tapering toward the tail.",
- tip: "Like a soft egg lying on its side.",
- svg: ` ` },
- { n: 2, kind: "outline", title: "The tail", instruction: "Add a fan-shaped tail on the right with a little notch.",
- svg: ` ` },
- { n: 3, kind: "outline", title: "The fins", instruction: "Add a small fin on top (the back) and one underneath the belly.",
- svg: ` ` },
- { n: 4, kind: "detail", title: "The eye", instruction: "Draw a circle near the front with a dark pupil and a tiny white sparkle.",
- tip: "The sparkle makes it look alive!",
- svg: ` ` },
- { n: 5, kind: "detail", title: "Mouth and gill", instruction: "Add a little smile at the front and a curved gill line behind the head.",
- svg: ` ` },
- { n: 6, kind: "detail", title: "Stripes", instruction: "Add a couple of curved stripes, then color your fish any way you like!",
- tip: "π",
- svg: ` ` },
+// Fish is the pilot for the 3-lesson model: Outline β Detail β Color it.
+// Outline/Detail use per-step `lines` (coloring-book strokes drawn one at a time).
+const LN = (d: string) => ` `;
+
+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: [LN("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")] },
+ { n: 2, title: "The tail", instruction: "Add the fan-shaped tail at the back.",
+ lines: [LN("M300 150 C 332 130, 360 112, 390 100 C 374 130, 374 170, 390 200 C 360 188, 332 170, 300 150 Z")] },
+ { n: 3, title: "The fins", instruction: "Draw a fin on the back, then one under the belly.",
+ lines: [LN("M150 92 C 168 62, 212 60, 240 84"), LN("M150 178 C 168 208, 206 210, 228 196")] },
];
-export const FISH_LESSON: Lesson = { level: "early-beginner", sublevel: 1, slug: "fish", title: "Draw a Fish", subject: "a happy fish", emoji: "π", intro: "We'll build a friendly fish from a few simple shapes. Six easy steps!", badgeKey: "early-beginner-1-fish", badgeName: "Fish Friend", mode: "draw", steps: FISH_STEPS };
+const FISH_DETAIL_STEPS: LessonStep[] = [
+ { n: 1, title: "The eye", instruction: "Draw a round eye, then a little dot inside.",
+ lines: [LN("M147 134 a15 15 0 1 0 0.1 0"), ` `] },
+ { n: 2, title: "The mouth", instruction: "Add a small smile at the front.",
+ lines: [LN("M90 162 q15 13 32 4")] },
+ { n: 3, title: "The gill", instruction: "Add a curved gill line behind the head.",
+ lines: [LN("M160 104 q-16 46 0 92")] },
+ { n: 4, title: "Stripes", instruction: "Add a couple of curved stripes across the body.", tip: "Now it's ready to color!",
+ lines: [LN("M205 96 q-20 54 4 110"), LN("M240 100 q-16 50 4 100")] },
+];
+
+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 Β· Outline", subject: "a fish outline", emoji: "π", intro: "Let's draw the outline of a fish, 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 Β· Details", subject: "fish details", emoji: "π", intro: "Now add the details that make it a fish β the eye, mouth, gill, and stripes.", 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 Β· Color it!", subject: "color the fish", emoji: "π¨", 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_STEPS: LessonStep[] = [
{ n: 1, kind: "construct", title: "The head", instruction: "Draw one big round face.",
@@ -170,8 +182,23 @@ const SHADE_STEPS: LessonStep[] = [
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: "Time to make a flat circle look like a round ball β using light and shadow! First, learn how to hold your pencil.", badgeKey: "learner-1-shading", badgeName: "Shading Star", mode: "shade", steps: SHADE_STEPS };
-export const LESSONS: Lesson[] = [FISH_LESSON, PANDA_LESSON, FLOWER_LESSON, UNICORN_LESSON, TREE_LESSON, DINO_LESSON, SHADE_BALL_LESSON];
+export const LESSONS: Lesson[] = [FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON, FISH_EXTRA_LESSON, PANDA_LESSON, FLOWER_LESSON, UNICORN_LESSON, TREE_LESSON, DINO_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 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"); }
+
+/** Subjects in a level, in order, each with its phase lessons (or a single legacy lesson). */
+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();
+ 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 phaseRank: Record = { outline: 0, detail: 1, extra: 2 };
+ for (const s of map.values()) s.lessons.sort((a, b) => (phaseRank[a.phase ?? ""] ?? 0) - (phaseRank[b.phase ?? ""] ?? 0));
+ return [...map.values()].sort((a, b) => a.order - b.order);
+}