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 }; +}