fix(ui): make the theme toggle switch on one click and stop Docs looking dimmer than Blog

The top bar's theme control needed a click on the sun/moon, then a menu, then
a choice, to do something every other product does in one click. It is now a
plain button that flips between light and dark, with the beta marker moved into
the label of the click that turns dark on. An explicit "system" choice is gone,
but next-themes still follows the OS for anyone who has it stored and has not
clicked yet.

Docs and Blog also drifted apart in the gateway header: Blog rendered through
the shared product-link class while Docs was a muted ghost button one size
down, so Docs read as dimmer and sat 4px shorter. Both now go through a shared
DocsLink component, which is also what the legacy navbar uses, so the pair
cannot drift again.
This commit is contained in:
ryan-crabbe-berri 2026-08-27 18:05:04 -07:00
parent 22a349ee70
commit e5dcc6873e
7 changed files with 101 additions and 101 deletions

View file

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { DashboardHeader } from "./DashboardHeader";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
const state = {
@ -55,6 +56,16 @@ describe("DashboardHeader breadcrumb", () => {
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
});
it("styles Docs with the shared product-link class instead of a muted toolbar button", () => {
render(<DashboardHeader page="logs" />);
const docs = screen.getByRole("link", { name: "Docs" });
for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) {
expect(docs).toHaveClass(cls);
}
expect(docs).not.toHaveClass("text-muted-foreground");
});
it("renders the tools divider centered rather than stretched to the top of the row", () => {
const { container } = render(<DashboardHeader page="logs" />);

View file

@ -1,6 +1,5 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Breadcrumb,
BreadcrumbItem,
@ -11,6 +10,7 @@ import {
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
import { getBreadcrumb } from "@/components/leftnav";
import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown";
import { DocsLink } from "@/components/Navbar/DocsLink/DocsLink";
import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
@ -62,15 +62,7 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
<ToolbarSeparator />
</>
)}
<Button
variant="ghost"
size="sm"
nativeButton={false}
render={<a href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer" />}
className="text-muted-foreground"
>
Docs
</Button>
<DocsLink />
<BlogDropdown />
{!hideCommunityLinks && <CommunityEngagementButtons />}
<ToolbarSeparator />

View file

@ -0,0 +1,27 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { DocsLink } from "./DocsLink";
const sharedClasses = NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/);
describe("DocsLink", () => {
it("opens the docs in a new tab without leaking the opener", () => {
render(<DocsLink />);
const link = screen.getByRole("link", { name: "Docs" });
expect(link).toHaveAttribute("href", "https://docs.litellm.ai/docs/");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("carries the same product-link styling as the Blog trigger, so the two never drift apart", () => {
render(<DocsLink />);
const link = screen.getByRole("link", { name: "Docs" });
for (const cls of sharedClasses) {
expect(link).toHaveClass(cls);
}
expect(link).not.toHaveClass("text-muted-foreground");
});
});

View file

@ -0,0 +1,15 @@
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { ChevronDown } from "lucide-react";
import React from "react";
export const DOCS_URL = "https://docs.litellm.ai/docs/";
export const DocsLink: React.FC = () => (
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={NAV_PRODUCT_LINK_CLASS}>
Docs
{/* Docs is a single outbound link; the hidden chevron keeps its box identical to the Blog dropdown trigger. */}
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
</a>
);
export default DocsLink;

View file

@ -11,12 +11,7 @@ const renderToggle = () =>
</ThemeProvider>,
);
const openMenu = async () => {
await userEvent.click(screen.getByRole("button", { name: "Theme" }));
await screen.findByRole("menu");
};
const pick = async (label: string | RegExp) => userEvent.click(screen.getByRole("menuitemradio", { name: label }));
const toggle = () => screen.getByRole("button", { name: /Switch to (light|dark) mode/ });
beforeEach(() => {
localStorage.clear();
@ -28,50 +23,49 @@ afterAll(() => {
});
describe("ThemeToggle", () => {
it("starts on light rather than following the system preference", async () => {
it("switches to dark on a single click, with no menu in between", 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/);
await userEvent.click(toggle());
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(document.documentElement).toHaveClass("dark");
expect(localStorage.getItem("theme")).toBe("dark");
});
it("hands control back to the system preference when asked", async () => {
it("switches back to light on the next click", async () => {
renderToggle();
await openMenu();
await pick(/^Dark/);
await userEvent.click(toggle());
await pick("System");
await userEvent.click(toggle());
expect(document.documentElement).not.toHaveClass("dark");
expect(localStorage.getItem("theme")).toBe("light");
});
it("names the mode the click will switch to, so the button says what it does", async () => {
renderToggle();
expect(screen.getByRole("button", { name: "Switch to dark mode (beta)" })).toBeInTheDocument();
await userEvent.click(toggle());
expect(await screen.findByRole("button", { name: "Switch to light mode" })).toBeInTheDocument();
});
it("leaves a stored system preference following the OS until the user clicks", () => {
localStorage.setItem("theme", "system");
renderToggle();
expect(localStorage.getItem("theme")).toBe("system");
expect(document.documentElement).not.toHaveClass("dark");
});
it("marks dark as beta in the menu, and leaves the other choices unmarked", async () => {
it("keeps the beta marker out of the toolbar label once dark is on", async () => {
renderToggle();
await openMenu();
expect(screen.getByRole("menuitemradio", { name: /^Dark/ })).toHaveTextContent("Beta");
expect(screen.getByRole("menuitemradio", { name: "Light" })).not.toHaveTextContent("Beta");
expect(screen.getByRole("menuitemradio", { name: "System" })).not.toHaveTextContent("Beta");
});
await userEvent.click(toggle());
it("keeps the beta marker inside the menu rather than in the toolbar", async () => {
renderToggle();
await openMenu();
await pick(/^Dark/);
expect(screen.getByRole("button", { name: "Theme" })).not.toHaveTextContent("Beta");
expect(toggle()).not.toHaveTextContent("Beta");
expect(toggle()).toHaveAccessibleName("Switch to light mode");
});
});

View file

@ -1,57 +1,27 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { 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, beta: false },
{ value: "light", label: "Light", Icon: Sun, beta: false },
{ value: "dark", label: "Dark", Icon: Moon, beta: true },
] as const;
const ThemeToggle: React.FC = () => {
const { theme, setTheme, resolvedTheme } = useTheme();
const { setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const label = isDark ? "Switch to light mode" : "Switch to dark mode (beta)";
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" aria-label="Theme" title="Theme" className="text-muted-foreground" />
}
>
{resolvedTheme === "dark" ? <Moon /> : <Sun />}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuRadioGroup value={theme ?? "light"} onValueChange={setTheme}>
{THEMES.map(({ value, label, Icon, beta }) => (
<DropdownMenuRadioItem key={value} value={value}>
<Icon />
{label}
{beta && (
<Badge
variant="secondary"
className="px-1 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"
>
Beta
</Badge>
)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon-sm"
aria-label={label}
title={label}
className="text-muted-foreground"
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <Moon /> : <Sun />}
</Button>
);
};

View file

@ -9,12 +9,12 @@ import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
import { Badge } from "@/components/ui/badge";
import { ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import Link from "next/link";
import React from "react";
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
import { DocsLink } from "./Navbar/DocsLink/DocsLink";
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";
@ -143,16 +143,7 @@ const Navbar: React.FC<NavbarProps> = ({
aria-label="Product documentation"
className={`flex min-w-0 items-center gap-2 ${showWorkerSwitch ? "border-l border-border pl-4" : ""}`}
>
<a
href="https://docs.litellm.ai/docs/"
target="_blank"
rel="noopener noreferrer"
className={NAV_PRODUCT_LINK_CLASS}
>
Docs
{/* Layout parity with Blog chevron — intentional single-level link */}
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
</a>
<DocsLink />
<BlogDropdown />
</nav>