From e5dcc6873ec060d57038dd4093ab3b803b229a4a Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 27 Aug 2026 18:05:04 -0700 Subject: [PATCH 1/3] 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. --- .../src/components/DashboardHeader.test.tsx | 11 ++++ .../src/components/DashboardHeader.tsx | 12 +--- .../Navbar/DocsLink/DocsLink.test.tsx | 27 ++++++++ .../components/Navbar/DocsLink/DocsLink.tsx | 15 +++++ .../ThemeToggle/ThemeToggle.test.tsx | 64 +++++++++---------- .../components/ThemeToggle/ThemeToggle.tsx | 58 ++++------------- .../src/components/navbar.tsx | 15 +---- 7 files changed, 101 insertions(+), 101 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 6c656382deb..6a06e1ba612 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -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(); + + 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(); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index 31f2a0b715a..fe824ce074d 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -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) { )} - + {!hideCommunityLinks && } diff --git a/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.test.tsx b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.test.tsx new file mode 100644 index 00000000000..d5a155cf503 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.test.tsx @@ -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(); + + 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(); + + const link = screen.getByRole("link", { name: "Docs" }); + for (const cls of sharedClasses) { + expect(link).toHaveClass(cls); + } + expect(link).not.toHaveClass("text-muted-foreground"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx new file mode 100644 index 00000000000..9176e1f9c38 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx @@ -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 = () => ( + + Docs + {/* Docs is a single outbound link; the hidden chevron keeps its box identical to the Blog dropdown trigger. */} + + +); + +export default DocsLink; diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx index 41efedcd43a..c5dda3d3f7c 100644 --- a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx +++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx @@ -11,12 +11,7 @@ const renderToggle = () => , ); -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"); }); }); diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx index 3fbb3d2eb5b..2f941dbc2d2 100644 --- a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx +++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx @@ -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 ( - - - } - > - {resolvedTheme === "dark" ? : } - - - - {THEMES.map(({ value, label, Icon, beta }) => ( - - - {label} - {beta && ( - - Beta - - )} - - ))} - - - + ); }; diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index e81c67cc958..88cd125ea15 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -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 = ({ aria-label="Product documentation" className={`flex min-w-0 items-center gap-2 ${showWorkerSwitch ? "border-l border-border pl-4" : ""}`} > - - Docs - {/* Layout parity with Blog chevron — intentional single-level link */} - - + From 929946bdc17e375946ebea27e216302425d3c641 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 27 Aug 2026 20:50:41 -0700 Subject: [PATCH 2/3] fix(ui): give the shared product-link class a focus ring Docs went from a ghost Button to a plain anchor, which dropped the focus treatment the Button was supplying, so tabbing to Docs showed nothing while tabbing to Blog showed a ring. The ring now lives on the shared class both sides use, matching the Button primitive's values. Kept Docs as a real anchor rather than routing it back through Button: nativeButton={false} stamps role="button" onto the element, so the old DashboardHeader markup announced Docs as a button and lost its link semantics. Tests pin both the ring and the link role. --- .../components/Navbar/DocsLink/DocsLink.test.tsx | 15 +++++++++++++++ .../src/components/Navbar/DocsLink/DocsLink.tsx | 7 +++++-- .../src/components/Navbar/navProductLinkClass.ts | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.test.tsx b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.test.tsx index d5a155cf503..dec76041802 100644 --- a/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.test.tsx @@ -24,4 +24,19 @@ describe("DocsLink", () => { } expect(link).not.toHaveClass("text-muted-foreground"); }); + + it("carries a focus ring, so tabbing to Docs looks like tabbing to Blog", () => { + render(); + + const link = screen.getByRole("link", { name: "Docs" }); + expect(link).toHaveClass("focus-visible:ring-3"); + expect(link).toHaveClass("focus-visible:ring-ring/50"); + }); + + it("stays a link rather than being relabelled as a button by the Button primitive", () => { + render(); + + expect(screen.getByRole("link", { name: "Docs" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Docs" })).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx index 9176e1f9c38..9b4f4c90b64 100644 --- a/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/DocsLink/DocsLink.tsx @@ -4,11 +4,14 @@ import React from "react"; export const DOCS_URL = "https://docs.litellm.ai/docs/"; +const ChevronWidthSpacer: React.FC = () => ( + +); + export const DocsLink: React.FC = () => ( Docs - {/* Docs is a single outbound link; the hidden chevron keeps its box identical to the Blog dropdown trigger. */} - + ); diff --git a/ui/litellm-dashboard/src/components/Navbar/navProductLinkClass.ts b/ui/litellm-dashboard/src/components/Navbar/navProductLinkClass.ts index e0fd000ab97..56b0d51401d 100644 --- a/ui/litellm-dashboard/src/components/Navbar/navProductLinkClass.ts +++ b/ui/litellm-dashboard/src/components/Navbar/navProductLinkClass.ts @@ -1,3 +1,3 @@ -/** Shared styling for Docs / Blog in the top nav (product navigation zone). */ +/** Shared styling for Docs / Blog in the top nav (product navigation zone). Focus ring matches the Button primitive. */ export const NAV_PRODUCT_LINK_CLASS = - "inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-sm font-medium leading-none text-foreground transition-colors hover:bg-accent "; + "inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-sm font-medium leading-none text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 "; From b72b9126b59aa72184ba81fc1acbc3b497386116 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 27 Aug 2026 20:58:16 -0700 Subject: [PATCH 3/3] fix(ui): restore the Blog hover highlight in the top bar The Blog trigger carried `bg-transparent!`, which emits an important background-color and so beat the non-important `hover:bg-accent` the shared product-link class supplies. Docs lit up on hover and Blog stayed flat, the same Docs/Blog inconsistency this branch is about on a different axis. Dropping the override lets the shared hover through. `border-0!` stays, since it keeps the trigger's box identical to the plain Docs anchor. Verified in the browser: both now paint lab(96.1596 -0.0823438 -1.13575) on hover at 36px tall. --- .../components/Navbar/BlogDropdown/BlogDropdown.test.tsx | 8 ++++++++ .../src/components/Navbar/BlogDropdown/BlogDropdown.tsx | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx index 0581a381a9a..4da50a266f0 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -66,6 +66,14 @@ describe("BlogDropdown", () => { expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument(); }); + it("should keep the shared hover highlight rather than pinning the background transparent", () => { + renderWithProviders(); + + const trigger = screen.getByRole("button", { name: /blog/i }); + expect(trigger).toHaveClass("hover:bg-accent"); + expect(trigger.className).not.toMatch(/\bbg-\S*!/); + }); + it("should not render menu content before the trigger is hovered", () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } }; renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index c4ff1ec72dd..eaaf3301a8b 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -85,7 +85,7 @@ export const BlogDropdown: React.FC = () => { } + render={