Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forkcast</title>
<!-- Set the theme before first paint to avoid a flash of the wrong theme.
Mirrors resolveTheme() in src/hooks/useTheme.ts. -->
<script>
(function () {
var saved = localStorage.getItem("forkcast-theme");
var dark = saved
? saved === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches;
if (dark) document.documentElement.classList.add("dark");
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tabs defaultValue="chat" className="mx-auto flex h-full max-w-3xl flex-col gap-0">
<header className="flex items-center justify-between gap-4 border-b px-4 py-3">
Expand All @@ -23,6 +25,14 @@ export function AppShell({ user }: { user: User }) {
<span className="hidden text-sm text-muted-foreground sm:inline">
{user.name ?? user.email}
</span>
<button
type="button"
onClick={toggle}
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
>
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
<a
href={`${API_BASE}/auth/logout`}
title="Sign out"
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/hooks/useTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { resolveTheme } from "./useTheme";

// Why: a saved choice must beat the OS preference, and the OS preference is
// only the fallback when the user has never chosen. This precedence is the
// rule most likely to regress, and it drives both the inline FOUC script and
// the hook's initial state.
describe("resolveTheme", () => {
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");
});
});
32 changes: 32 additions & 0 deletions frontend/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -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<Theme>(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 };
}
Loading