mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
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.
This commit is contained in:
parent
67c7b97fd2
commit
0b7852d646
5 changed files with 158 additions and 1 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,40 @@ describe("Logo", () => {
|
|||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("leaves the caller's class list untouched for an asset that reads on dark", () => {
|
||||
render(<Logo src="/ui/assets/logos/slack.svg" label="Slack" className="w-5 h-5 shrink-0" />);
|
||||
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(<Logo src="/ui/assets/logos/slack.svg" label="Slack" className="w-4 w-5 h-5" />);
|
||||
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(<Logo src="/ui/assets/logos/github.svg" label="GitHub" className="w-5 h-5" />);
|
||||
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(<Logo src="/ui/assets/logos/fireworks.svg" label="Fireworks" className="w-5 h-5" />);
|
||||
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(<Logo src="https://cdn.example.com/github.svg" label="Ext" className="w-5 h-5" />);
|
||||
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(<Logo provider="openrouter" className="w-5 h-5" />);
|
||||
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(<Logo src="/ui/assets/logos/broken.svg" label="Agent" />);
|
||||
|
|
|
|||
|
|
@ -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<Record<LogoTreatment, string>> = {
|
||||
invert: "dark:[filter:brightness(0)_invert(1)]",
|
||||
plate: "dark:bg-logo-surface dark:object-contain dark:p-0.5",
|
||||
};
|
||||
|
||||
export const Logo: React.FC<LogoProps> = ({ provider, src, label, className = "w-4 h-4" }) => {
|
||||
const [erroredSrc, setErroredSrc] = useState<string | null>(null);
|
||||
const resolvedSrc = provider !== undefined ? getProviderLogoAndName(provider).logo : resolveLogoSrc(src) ?? "";
|
||||
|
|
@ -20,11 +27,13 @@ export const Logo: React.FC<LogoProps> = ({ provider, src, label, className = "w
|
|||
);
|
||||
}
|
||||
|
||||
const treatment = logoTreatmentFor(resolvedSrc);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={`${name || "-"} logo`}
|
||||
className={className}
|
||||
className={treatment === undefined ? className : cn(className, DARK_TREATMENT_CLASS[treatment])}
|
||||
onError={() => {
|
||||
console.warn(`Logo failed to load: ${resolvedSrc}`);
|
||||
setErroredSrc(resolvedSrc);
|
||||
|
|
|
|||
56
ui/litellm-dashboard/src/lib/logoTreatments.test.ts
Normal file
56
ui/litellm-dashboard/src/lib/logoTreatments.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
56
ui/litellm-dashboard/src/lib/logoTreatments.ts
Normal file
56
ui/litellm-dashboard/src/lib/logoTreatments.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export type LogoTreatment = "invert" | "plate";
|
||||
|
||||
const BUNDLED_LOGO_PATH = /(?:\/assets\/logos\/|\/_next\/static\/media\/)/;
|
||||
|
||||
const TREATMENT_BY_ASSET: Readonly<Record<string, LogoTreatment>> = {
|
||||
"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];
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue