diff --git a/apps/web/src/components/theme-toggle.test.tsx b/apps/web/src/components/theme-toggle.test.tsx
new file mode 100644
index 0000000..034eb07
--- /dev/null
+++ b/apps/web/src/components/theme-toggle.test.tsx
@@ -0,0 +1,149 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { ReactNode } from "react";
+
+// next-themes accesses window/localStorage on import; stub the hook so the
+// component is testable in a Node environment.
+const setThemeMock = vi.fn();
+const useThemeReturn: { theme: string | undefined; setTheme: typeof setThemeMock } = {
+ theme: "system",
+ setTheme: setThemeMock,
+};
+vi.mock("next-themes", () => ({
+ useTheme: () => useThemeReturn,
+}));
+
+// Radix DropdownMenu uses portals + client refs. Render trigger and items
+// inline so we can assert on the markup deterministically.
+vi.mock("@/components/ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}>,
+ DropdownMenuContent: ({ children }: { children: ReactNode }) => (
+
{children}
+ ),
+ DropdownMenuItem: ({
+ children,
+ onClick,
+ }: {
+ children: ReactNode;
+ onClick?: () => void;
+ }) => (
+
+ ),
+}));
+
+import { ThemeToggle } from "./theme-toggle";
+
+function setTheme(t: string | undefined) {
+ useThemeReturn.theme = t;
+}
+
+describe("ThemeToggle — rendered markup", () => {
+ beforeEach(() => {
+ setThemeMock.mockReset();
+ setTheme("system");
+ });
+
+ it("shows the system label + monitor icon when theme is 'system'", () => {
+ setTheme("system");
+ const html = renderToStaticMarkup();
+ expect(html).toMatch(/lucide-monitor/);
+ expect(html).toContain(">system<");
+ });
+
+ it("shows the light label + sun icon when theme is 'light'", () => {
+ setTheme("light");
+ const html = renderToStaticMarkup();
+ expect(html).toMatch(/lucide-sun/);
+ expect(html).toContain(">light<");
+ });
+
+ it("shows the dark label + moon icon when theme is 'dark'", () => {
+ setTheme("dark");
+ const html = renderToStaticMarkup();
+ expect(html).toMatch(/lucide-moon/);
+ expect(html).toContain(">dark<");
+ });
+
+ it("falls back to 'system' label when next-themes returns undefined (pre-mount)", () => {
+ setTheme(undefined);
+ const html = renderToStaticMarkup();
+ expect(html).toContain(">system<");
+ });
+
+ it("renders three menu items (Light / Dark / System)", () => {
+ setTheme("system");
+ const html = renderToStaticMarkup();
+ expect(html).toContain("Light");
+ expect(html).toContain("Dark");
+ expect(html).toContain("System");
+ // One trigger + three menu items.
+ const items = html.match(/data-testid="dropdown-item"/g) ?? [];
+ expect(items).toHaveLength(3);
+ });
+});
+
+describe("ThemeToggle — onClick wires through to setTheme", () => {
+ beforeEach(() => {
+ setThemeMock.mockReset();
+ setTheme("system");
+ });
+
+ // We can't realistically click a server-rendered button. Instead reach
+ // into the rendered React tree and grab the onClick handlers — that's
+ // exactly what the click handler will fire in the browser, so it's a
+ // faithful contract check.
+ function getMenuItemHandlers(): Array<() => void> {
+ // Render via React.createElement to keep the element tree.
+ // Then walk it for the dropdown-item buttons.
+ // (Easier than parsing the SSR markup.)
+ const el = () as unknown as React.ReactElement;
+ const handlers: Array<() => void> = [];
+ function visit(node: unknown): void {
+ if (!node) return;
+ if (Array.isArray(node)) {
+ for (const c of node) visit(c);
+ return;
+ }
+ if (typeof node !== "object") return;
+ const n = node as { props?: Record; type?: unknown };
+ const props = n.props ?? {};
+ if (typeof props.onClick === "function" && props["data-testid"] === "dropdown-item") {
+ handlers.push(props.onClick as () => void);
+ }
+ // type can be a function component — render it once with its props
+ // so we can recurse into its output. Component functions used here
+ // (DropdownMenuItem mocks etc.) are pure render-children.
+ if (typeof n.type === "function") {
+ const rendered = (n.type as (p: unknown) => unknown)(props);
+ visit(rendered);
+ }
+ if ("children" in props) {
+ visit(props.children);
+ }
+ }
+ visit(el);
+ return handlers;
+ }
+
+ it("first menu item sets theme to 'light'", () => {
+ const [light] = getMenuItemHandlers();
+ light?.();
+ expect(setThemeMock).toHaveBeenCalledTimes(1);
+ expect(setThemeMock).toHaveBeenCalledWith("light");
+ });
+
+ it("second menu item sets theme to 'dark'", () => {
+ const [, dark] = getMenuItemHandlers();
+ dark?.();
+ expect(setThemeMock).toHaveBeenCalledWith("dark");
+ });
+
+ it("third menu item sets theme to 'system'", () => {
+ const [, , system] = getMenuItemHandlers();
+ system?.();
+ expect(setThemeMock).toHaveBeenCalledWith("system");
+ });
+});