From 14a71e43531d40e9547ca10e5fee1bb24a0e4312 Mon Sep 17 00:00:00 2001
From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com>
Date: Wed, 22 Jul 2026 13:48:39 +0000
Subject: [PATCH] Add dark mode toggle with persistence and FOUC fix
Resolve theme on load from the saved choice or prefers-color-scheme,
apply it before first paint via an inline script, and add a header
toggle in AppShell that persists to localStorage.
Closes #44
Co-authored-by: Deric Walintukan <4350404+dwalintukan@users.noreply.github.com>
---
frontend/index.html | 11 ++++++++++
frontend/src/components/AppShell.tsx | 12 ++++++++++-
frontend/src/hooks/useTheme.test.ts | 22 +++++++++++++++++++
frontend/src/hooks/useTheme.ts | 32 ++++++++++++++++++++++++++++
4 files changed, 76 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/hooks/useTheme.test.ts
create mode 100644 frontend/src/hooks/useTheme.ts
diff --git a/frontend/index.html b/frontend/index.html
index 5c40a0a..082e8ad 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,6 +4,17 @@
Forkcast
+
+
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index 0fc0c47..5635106 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -1,13 +1,15 @@
-import { ChefHat, LogOut } from "lucide-react";
+import { ChefHat, LogOut, Moon, Sun } from "lucide-react";
import type { User } from "@/lib/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
+import { useTheme } from "@/hooks/useTheme";
import { ChatView } from "@/components/chat/ChatView";
import { MealPlanView } from "@/components/mealplan/MealPlanView";
import { API_BASE } from "@/lib/api";
export function AppShell({ user }: { user: User }) {
+ const { theme, toggle } = useTheme();
return (
@@ -23,6 +25,14 @@ export function AppShell({ user }: { user: User }) {
{user.name ?? user.email}
+
{
+ it("prefers a saved choice over the OS preference", () => {
+ expect(resolveTheme("light", true)).toBe("light");
+ expect(resolveTheme("dark", false)).toBe("dark");
+ });
+
+ it("falls back to the OS preference when nothing is saved", () => {
+ expect(resolveTheme(null, true)).toBe("dark");
+ expect(resolveTheme(null, false)).toBe("light");
+ });
+
+ it("ignores an unrecognized saved value and uses the OS preference", () => {
+ expect(resolveTheme("system", true)).toBe("dark");
+ });
+});
diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts
new file mode 100644
index 0000000..c5f9f95
--- /dev/null
+++ b/frontend/src/hooks/useTheme.ts
@@ -0,0 +1,32 @@
+import { useState } from "react";
+
+export type Theme = "light" | "dark";
+
+const STORAGE_KEY = "forkcast-theme";
+
+// Resolves the theme the app should start in. A saved choice always wins;
+// otherwise fall back to the OS preference. The inline script in index.html
+// mirrors this so the class is set before first paint — keep them in sync.
+export function resolveTheme(saved: string | null, prefersDark: boolean): Theme {
+ if (saved === "light" || saved === "dark") return saved;
+ return prefersDark ? "dark" : "light";
+}
+
+// The inline script in index.html is the source of truth for the initial
+// theme; read the class it set rather than resolving again here.
+function currentTheme(): Theme {
+ return document.documentElement.classList.contains("dark") ? "dark" : "light";
+}
+
+export function useTheme() {
+ const [theme, setTheme] = useState(currentTheme);
+
+ function toggle() {
+ const next: Theme = theme === "dark" ? "light" : "dark";
+ document.documentElement.classList.toggle("dark", next === "dark");
+ localStorage.setItem(STORAGE_KEY, next);
+ setTheme(next);
+ }
+
+ return { theme, toggle };
+}