feat(ui): wire up dark mode toggle in dashboard

Centralizes dark mode state in ThemeContext so a single source manages the
`dark` class on <html>, persists the preference to localStorage, and wraps
children with antd's ConfigProvider (darkAlgorithm). The previously
hidden navbar toggle is now rendered and functional.

- ThemeContext now exposes isDarkMode / toggleDarkMode and applies the
  Tailwind `dark` class + antd dark algorithm based on the stored value.
- Navbar isDarkMode / toggleDarkMode props are optional and fall back to
  the context; the toggle is no longer gated behind a `false &&` flag.
- Root page and dashboard layout consume the context instead of holding
  their own local dark mode state / duplicate ConfigProvider.
- Tailwind dark variants added to navbar and dashboard shell so the most
  visible surfaces respect the theme.
- globals.css adds `.dark` overrides for the body background tokens.

https://claude.ai/code/session_01SkckoL68328QgB5ifQwntT
This commit is contained in:
Claude 2026-05-19 16:13:23 +00:00
parent cff3e0b75e
commit cf059b94cc
No known key found for this signature in database
7 changed files with 160 additions and 80 deletions

View file

@ -2,7 +2,7 @@
import React, { Suspense, useEffect, useState } from "react";
import Navbar from "@/components/navbar";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { ThemeProvider, useTheme } from "@/contexts/ThemeContext";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useRouter, useSearchParams } from "next/navigation";
@ -34,10 +34,11 @@ const MIGRATED_PAGES: Record<string, string> = {
"api-reference": "api-reference",
};
function LayoutContent({ children }: { children: React.ReactNode }) {
function DashboardShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const searchParams = useSearchParams();
const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized();
const { isDarkMode, toggleDarkMode } = useTheme();
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
const [page, setPage] = useState(() => {
return searchParams.get("page") || "api-keys";
@ -64,34 +65,36 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
const toggleSidebar = () => setSidebarCollapsed((v) => !v);
return (
<ThemeProvider accessToken={""}>
<div className="flex flex-col min-h-screen">
<Navbar
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
userID={userId}
userEmail={userEmail}
userRole={userRole}
premiumUser={premiumUser}
proxySettings={undefined}
setProxySettings={() => { }}
accessToken={accessToken}
isDarkMode={false}
toggleDarkMode={() => { }}
/>
<DebugWarningBanner accessToken={accessToken} />
<div className="flex flex-1 overflow-auto">
<div className="mt-2">
<SidebarProvider
setPage={handleSetPage}
defaultSelectedKey={page}
sidebarCollapsed={sidebarCollapsed}
/>
</div>
<main className="flex-1">{children}</main>
<div className="flex flex-col min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<Navbar
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
userID={userId}
userEmail={userEmail}
userRole={userRole}
premiumUser={premiumUser}
proxySettings={undefined}
setProxySettings={() => {}}
accessToken={accessToken}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
/>
<DebugWarningBanner accessToken={accessToken} />
<div className="flex flex-1 overflow-auto">
<div className="mt-2">
<SidebarProvider setPage={handleSetPage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
</div>
<main className="flex-1">{children}</main>
</div>
</div>
);
}
function LayoutContent({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider accessToken={""}>
<DashboardShell>{children}</DashboardShell>
</ThemeProvider>
);
}

View file

@ -9,13 +9,12 @@
--neutral-border: #dcddeb;
}
/* @media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
} */
.dark {
--foreground-rgb: 229, 231, 235; /* gray-200 */
--background-start-rgb: 17, 24, 39; /* gray-900 */
--background-end-rgb: 17, 24, 39;
--neutral-border: #374151; /* gray-700 */
}
body {
color: rgb(var(--foreground-rgb));

View file

@ -52,7 +52,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { jwtDecode } from "jwt-decode";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { ConfigProvider, theme } from "antd";
function deleteCookie(name: string, path = "/") {
// Best-effort client-side clear (works for non-HttpOnly cookies without Domain)
@ -110,12 +109,6 @@ function CreateKeyPageContent() {
const [showClaudeCodePrompt, setShowClaudeCodePrompt] = useState(false);
const [showClaudeCodeModal, setShowClaudeCodeModal] = useState(false);
// Dark mode state
const [isDarkMode, setIsDarkMode] = useState(false);
const toggleDarkMode = () => {
setIsDarkMode(!isDarkMode);
};
const invitation_id = searchParams.get("invitation_id");
// Parse URL query parameters for pre-filling the create key form
@ -450,10 +443,7 @@ function CreateKeyPageContent() {
return (
<Suspense fallback={<LoadingScreen />}>
<ConfigProvider theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}>
<ThemeProvider accessToken={accessToken}>
<ThemeProvider accessToken={accessToken}>
{invitation_id ? (
<UserDashboard
userID={userID}
@ -471,7 +461,7 @@ function CreateKeyPageContent() {
createClicked={createClicked}
/>
) : (
<div className="flex flex-col min-h-screen">
<div className="flex flex-col min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<Navbar
userID={userID}
userRole={userRole}
@ -483,8 +473,6 @@ function CreateKeyPageContent() {
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
/>
<div className="flex flex-1">
<div className="mt-2">
@ -682,7 +670,6 @@ function CreateKeyPageContent() {
</div>
)}
</ThemeProvider>
</ConfigProvider>
</Suspense>
);
}

View file

@ -89,7 +89,11 @@ vi.mock("./Navbar/CommunityEngagementButtons/CommunityEngagementButtons", () =>
}));
// Create mock functions that can be controlled in tests
let mockUseThemeImpl = () => ({ logoUrl: null as string | null });
let mockUseThemeImpl = () => ({
logoUrl: null as string | null,
isDarkMode: false,
toggleDarkMode: vi.fn(),
});
let mockUseHealthReadinessDetailsImpl = () => ({ data: null as any });
let mockGetLocalStorageItemImpl = (key: string) => null as string | null;
let mockUseAuthorizedImpl = () => ({
@ -236,7 +240,11 @@ describe("Navbar", () => {
});
it("should use custom logo from theme context", () => {
mockUseThemeImpl = () => ({ logoUrl: "https://example.com/custom-logo.png" });
mockUseThemeImpl = () => ({
logoUrl: "https://example.com/custom-logo.png",
isDarkMode: false,
toggleDarkMode: vi.fn(),
});
renderWithProviders(<Navbar {...defaultProps} />);
@ -244,7 +252,7 @@ describe("Navbar", () => {
expect(logoImg).toHaveAttribute("src", "https://example.com/custom-logo.png");
// Reset mock
mockUseThemeImpl = () => ({ logoUrl: null });
mockUseThemeImpl = () => ({ logoUrl: null, isDarkMode: false, toggleDarkMode: vi.fn() });
});
it("should hide user dropdown on public pages", () => {
@ -304,10 +312,38 @@ describe("Navbar", () => {
expect(window.location.href).toBe("");
});
it("should not render dark mode toggle slider", () => {
it("should render dark mode toggle", () => {
renderWithProviders(<Navbar {...defaultProps} />);
// DO NOT RENDER THIS UNTIL ALL COMPONENTS ARE CONFIRMED TO SUPPORT DARK MODE STYLES. IT IS AN ISSUE IF THIS TEST FAILS.
expect(screen.queryByTestId("dark-mode-toggle")).not.toBeInTheDocument();
expect(screen.getByTestId("dark-mode-toggle")).toBeInTheDocument();
});
it("should call toggleDarkMode when toggle is clicked", async () => {
const user = userEvent.setup();
const mockToggle = vi.fn();
renderWithProviders(<Navbar {...defaultProps} isDarkMode={false} toggleDarkMode={mockToggle} />);
await user.click(screen.getByTestId("dark-mode-toggle"));
expect(mockToggle).toHaveBeenCalledTimes(1);
});
it("should fall back to ThemeContext when toggle props are not provided", async () => {
const user = userEvent.setup();
const ctxToggle = vi.fn();
mockUseThemeImpl = () => ({
logoUrl: null,
isDarkMode: true,
toggleDarkMode: ctxToggle,
});
const { isDarkMode, toggleDarkMode, ...rest } = defaultProps;
renderWithProviders(<Navbar {...rest} />);
await user.click(screen.getByTestId("dark-mode-toggle"));
expect(ctxToggle).toHaveBeenCalledTimes(1);
// Reset mock
mockUseThemeImpl = () => ({ logoUrl: null, isDarkMode: false, toggleDarkMode: vi.fn() });
});
});

View file

@ -25,8 +25,10 @@ interface NavbarProps {
isPublicPage: boolean;
sidebarCollapsed?: boolean;
onToggleSidebar?: () => void;
isDarkMode: boolean;
toggleDarkMode: () => void;
/** Optional override; defaults to the value from ThemeContext. */
isDarkMode?: boolean;
/** Optional override; defaults to the toggler from ThemeContext. */
toggleDarkMode?: () => void;
}
const Navbar: React.FC<NavbarProps> = ({
@ -40,12 +42,14 @@ const Navbar: React.FC<NavbarProps> = ({
isPublicPage = false,
sidebarCollapsed = false,
onToggleSidebar,
isDarkMode,
toggleDarkMode,
isDarkMode: isDarkModeProp,
toggleDarkMode: toggleDarkModeProp,
}) => {
const baseUrl = getProxyBaseUrl();
const [logoutUrl, setLogoutUrl] = useState("");
const { logoUrl } = useTheme();
const { logoUrl, isDarkMode: isDarkModeCtx, toggleDarkMode: toggleDarkModeCtx } = useTheme();
const isDarkMode = isDarkModeProp ?? isDarkModeCtx;
const toggleDarkMode = toggleDarkModeProp ?? toggleDarkModeCtx;
const { data: healthData } = useHealthReadinessDetails(accessToken);
const version = healthData?.litellm_version;
const disableBouncingIcon = useDisableBouncingIcon();
@ -87,14 +91,14 @@ const Navbar: React.FC<NavbarProps> = ({
};
return (
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
<nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
<div className="w-full">
<div className="flex items-center h-14 px-4">
<div className="flex items-center flex-shrink-0">
{onToggleSidebar && (
<button
onClick={onToggleSidebar}
className="flex items-center justify-center w-10 h-10 mr-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
className="flex items-center justify-center w-10 h-10 mr-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<span className="text-lg">{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}</span>
@ -142,17 +146,14 @@ const Navbar: React.FC<NavbarProps> = ({
<div className="flex items-center space-x-5 ml-auto">
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
<CommunityEngagementButtons />
{/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below.
Do not set this to true by default until all components are confirmed to support dark mode styles. */}
{false && (
<Switch
data-testid="dark-mode-toggle"
checked={isDarkMode}
onChange={toggleDarkMode}
checkedChildren={<MoonOutlined />}
unCheckedChildren={<SunOutlined />}
/>
)}
<Switch
data-testid="dark-mode-toggle"
aria-label="Toggle dark mode"
checked={isDarkMode}
onChange={toggleDarkMode}
checkedChildren={<MoonOutlined />}
unCheckedChildren={<SunOutlined />}
/>
<Button type="text" href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer">
Docs
</Button>

View file

@ -979,7 +979,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
return (
<ThemeProvider accessToken={accessToken}>
<div className={isEmbedded ? "w-full" : "min-h-screen bg-white"}>
<div className={isEmbedded ? "w-full" : "min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"}>
{/* Navigation - only show when not embedded */}
{!isEmbedded && (
<Navbar
@ -991,8 +991,6 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
proxySettings={proxySettings}
accessToken={accessToken || null}
isPublicPage={true}
isDarkMode={false}
toggleDarkMode={() => {}}
/>
)}

View file

@ -1,11 +1,17 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react";
import { ConfigProvider, theme as antdTheme } from "antd";
import { getProxyBaseUrl } from "@/components/networking";
const DARK_MODE_STORAGE_KEY = "litellm-dark-mode";
interface ThemeContextType {
logoUrl: string | null;
setLogoUrl: (url: string | null) => void;
faviconUrl: string | null;
setFaviconUrl: (url: string | null) => void;
isDarkMode: boolean;
toggleDarkMode: () => void;
setIsDarkMode: (value: boolean) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@ -23,9 +29,51 @@ interface ThemeProviderProps {
accessToken?: string | null;
}
const readInitialDarkMode = (): boolean => {
if (typeof window === "undefined") return false;
try {
const stored = window.localStorage.getItem(DARK_MODE_STORAGE_KEY);
if (stored !== null) return stored === "true";
} catch {
// ignore localStorage access errors (e.g. privacy mode)
}
return false;
};
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessToken }) => {
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
const [isDarkMode, setIsDarkModeState] = useState<boolean>(false);
// Hydrate dark mode from localStorage after mount to avoid SSR mismatch.
useEffect(() => {
setIsDarkModeState(readInitialDarkMode());
}, []);
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (isDarkMode) {
root.classList.add("dark");
root.style.colorScheme = "dark";
} else {
root.classList.remove("dark");
root.style.colorScheme = "light";
}
try {
window.localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
} catch {
// ignore localStorage write errors
}
}, [isDarkMode]);
const setIsDarkMode = useCallback((value: boolean) => {
setIsDarkModeState(value);
}, []);
const toggleDarkMode = useCallback(() => {
setIsDarkModeState((prev) => !prev);
}, []);
useEffect(() => {
const loadThemeSettings = async () => {
@ -71,8 +119,16 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessTo
}, [faviconUrl]);
return (
<ThemeContext.Provider value={{ logoUrl, setLogoUrl, faviconUrl, setFaviconUrl }}>
{children}
<ThemeContext.Provider
value={{ logoUrl, setLogoUrl, faviconUrl, setFaviconUrl, isDarkMode, toggleDarkMode, setIsDarkMode }}
>
<ConfigProvider
theme={{
algorithm: isDarkMode ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
}}
>
{children}
</ConfigProvider>
</ThemeContext.Provider>
);
};