mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
3a31331435
commit
933e28d900
13 changed files with 216 additions and 90 deletions
11
ui/litellm-dashboard/package-lock.json
generated
11
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue