[Feature] Add beginner levels #10

Merged
iamdoubz merged 1 commits from feature_beginner_levels into main 2026-06-21 08:45:37 -05:00
9 changed files with 538 additions and 152 deletions

No files matched your search

+8 -4
View File
@@ -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" }),
});
+13 -3
View File
@@ -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 });
}
+50 -43
View File
@@ -4,15 +4,21 @@ 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";
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);
@@ -27,69 +33,70 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
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>
</>
);
+113 -72
View File
@@ -1,23 +1,73 @@
import Link from "next/link";
import SiteNav from "@/components/SiteNav";
import { LEVELS, getSubjects, type Lesson } from "@/lib/curriculum";
import { LEVELS, getSubjects, BEGINNER_PACKS, 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";
export const metadata = { title: "Learn · DrawIt" };
export const dynamic = "force-dynamic";
const PHASE_LABEL: Record<string, string> = { outline: "Outline", detail: "Details", extra: "Color it!" };
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 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 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>
);
};
return (
@@ -26,93 +76,84 @@ export default async function LearnPage() {
<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.
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 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;
const unlocked = isLevelUnlocked(user?.id ?? null, lvl.key);
const firstIncomplete = subjects.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>
<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;
{/* 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&apos;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 (
<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 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>
</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>
) : (
// Early Beginner (and any future flat levels)
<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>
);
})}
+12 -15
View File
@@ -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 (
<>
+19 -11
View File
@@ -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 */}
+268
View File
@@ -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"] },
];
+26 -4
View File
@@ -13,12 +13,13 @@ 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";
// 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; steps: LessonStep[];
subjectKey?: string; subjectName?: string; subjectEmoji?: string; order?: number; packKey?: string; steps: LessonStep[];
}
@@ -171,7 +172,12 @@ 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:"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];
import { BEGINNER_LESSONS, BEGINNER_PACKS, type Pack } from "./curriculum.beginner";
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];
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); }
@@ -185,7 +191,23 @@ export function getSubjects(level: string): { key: string; name: string; emoji:
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 };
// 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);
}
/** 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; }
+29
View File
@@ -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));
}