From 0b7852d646e6b8856043e69a4e097f64ebfc0047 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 16:09:20 -0700 Subject: [PATCH] feat(ui): make provider logos readable in dark mode The dashboard's dark theme left a chunk of the bundled provider logos unreadable: 25 of them are pure black marks on a transparent background, so on a near-black surface they disappeared entirely, and another 11 are dark multicolor marks drawn for a white page. This adds the seam the rest of the work hangs off: a per-asset treatment manifest in logoTreatments.ts, and a Logo component that applies the treatment it names. Two treatments exist today. "invert" flattens a mark to solid white with brightness(0) invert(1), which is what the vendor's own white mark looks like for a pure-black transparent glyph. "plate" puts a white surface behind the mark so it reads exactly as it does on a light page. Both are dark-only, and only assets named in the manifest are touched, so light mode is unchanged and the other 96 bundled logos keep rendering byte for byte as they do today. The className an untreated logo receives is passed through verbatim rather than routed through cn(), so even the class string is unchanged. The split between invert and plate was measured per asset, not guessed: luminance, saturation and alpha coverage sampled off a canvas render. Two assets that look monochrome, aiml_api and repelloai, carry a light knockout inside dark artwork, so inversion would flatten the knockout into the mark and erase it. They get a plate instead, and a test pins that. Six assets whose artwork is an opaque dark box (aim_logo, aim_security, deepgram, jina, lakeraai, openmeter) are deliberately left untreated. A plate cannot show through an opaque image, so the only honest fix for them is a replacement asset. --- ui/litellm-dashboard/src/app/globals.css | 2 + .../components/molecules/logo/Logo.test.tsx | 34 +++++++++++ .../src/components/molecules/logo/Logo.tsx | 11 +++- .../src/lib/logoTreatments.test.ts | 56 +++++++++++++++++++ .../src/lib/logoTreatments.ts | 56 +++++++++++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/lib/logoTreatments.test.ts create mode 100644 ui/litellm-dashboard/src/lib/logoTreatments.ts diff --git a/ui/litellm-dashboard/src/app/globals.css b/ui/litellm-dashboard/src/app/globals.css index f389fa5df3d..87959ca0139 100644 --- a/ui/litellm-dashboard/src/app/globals.css +++ b/ui/litellm-dashboard/src/app/globals.css @@ -140,6 +140,7 @@ --sidebar-border: oklch(0.928 0.006 264.531); --sidebar-ring: oklch(0.707 0.022 261.325); --neutral-border: #dcddeb; + --logo-surface: oklch(1 0 0); } .dark { @@ -227,6 +228,7 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-logo-surface: var(--logo-surface); } @layer base { diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx index 5e4da208f4e..62ce8e9ee12 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx @@ -52,6 +52,40 @@ describe("Logo", () => { warnSpy.mockRestore(); }); + it("leaves the caller's class list untouched for an asset that reads on dark", () => { + render(); + expect(screen.getByRole("img", { name: "Slack logo" })).toHaveClass("w-5 h-5 shrink-0", { exact: true }); + }); + + it("passes an untreated logo's classes through verbatim rather than normalizing them", () => { + render(); + expect(screen.getByRole("img", { name: "Slack logo" })).toHaveClass("w-4 w-5 h-5", { exact: true }); + }); + + it("forces a monochrome mark to white on dark without disturbing the caller's classes", () => { + render(); + const img = screen.getByRole("img", { name: "GitHub logo" }); + expect(img).toHaveClass("w-5", "h-5", "dark:[filter:brightness(0)_invert(1)]"); + expect(img).not.toHaveClass("dark:bg-logo-surface"); + }); + + it("plates a multicolor dark mark rather than inverting it", () => { + render(); + const img = screen.getByRole("img", { name: "Fireworks logo" }); + expect(img).toHaveClass("dark:bg-logo-surface", "dark:object-contain", "dark:p-0.5"); + expect(img).not.toHaveClass("dark:[filter:brightness(0)_invert(1)]"); + }); + + it("does not treat an external logo URL that collides with a bundled filename", () => { + render(); + expect(screen.getByRole("img", { name: "Ext logo" })).toHaveClass("w-5 h-5", { exact: true }); + }); + + it("applies the treatment to a provider logo resolved through the bundler", () => { + render(); + expect(screen.getByRole("img", { name: "openrouter logo" })).toHaveClass("dark:[filter:brightness(0)_invert(1)]"); + }); + it("retries with a new src after a previous src errored", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { rerender } = render(); diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx index d388e3dc07d..66c10f1e222 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx @@ -1,12 +1,19 @@ import React, { useState } from "react"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { resolveLogoSrc } from "@/lib/assetPaths"; +import { cn } from "@/lib/cva.config"; +import { logoTreatmentFor, type LogoTreatment } from "@/lib/logoTreatments"; type LogoProps = { className?: string } & ( | { provider: string; src?: never; label?: string } | { provider?: never; src: string | null | undefined; label: string } ); +const DARK_TREATMENT_CLASS: Readonly> = { + invert: "dark:[filter:brightness(0)_invert(1)]", + plate: "dark:bg-logo-surface dark:object-contain dark:p-0.5", +}; + export const Logo: React.FC = ({ provider, src, label, className = "w-4 h-4" }) => { const [erroredSrc, setErroredSrc] = useState(null); const resolvedSrc = provider !== undefined ? getProviderLogoAndName(provider).logo : resolveLogoSrc(src) ?? ""; @@ -20,11 +27,13 @@ export const Logo: React.FC = ({ provider, src, label, className = "w ); } + const treatment = logoTreatmentFor(resolvedSrc); + return ( {`${name { console.warn(`Logo failed to load: ${resolvedSrc}`); setErroredSrc(resolvedSrc); diff --git a/ui/litellm-dashboard/src/lib/logoTreatments.test.ts b/ui/litellm-dashboard/src/lib/logoTreatments.test.ts new file mode 100644 index 00000000000..b0a2073be8d --- /dev/null +++ b/ui/litellm-dashboard/src/lib/logoTreatments.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { logoTreatmentFor } from "./logoTreatments"; + +describe("logoTreatmentFor", () => { + it("marks a monochrome transparent mark for inversion", () => { + expect(logoTreatmentFor("/ui/assets/logos/github.svg")).toBe("invert"); + }); + + it("marks a multicolor dark mark for a plate instead of inversion", () => { + expect(logoTreatmentFor("/ui/assets/logos/fireworks.svg")).toBe("plate"); + }); + + it("plates a dark mark with a light knockout, which inversion would flatten away", () => { + expect(logoTreatmentFor("/ui/assets/logos/repelloai.png")).toBe("plate"); + expect(logoTreatmentFor("/ui/assets/logos/aiml_api.svg")).toBe("plate"); + }); + + it("leaves an asset that already reads on dark untreated", () => { + expect(logoTreatmentFor("/ui/assets/logos/slack.svg")).toBeUndefined(); + }); + + it("resolves through a bundler fingerprint in the filename", () => { + expect(logoTreatmentFor("/litellm-asset-prefix/_next/static/media/openrouter.1xk7748-_jixf.svg")).toBe("invert"); + }); + + it("resolves a bundled asset served under a proxy root path", () => { + expect(logoTreatmentFor("/litellm/ui/assets/logos/notion.svg")).toBe("invert"); + }); + + it("ignores a query string and fragment on the asset URL", () => { + expect(logoTreatmentFor("/ui/assets/logos/vercel.svg?v=2#icon")).toBe("invert"); + }); + + it("does not treat an external URL whose filename collides with a bundled asset", () => { + expect(logoTreatmentFor("https://cdn.example.com/github.svg")).toBeUndefined(); + }); + + it("does not treat a non-logo path whose filename collides with a bundled asset", () => { + expect(logoTreatmentFor("/uploads/user/github.svg")).toBeUndefined(); + }); + + it("leaves an opaque dark box untreated, since a plate behind it cannot show through", () => { + expect(logoTreatmentFor("/ui/assets/logos/lakeraai.jpeg")).toBeUndefined(); + }); + + it("returns undefined for empty and nullish input", () => { + expect(logoTreatmentFor(null)).toBeUndefined(); + expect(logoTreatmentFor(undefined)).toBeUndefined(); + expect(logoTreatmentFor("")).toBeUndefined(); + }); + + it("distinguishes assets that share a stem but differ by extension", () => { + expect(logoTreatmentFor("/ui/assets/logos/runway.png")).toBe("invert"); + expect(logoTreatmentFor("/ui/assets/logos/runway.svg")).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/logoTreatments.ts b/ui/litellm-dashboard/src/lib/logoTreatments.ts new file mode 100644 index 00000000000..f8378800046 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/logoTreatments.ts @@ -0,0 +1,56 @@ +export type LogoTreatment = "invert" | "plate"; + +const BUNDLED_LOGO_PATH = /(?:\/assets\/logos\/|\/_next\/static\/media\/)/; + +const TREATMENT_BY_ASSET: Readonly> = { + "baseten.svg": "invert", + "cursor.svg": "invert", + "enkrypt_ai.avif": "invert", + "friendli.svg": "invert", + "github.svg": "invert", + "github_copilot.svg": "invert", + "lago.svg": "invert", + "lambda.svg": "invert", + "langflow.svg": "invert", + "lmstudio.svg": "invert", + "moonshot.svg": "invert", + "nebius.svg": "invert", + "notion.svg": "invert", + "ollama.svg": "invert", + "openrouter.svg": "invert", + "promptguard.svg": "invert", + "recraft.svg": "invert", + "replicate.svg": "invert", + "runway.png": "invert", + "scx_ai.svg": "invert", + "secret_detect.png": "invert", + "topaz.svg": "invert", + "v0.svg": "invert", + "vercel.svg": "invert", + "watsonx.svg": "invert", + "aiml_api.svg": "plate", + "akto.svg": "plate", + "aws.svg": "plate", + "deepkeep.svg": "plate", + "fireworks.svg": "plate", + "llm_guard.png": "plate", + "pangea.png": "plate", + "repelloai.png": "plate", + "sambanova.svg": "plate", + "sentry.svg": "plate", + "valkey.svg": "plate", +}; + +const basenameOf = (src: string): string | undefined => src.split(/[?#]/)[0].split("/").pop() || undefined; + +const withoutBundlerHash = (basename: string): string | undefined => { + const parts = basename.split("."); + return parts.length < 2 ? undefined : `${parts[0]}.${parts[parts.length - 1]}`; +}; + +export const logoTreatmentFor = (src: string | null | undefined): LogoTreatment | undefined => { + if (!src || !BUNDLED_LOGO_PATH.test(src)) return undefined; + const basename = basenameOf(src); + const key = basename === undefined ? undefined : withoutBundlerHash(basename); + return key === undefined ? undefined : TREATMENT_BY_ASSET[key]; +};