mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
Merge pull request #38588 from BerriAI/litellm_/dark-mode-logo-strategy-7b99f2
feat(ui): make provider logos readable in dark mode
This commit is contained in:
commit
733d0b5af5
6 changed files with 179 additions and 2 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,45 @@ 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("does not treat a user-supplied logo URL that mimics the bundled asset path", () => {
|
||||
render(<Logo src="https://cdn.example.com/assets/logos/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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
67
ui/litellm-dashboard/src/lib/logoTreatments.test.ts
Normal file
67
ui/litellm-dashboard/src/lib/logoTreatments.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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 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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
58
ui/litellm-dashboard/src/lib/logoTreatments.ts
Normal file
58
ui/litellm-dashboard/src/lib/logoTreatments.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { isExternalAssetSrc } from "@/lib/assetPaths";
|
||||
|
||||
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 || 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];
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue