From c02893371ac9d347e5fa25ba03db73bc03255ec0 Mon Sep 17 00:00:00 2001
From: iamdoubz <>
Date: Sat, 20 Jun 2026 20:45:05 -0500
Subject: [PATCH] Add dark mode, theming
---
src/app/globals.css | 43 ++++++++++++--
src/app/layout.tsx | 8 ++-
src/app/learn/[level]/[slug]/page.tsx | 9 ++-
src/app/learn/page.tsx | 4 +-
src/app/page.tsx | 4 +-
src/components/ColoringRunner.tsx | 81 +++++++++++++++++++++------
src/components/NavLinks.tsx | 2 +
src/components/ThemeToggle.tsx | 67 ++++++++++++++++++++++
8 files changed, 190 insertions(+), 28 deletions(-)
create mode 100644 src/components/ThemeToggle.tsx
diff --git a/src/app/globals.css b/src/app/globals.css
index 1f10d7d..b74edf4 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -19,6 +19,41 @@
--font: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
}
+/* Dark theme โ applied when data-theme="dark" is set on (by the no-flash script / toggle). */
+:root[data-theme="dark"] {
+ --bg: #17141f;
+ --surface: #221d31;
+ --ink: #f2eefb;
+ --ink-soft: #aaa2c0;
+ --primary: #7b9bff;
+ --primary-dark: #9db4ff;
+ --accent: #ff8ec0;
+ --sun: #ffce5b;
+ --sea: #43d3d3;
+ --leaf: #5fd083;
+ --danger: #ff7a7a;
+ --line: #36304a;
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+}
+/* No-JS / pre-hydration fallback to the system preference */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme]) {
+ --bg: #17141f;
+ --surface: #221d31;
+ --ink: #f2eefb;
+ --ink-soft: #aaa2c0;
+ --primary: #7b9bff;
+ --primary-dark: #9db4ff;
+ --accent: #ff8ec0;
+ --sun: #ffce5b;
+ --sea: #43d3d3;
+ --leaf: #5fd083;
+ --danger: #ff7a7a;
+ --line: #36304a;
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+ }
+}
+
* { box-sizing: border-box; }
html, body {
@@ -74,7 +109,7 @@ a { color: var(--primary-dark); }
}
.btn:active { transform: translateY(1px) scale(0.99); }
.btn:hover { filter: brightness(1.04); }
-.btn.secondary { background: #fff; color: var(--primary-dark); border: 2px solid var(--line); box-shadow: none; }
+.btn.secondary { background: var(--surface); color: var(--primary-dark); border: 2px solid var(--line); box-shadow: none; }
.btn.accent { background: var(--accent); }
.btn.ghost { background: transparent; color: var(--ink); box-shadow: none; }
.btn.danger { background: var(--danger); }
@@ -90,7 +125,7 @@ a { color: var(--primary-dark); }
/* Top navigation */
.nav {
position: sticky; top: 0; z-index: 20;
- background: rgba(254, 249, 243, 0.9); backdrop-filter: blur(8px);
+ background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px; max-width: var(--maxw); margin: 0 auto; }
@@ -98,7 +133,7 @@ a { color: var(--primary-dark); }
.brand .logo { font-size: 1.5rem; }
.nav-links { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.nav-links a { text-decoration: none; color: var(--ink); font-weight: 700; padding: 8px 14px; border-radius: 999px; border: 2px solid transparent; }
-.nav-links a:hover { background: #fff; color: var(--ink); }
+.nav-links a:hover { background: var(--surface); color: var(--ink); }
/* Current page: lighter fill, darker bold text for clear, readable contrast */
.nav-links a.active { background: #eaf0ff; color: var(--ink); font-weight: 800; }
/* Sign-up call-to-action: outlined so it doesn't read as the active page */
@@ -112,7 +147,7 @@ a { color: var(--primary-dark); }
.field input, .field select, .field textarea {
font: inherit; padding: 14px 16px; min-height: 52px;
border: 2px solid var(--line); border-radius: var(--radius-sm);
- background: #fff; color: var(--ink); width: 100%;
+ background: var(--surface); color: var(--ink); width: 100%;
}
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--primary); }
.field textarea { min-height: 110px; resize: vertical; }
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 8db110a..84275a4 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -44,9 +44,15 @@ export const viewport: Viewport = {
themeColor: "#dce06b",
};
+// Runs before paint to set the theme (avoids a flash of the wrong colors).
+const themeScript = `(function(){try{var p=localStorage.getItem('drawit-theme')||'system';var d=p==='dark'||(p==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.setAttribute('data-theme',d?'dark':'light');}catch(e){}})();`;
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
+
+
+
+
{children}
);
diff --git a/src/app/learn/[level]/[slug]/page.tsx b/src/app/learn/[level]/[slug]/page.tsx
index f0d2d09..f3d09cd 100644
--- a/src/app/learn/[level]/[slug]/page.tsx
+++ b/src/app/learn/[level]/[slug]/page.tsx
@@ -78,11 +78,18 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
// 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) ?? "";
+ }
return (
<>
-
+
>
);
diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx
index 43709d8..9c68661 100644
--- a/src/app/learn/page.tsx
+++ b/src/app/learn/page.tsx
@@ -94,7 +94,7 @@ export default async function LearnPage() {
{subj.emoji} {subj.name}
@@ -123,4 +123,4 @@ export default async function LearnPage() {
}
const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 });
-const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "#fff" };
+const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" };
diff --git a/src/app/page.tsx b/src/app/page.tsx
index fa1ac7d..e68e0ad 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -34,7 +34,7 @@ export default function Home() {
-
+
Watch it drawn, line by line ๐
@@ -97,7 +97,7 @@ export default function Home() {
-
+
How DrawIt works
diff --git a/src/components/ColoringRunner.tsx b/src/components/ColoringRunner.tsx
index 779dfdf..2d10ae1 100644
--- a/src/components/ColoringRunner.tsx
+++ b/src/components/ColoringRunner.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL } from "./drawingPersistence";
+import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence";
interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string }
@@ -32,10 +32,14 @@ function newLayer(id: number, name: string): Layer {
return { id, name, visible: true, canvas: c };
}
-export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned }: {
- meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean;
+export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "" }: {
+ meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string;
}) {
const router = useRouter();
+ // 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");
+ const [overlayUrl, setOverlayUrl] = useState
("");
const [done, setDone] = useState(false);
const [finalImage, setFinalImage] = useState(null);
const [uploadMsg, setUploadMsg] = useState("");
@@ -81,21 +85,35 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
// Repaint when returning to the studio from the "done" screen
useEffect(() => { if (!done) composite(); }, [done, composite]);
- // Rasterize the coloring-book template for the fill boundary + final export
+ // Build the line-art overlay (+ fill boundary + final export template) from the chosen source.
+ // "stock" = the curriculum's template SVG; "yours" = the child's own sketch (white knocked out to transparent so coloring shows under the lines).
useEffect(() => {
- const svg = ``;
- const blob = new Blob([svg], { type: "image/svg+xml" });
- const url = URL.createObjectURL(blob);
- const img = new Image();
- img.onload = () => {
- templImgRef.current = img;
- const b = document.createElement("canvas"); b.width = W; b.height = H;
- const bctx = b.getContext("2d")!; bctx.drawImage(img, 0, 0, W, H);
- boundaryRef.current = bctx.getImageData(0, 0, W, H);
- URL.revokeObjectURL(url);
- };
- img.src = url;
- }, [meta.baseSvg]);
+ if (!sketch) return;
+ let alive = true;
+ (async () => {
+ let url: string;
+ if (sketch === "yours" && userSketch) {
+ const layer = await loadDrawingAsLayer(userSketch); // dark lines on transparent
+ const c = document.createElement("canvas"); c.width = W; c.height = H;
+ c.getContext("2d")!.drawImage(layer, 0, 0, W, H);
+ url = c.toDataURL("image/png");
+ } else {
+ url = "data:image/svg+xml;utf8," + encodeURIComponent(``);
+ }
+ if (!alive) return;
+ const img = new Image();
+ img.onload = () => {
+ if (!alive) return;
+ templImgRef.current = img;
+ const b = document.createElement("canvas"); b.width = W; b.height = H;
+ const bctx = b.getContext("2d")!; bctx.drawImage(img, 0, 0, W, H);
+ boundaryRef.current = bctx.getImageData(0, 0, W, H);
+ setOverlayUrl(url);
+ };
+ img.src = url;
+ })();
+ return () => { alive = false; };
+ }, [sketch, meta.baseSvg, userSketch]);
// Restore saved coloring into the bottom layer
useEffect(() => {
@@ -239,6 +257,33 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
finally { if (uploadRef.current) uploadRef.current.value = ""; }
}
+ // Ask which line art to color before opening the studio (only when the child has a sketch of their own).
+ if (sketch === null) {
+ return (
+
+
{meta.levelName} ยท Color it!
+
{meta.emoji} {meta.title}
+
Which drawing would you like to color in?
+
+
+
+
+
+ );
+ }
+
if (done) {
return (
@@ -287,7 +332,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
-
+ {overlayUrl &&

}
{/* Tools */}
diff --git a/src/components/NavLinks.tsx b/src/components/NavLinks.tsx
index f1eacba..c8cfc82 100644
--- a/src/components/NavLinks.tsx
+++ b/src/components/NavLinks.tsx
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import LogoutButton from "./LogoutButton";
+import ThemeToggle from "./ThemeToggle";
export default function NavLinks({
user,
@@ -54,6 +55,7 @@ export default function NavLinks({
>
)}
+
);
}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..35ed215
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,67 @@
+"use client";
+import { useEffect, useState } from "react";
+
+type Pref = "system" | "light" | "dark";
+const ORDER: Pref[] = ["system", "light", "dark"];
+const ICON: Record = { system: "๐", light: "โ๏ธ", dark: "๐" };
+
+export default function ThemeToggle() {
+ const [pref, setPref] = useState("system");
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ try {
+ const p = localStorage.getItem("drawit-theme") as Pref | null;
+ if (p === "light" || p === "dark" || p === "system") setPref(p);
+ } catch {
+ /* ignore */
+ }
+ }, []);
+
+ // Apply the effective theme whenever the preference changes (and follow the system if "system").
+ useEffect(() => {
+ if (!mounted) return;
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ const apply = () => {
+ const dark = pref === "dark" || (pref === "system" && mq.matches);
+ document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
+ };
+ apply();
+ if (pref === "system") {
+ mq.addEventListener("change", apply);
+ return () => mq.removeEventListener("change", apply);
+ }
+ }, [pref, mounted]);
+
+ function cycle() {
+ const nextPref = ORDER[(ORDER.indexOf(pref) + 1) % ORDER.length];
+ setPref(nextPref);
+ try {
+ localStorage.setItem("drawit-theme", nextPref);
+ } catch {
+ /* ignore */
+ }
+ }
+
+ return (
+
+ );
+}