feat(ui): add a light/dark/system theme toggle to the top bar (#37669)

* feat(ui): add a light/dark/system theme toggle

The dashboard already carried a full `.dark` palette, dark-aware surfaces and a
dark logo variant, but nothing ever put the `dark` class on the document, so
none of it could be reached. next-themes now owns that class: it reads the
stored choice, falls back to the OS preference, and stamps the class from an
inline script before first paint so there is no light flash on load.

The toggle is a three-way System / Light / Dark control in the account menu,
in both the sidebar menu and the older navbar dropdown, so it is reachable from
the gateway dashboard, chat and the model hub alike.

useIsDarkMode watched the root element with a MutationObserver purely to answer
a question next-themes now answers directly, so it goes, and useSyntaxTheme
reads resolvedTheme instead. The toaster follows the resolved theme too.

* feat(ui): move the theme control to the top bar and default to light

The toggle now lives in the header toolbar of both shells, the gateway
dashboard's DashboardHeader and the older full-width Navbar, where it replaces
the placeholder comment that had been holding its spot. It reads better there
as a single icon button with a System / Light / Dark menu than as a segmented
row buried in the account popover, so the account menus lose their theme row.

Dark mode is still being rolled out, so an install that has never touched the
control now stays light instead of following the OS. System is still a choice,
just no longer the default. While dark is active the toolbar carries a small
Experimental badge, so nobody mistakes an unstyled surface for a bug.

* fix(ui): serve the dark logo in the legacy navbar too

The sidebar already paired its logo with a dark variant, but the full-width
navbar kept a single light-only image. That did not matter while dark mode was
unreachable; now that the toggle sits in that shell's own top bar, the white
JPEG slab lands on a dark bar. It gets the same two-image swap the sidebar uses,
and a test that pins the pairing so the two shells cannot drift apart again.
This commit is contained in:
ryan-crabbe-berri 2026-08-20 12:58:29 -07:00 committed by GitHub
parent 3a31331435
commit 933e28d900
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 216 additions and 90 deletions

View file

@ -24,6 +24,7 @@
"lucide-react": "0.513.0",
"moment": "2.30.1",
"next": "16.2.11",
"next-themes": "^0.4.6",
"nuqs": "^2.9.4",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",
@ -9805,6 +9806,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",

View file

@ -40,6 +40,7 @@
"lucide-react": "0.513.0",
"moment": "2.30.1",
"next": "16.2.11",
"next-themes": "^0.4.6",
"nuqs": "^2.9.4",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",

View file

@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { ThemeProvider } from "next-themes";
import { AuthProvider } from "@/contexts/AuthContext";
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
@ -22,14 +23,18 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
// next-themes stamps the theme class on <html> before paint, which the exported markup
// cannot predict; suppressHydrationWarning confines that mismatch to this element.
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<NuqsAdapter>
<ReactQueryProvider>
<AuthProvider>{children}</AuthProvider>
<Toaster />
</ReactQueryProvider>
</NuqsAdapter>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
<NuqsAdapter>
<ReactQueryProvider>
<AuthProvider>{children}</AuthProvider>
<Toaster />
</ReactQueryProvider>
</NuqsAdapter>
</ThemeProvider>
</body>
</html>
);

View file

@ -14,6 +14,7 @@ import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown";
import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown";
import { useWorker } from "@/hooks/useWorker";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
@ -73,6 +74,7 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
<BlogDropdown />
{!hideCommunityLinks && <CommunityEngagementButtons />}
<ToolbarSeparator />
<ThemeToggle />
<NotificationsBell />
</div>
</header>

View file

