From 0b7852d646e6b8856043e69a4e097f64ebfc0047 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 16:09:20 -0700 Subject: [PATCH 1/2] 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]; +}; From 51959feb89822eb7ec60b80294f91f60936811e5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 17:19:54 -0700 Subject: [PATCH 2/2] fix(ui): never treat a remote logo URL as a bundled asset The bundled-path check was an unanchored substring match, so a user-supplied logo URL that happened to carry /assets/logos/ or /_next/static/media/ in its path, and whose filename collided with one of the 36 manifest entries, would pick up a dark-mode treatment meant only for assets we ship. assetPaths already draws this line for resolveLogoSrc, which returns an external src untouched. Export that predicate instead of writing a second one, and require a treated src to clear it. The existing test only covered a remote URL with a bare filename, which passed either way. The new ones fail without the guard. --- .../src/components/molecules/logo/Logo.test.tsx | 5 +++++ ui/litellm-dashboard/src/lib/assetPaths.ts | 4 +++- ui/litellm-dashboard/src/lib/logoTreatments.test.ts | 11 +++++++++++ ui/litellm-dashboard/src/lib/logoTreatments.ts | 4 +++- 4 files changed, 22 insertions(+), 2 deletions(-) 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 62ce8e9ee12..a8e7b4e41d4 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx @@ -81,6 +81,11 @@ describe("Logo", () => { expect(screen.getByRole("img", { name: "Ext logo" })).toHaveClass("w-5 h-5", { exact: true }); }); + it("does not treat a user-supplied logo URL that mimics the bundled asset path", () => { + 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)]"); diff --git a/ui/litellm-dashboard/src/lib/assetPaths.ts b/ui/litellm-dashboard/src/lib/assetPaths.ts index abce69127cb..af208e7d78c 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.ts @@ -3,6 +3,8 @@ import { normalizeRootPath } from "@/lib/http/resolveApiBase"; const EXTERNAL_SRC = /^(https?:|data:|blob:|\/\/)/i; +export const isExternalAssetSrc = (value: string): boolean => EXTERNAL_SRC.test(value); + /** * Prefix a root-relative asset path (e.g. "/ui/assets/logos/openai.svg") with the * proxy's server root path so it resolves when the UI is mounted under a sub-path @@ -22,7 +24,7 @@ export const withServerRoot = (path: string, root: string): string => { */ export const resolveLogoSrc = (value: string | null | undefined, root: string = serverRootPath): string | undefined => { if (!value) return undefined; - if (EXTERNAL_SRC.test(value)) return value; + if (isExternalAssetSrc(value)) return value; if (value.includes("/_next/static/")) return value; const prefix = normalizeRootPath(root); if (prefix && (value === prefix || value.startsWith(`${prefix}/`))) return value; diff --git a/ui/litellm-dashboard/src/lib/logoTreatments.test.ts b/ui/litellm-dashboard/src/lib/logoTreatments.test.ts index b0a2073be8d..08ea283a7a3 100644 --- a/ui/litellm-dashboard/src/lib/logoTreatments.test.ts +++ b/ui/litellm-dashboard/src/lib/logoTreatments.test.ts @@ -35,6 +35,17 @@ describe("logoTreatmentFor", () => { expect(logoTreatmentFor("https://cdn.example.com/github.svg")).toBeUndefined(); }); + it("does not treat a remote URL that also carries a bundled-looking path", () => { + expect(logoTreatmentFor("https://cdn.example.com/assets/logos/github.svg")).toBeUndefined(); + expect(logoTreatmentFor("http://cdn.example.com/_next/static/media/github.abc123.svg")).toBeUndefined(); + expect(logoTreatmentFor("//cdn.example.com/assets/logos/github.svg")).toBeUndefined(); + expect(logoTreatmentFor("HTTPS://CDN.EXAMPLE.COM/assets/logos/github.svg")).toBeUndefined(); + }); + + it("does not treat a data URL that happens to contain a bundled-looking path", () => { + expect(logoTreatmentFor("data:image/svg+xml,/assets/logos/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(); }); diff --git a/ui/litellm-dashboard/src/lib/logoTreatments.ts b/ui/litellm-dashboard/src/lib/logoTreatments.ts index f8378800046..7a5a7892d17 100644 --- a/ui/litellm-dashboard/src/lib/logoTreatments.ts +++ b/ui/litellm-dashboard/src/lib/logoTreatments.ts @@ -1,3 +1,5 @@ +import { isExternalAssetSrc } from "@/lib/assetPaths"; + export type LogoTreatment = "invert" | "plate"; const BUNDLED_LOGO_PATH = /(?:\/assets\/logos\/|\/_next\/static\/media\/)/; @@ -49,7 +51,7 @@ const withoutBundlerHash = (basename: string): string | undefined => { }; export const logoTreatmentFor = (src: string | null | undefined): LogoTreatment | undefined => { - if (!src || !BUNDLED_LOGO_PATH.test(src)) return undefined; + if (!src || isExternalAssetSrc(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];