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:
Yuneng Jiang 2026-08-27 16:09:20 -07:00
parent 67c7b97fd2
commit 0b7852d646
No known key found for this signature in database
5 changed files with 158 additions and 1 deletions

View file

@ -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 {

View file

@ -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" />);

View file

@ -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);

View 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();
});
});

View 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];
};