@ -0,0 +1,72 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThemeProvider } from "next-themes";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import ThemeToggle from "./ThemeToggle";
const renderToggle = () =>
render(
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
<ThemeToggle />
</ThemeProvider>,
);
const openMenu = async () => {
await userEvent.click(screen.getByRole("button", { name: "Theme" }));
await screen.findByRole("menu");
};
const pick = async (label: string) => userEvent.click(screen.getByRole("menuitemradio", { name: label }));
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark", "light");
});
afterAll(() => {
document.documentElement.classList.remove("dark", "light");
});
describe("ThemeToggle", () => {
it("starts on light rather than following the system preference", async () => {
renderToggle();
await openMenu();
expect(screen.getByRole("menuitemradio", { name: "Light" })).toBeChecked();
expect(screen.getByRole("menuitemradio", { name: "Dark" })).not.toBeChecked();
expect(screen.getByRole("menuitemradio", { name: "System" })).not.toBeChecked();
});
it("puts the dark class on the document and remembers the choice", async () => {
renderToggle();
await openMenu();
await pick("Dark");
expect(document.documentElement).toHaveClass("dark");
expect(localStorage.getItem("theme")).toBe("dark");
});
it("hands control back to the system preference when asked", async () => {
renderToggle();
await openMenu();
await pick("Dark");
await pick("System");
expect(localStorage.getItem("theme")).toBe("system");
expect(document.documentElement).not.toHaveClass("dark");
});
it("flags dark mode as experimental, and only while it is on", async () => {
renderToggle();
await openMenu();
expect(screen.queryByText("Experimental")).not.toBeInTheDocument();
await pick("Dark");
expect(screen.getByText("Experimental")).toBeInTheDocument();
await pick("Light");
expect(screen.queryByText("Experimental")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,61 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const THEMES = [
{ value: "system", label: "System", Icon: Monitor },
{ value: "light", label: "Light", Icon: Sun },
{ value: "dark", label: "Dark", Icon: Moon },
] as const;
const ThemeToggle: React.FC = () => {
const { theme, setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return (
<span className="flex items-center gap-1">
{isDark && (
<Badge
variant="outline"
className="px-1.5 py-0 text-[10px] font-medium text-muted-foreground"
title="Dark mode is still being rolled out, so some surfaces may not be styled yet"
>
Experimental
</Badge>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" aria-label="Theme" title="Theme" className="text-muted-foreground" />
}
>
{isDark ? <Moon /> : <Sun />}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuRadioGroup value={theme ?? "light"} onValueChange={setTheme}>
{THEMES.map(({ value, label, Icon }) => (
<DropdownMenuRadioItem key={value} value={value}>
<Icon />
{label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</span>
);
};
export default ThemeToggle;

View file

@ -157,6 +157,21 @@ describe("Navbar", () => {
expect(screen.getByRole("link", { name: /litellm brand/i })).toHaveAttribute("href", "/ui");
});
it("pairs the logo with a dark-mode variant that swaps on the dark class", () => {
renderWithProviders(<Navbar {...defaultProps} />);
const [light, dark] = Array.from(screen.getByRole("link", { name: /litellm brand/i }).querySelectorAll("img"));
const classesOf = (el: Element) => new Set(el.className.split(/\s+/));
const lightSrc = light.getAttribute("src") ?? "";
expect(light).toHaveAttribute("src", expect.stringMatching(/\/get_image$/));
expect(dark).toHaveAttribute("src", `${lightSrc}?theme=dark`);
expect(classesOf(light).has("dark:hidden")).toBe(true);
expect(classesOf(light).has("hidden")).toBe(false);
expect(classesOf(dark).has("hidden")).toBe(true);
expect(classesOf(dark).has("dark:block")).toBe(true);
});
it("should display user information in dropdown", async () => {
const user = userEvent.setup();
renderWithProviders(<Navbar {...defaultProps} />);

View file

@ -15,8 +15,10 @@ import React from "react";
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass";
import { cn } from "@/lib/cva.config";
import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell";
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
import ThemeToggle from "./ThemeToggle/ThemeToggle";
import ViewSwitcher from "./Navbar/ViewSwitcher";
import WorkerDropdown from "./Navbar/WorkerDropdown/WorkerDropdown";
@ -27,6 +29,8 @@ interface NavbarProps {
onToggleSidebar?: () => void;
}
const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain";
const Navbar: React.FC<NavbarProps> = ({
accessToken,
isPublicPage = false,
@ -44,6 +48,7 @@ const Navbar: React.FC<NavbarProps> = ({
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const imageUrl = logoUrl || `${baseUrl}/get_image`;
const darkImageUrl = logoUrl || `${baseUrl}/get_image?theme=dark`;
const handleLogout = () => {
clearTokenCookies();
@ -85,10 +90,12 @@ const Navbar: React.FC<NavbarProps> = ({
<Link href={migratedHref("")} className="flex items-center">
<div className="relative">
<div className="flex h-10 max-w-48 items-center justify-center overflow-hidden">
<img src={imageUrl} alt="LiteLLM Brand" className={cn(NAV_LOGO_CLASS_NAME, "dark:hidden")} />
<img
src={imageUrl}
alt="LiteLLM Brand"
className="h-auto max-h-full w-auto max-w-full object-contain"
src={darkImageUrl}
alt=""
aria-hidden
className={cn(NAV_LOGO_CLASS_NAME, "hidden dark:block")}
/>
</div>
</div>
@ -158,6 +165,8 @@ const Navbar: React.FC<NavbarProps> = ({
{!isPublicPage && (
<div className="flex shrink-0 items-center border-l border-border pl-4">
<div className="flex items-center gap-0.5 rounded-lg bg-muted px-1 py-0 transition-colors hover:bg-accent">
<ThemeToggle />
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
<NotificationsBell />
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
<UserDropdown onLogout={handleLogout} />
@ -165,7 +174,6 @@ const Navbar: React.FC<NavbarProps> = ({
</div>
)}
</div>
{/* Dark mode toggle: keep disabled until the dashboard supports dark styles end-to-end. */}
</div>
</div>
</nav>

View file

@ -1,12 +1,15 @@
"use client";
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
function Toaster({ ...props }: ToasterProps) {
const { resolvedTheme } = useTheme();
return (
<Sonner
theme="light"
theme={resolvedTheme === "dark" ? "dark" : "light"}
position="top-right"
closeButton
className="toaster group"

View file

@ -1,42 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useIsDarkMode } from "./useIsDarkMode";
beforeEach(() => {
document.documentElement.classList.remove("dark");
});
afterAll(() => {
document.documentElement.classList.remove("dark");
});
describe("useIsDarkMode", () => {
it("reports the dark class already on the root element at mount", () => {
document.documentElement.classList.add("dark");
const { result } = renderHook(() => useIsDarkMode());
expect(result.current).toBe(true);
});
it("follows the root element's dark class as it is toggled", async () => {
const { result } = renderHook(() => useIsDarkMode());
expect(result.current).toBe(false);
document.documentElement.classList.add("dark");
await waitFor(() => expect(result.current).toBe(true));
document.documentElement.classList.remove("dark");
await waitFor(() => expect(result.current).toBe(false));
});
it("stops observing the root element once unmounted", () => {
const disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
const { unmount } = renderHook(() => useIsDarkMode());
unmount();
expect(disconnect).toHaveBeenCalled();
disconnect.mockRestore();
});
});

View file

@ -1,13 +0,0 @@
import { useSyncExternalStore } from "react";
const subscribe = (onStoreChange: () => void): (() => void) => {
const observer = new MutationObserver(onStoreChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
};
const getSnapshot = (): boolean => document.documentElement.classList.contains("dark");
const getServerSnapshot = (): boolean => false;
export const useIsDarkMode = (): boolean => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

View file

@ -1,47 +1,50 @@
import { act, renderHook } from "@testing-library/react";
import { ThemeProvider, useTheme } from "next-themes";
import type { ReactNode } from "react";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { useSyntaxTheme, type SyntaxTheme } from "./useSyntaxTheme";
const callerLightTheme: SyntaxTheme = { 'code[class*="language-"]': { color: "rebeccapurple" } };
const setRootDark = async (enabled: boolean) => {
await act(async () => {
document.documentElement.classList.toggle("dark", enabled);
await Promise.resolve();
const renderSyntaxTheme = (defaultTheme: string) =>
renderHook(() => ({ syntax: useSyntaxTheme(callerLightTheme), setTheme: useTheme().setTheme }), {
wrapper: ({ children }: { children: ReactNode }) => (
<ThemeProvider attribute="class" enableSystem={false} defaultTheme={defaultTheme}>
{children}
</ThemeProvider>
),
});
};
beforeEach(() => {
document.documentElement.classList.remove("dark");
localStorage.clear();
document.documentElement.classList.remove("dark", "light");
});
afterAll(() => {
document.documentElement.classList.remove("dark");
document.documentElement.classList.remove("dark", "light");
});
describe("useSyntaxTheme", () => {
it("keeps the caller's own stylesheet in light mode", () => {
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
const { result } = renderSyntaxTheme("light");
expect(result.current).toBe(callerLightTheme);
expect(result.current.syntax).toBe(callerLightTheme);
});
it("swaps to oneDark when the root element turns dark", async () => {
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
it("serves oneDark when the resolved theme is dark", () => {
const { result } = renderSyntaxTheme("dark");
await setRootDark(true);
expect(result.current).toBe(oneDark);
expect(result.current.syntax).toBe(oneDark);
});
it("restores the caller's stylesheet when dark mode is turned back off", async () => {
document.documentElement.classList.add("dark");
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
expect(result.current).toBe(oneDark);
it("swaps stylesheets when the theme is changed at runtime", () => {
const { result } = renderSyntaxTheme("light");
await setRootDark(false);
act(() => result.current.setTheme("dark"));
expect(result.current.syntax).toBe(oneDark);
expect(result.current).toBe(callerLightTheme);
act(() => result.current.setTheme("light"));
expect(result.current.syntax).toBe(callerLightTheme);
});
});

View file

@ -1,8 +1,8 @@
import type { CSSProperties } from "react";
import { useTheme } from "next-themes";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useIsDarkMode } from "./useIsDarkMode";
export type SyntaxTheme = Record<string, CSSProperties>;
export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme => (useIsDarkMode() ? oneDark : light);
export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme =>
useTheme().resolvedTheme === "dark" ? oneDark : light;