diff --git a/gui/src/components/provider-workspace/ProviderRail.tsx b/gui/src/components/provider-workspace/ProviderRail.tsx
index db6964f8de..22858d35dd 100644
--- a/gui/src/components/provider-workspace/ProviderRail.tsx
+++ b/gui/src/components/provider-workspace/ProviderRail.tsx
@@ -13,7 +13,7 @@ import {
type WorkspaceProvider,
} from "../../provider-workspace/catalog";
import { isLocalProvider } from "../../provider-workspace/kind";
-import { formatProviderDisplayName, providerIconSrc } from "../../provider-icons";
+import { formatProviderDisplayName, providerIconPaint, providerIconSrc } from "../../provider-icons";
export function statusLabel(p: WorkspaceProvider, t: TFn): string {
const s = binProviderStatus(p);
@@ -50,9 +50,30 @@ export function ProviderIcon({ name, adapter, baseUrl, cls }: {
}) {
const t = useT();
const src = providerIconSrc(name, { adapter, baseUrl });
+ /*
+ * Three ways to paint a mark, because two of them are wrong for most files.
+ *
+ * An
keeps the vendor's colours, which is what a brand deserves and what
+ * works whenever the artwork has enough contrast against both tiles. A neutral
+ * silhouette does not: one fill of near-black or near-white vanishes against
+ * one of the two surfaces, so it is drawn as a themed mask instead. And a mark
+ * that carries real colour but is dominantly dark can be neither -- masking
+ * would flatten its palette, leaving it alone leaves it invisible -- so its own
+ * artwork sits on a constant light plate, the way a favicon already assumes.
+ */
+ const paint = providerIconPaint(src);
+ const tileClass = paint === "plate" ? `${cls} provider-icon--plate`
+ : paint === "dark-plate" ? `${cls} provider-icon--plate-dark`
+ : cls;
return (
-
- {src ? (
+
+ {src && paint === "mask" ? (
+
+ ) : src ? (
) : (
diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts
index 2bf4d6c5a3..3192862648 100644
--- a/gui/src/provider-icons.ts
+++ b/gui/src/provider-icons.ts
@@ -169,6 +169,108 @@ export function providerIconSrc(provider: string, _hints?: ProviderIconHints): s
return icon ? `/provider-icons/${icon}` : undefined;
}
+/**
+ * Marks whose artwork is one neutral ink, so the ink has to come from the theme.
+ *
+ * Keyed by asset path, deliberately, and for the same reason `MASKED_MARKS` is on
+ * the client side: an asset reachable from two surfaces cannot be masked on one
+ * and drawn plain on the other without looking like a bug.
+ *
+ * Membership is a measurement, not a guess. Each of these renders a single fill
+ * that is either near-black or near-white, which means it disappears against one
+ * of the two tile surfaces (`--raised` resolves to #f4f4f4 light, #303030 dark).
+ * `zenmux` is #000, `synthetic` is #ffffff, `neuralwatt` is #081a17.
+ *
+ * A mark that carries real colour never belongs here. Masking discards every ink
+ * in the file and repaints the silhouette, so applying it to a palette is
+ * destructive in a way that still looks deliberate on screen.
+ */
+const MASKED_PROVIDER_ICONS: ReadonlySet = new Set([
+ "cerebras.svg",
+ "deepinfra.svg",
+ "neuralwatt.svg",
+ "nous.svg",
+ "novita.svg",
+ "siliconflow.svg",
+ "synthetic.svg",
+ "zenmux.svg",
+
+ /*
+ * Marks that predate this pass and were invisible on one tile the whole time.
+ *
+ * `opencode.svg` (#211e1e) and `kimi-color.svg` (#1a1a1a) are the same two files
+ * the client surface already masks -- the Integrations page fixed them and the
+ * provider rail kept drawing them plain, because the two surfaces had no shared
+ * decision. `grok.svg` is the same story one PR later. `ollama-color.svg`
+ * (#141414) and `vercel-ai-gateway-color.svg` (#000000) were never caught by
+ * either pass; the luminance guard found all five at once.
+ */
+ "grok.svg",
+ "kimi-color.svg",
+ "ollama-color.svg",
+ "opencode.svg",
+ "vercel-ai-gateway-color.svg",
+]);
+
+/**
+ * Marks that carry colour but whose dominant ink is near-black.
+ *
+ * These cannot be masked -- that would flatten a real palette -- and they cannot
+ * be left alone either: measured against the dark tile they land between 1.04:1
+ * and 1.59:1, which is invisible. `zai` is 1.04, `bizrouter` 1.08, `baseten` 1.59.
+ *
+ * The fix belongs to the tile rather than the file. A vendor's artwork is drawn
+ * unchanged on a constant light plate, which is what a favicon assumes anyway:
+ * every one of these was designed to sit on a page, not on a #303030 chip.
+ *
+ * `digitalocean.svg` looks like it belongs here and must not: its file carries
+ * its own `@media (prefers-color-scheme: dark)` rule that repaints the glyph
+ * #F4F5F5. A constant plate defeats that -- the file goes light-on-light and
+ * measures 1.01:1 -- so the one mark that solves this problem itself is left
+ * alone to do it. Check for an embedded media query before plating anything.
+ */
+const PLATED_PROVIDER_ICONS: ReadonlySet = new Set([
+ "baseten.svg",
+ "kilo.svg",
+ "sambanova.svg",
+ "venice.svg",
+ "zai.svg",
+]);
+
+/**
+ * The same problem pointing the other way: colour artwork whose dominant ink is
+ * near-WHITE, drawn for a dark header and invisible on the light tile.
+ *
+ * Measured dominant luminance: `parallel` 1.00, `bizrouter` 0.98, `nebius` 0.87,
+ * `featherless` 0.87, `umans` 0.84, `hyperbolic` 0.84 -- all of which land near
+ * 1.0:1 against #f4f4f4. A light plate would make them worse, so they get a dark
+ * one, which is the surface their own designers assumed.
+ *
+ * Two plates rather than one theme-following plate on purpose: a plate that
+ * followed the theme would put light-ink art back on a light tile in light mode,
+ * which is the exact failure being fixed.
+ */
+const DARK_PLATED_PROVIDER_ICONS: ReadonlySet = new Set([
+ "bizrouter.svg",
+ "featherless.svg",
+ "hyperbolic.svg",
+ "nebius.svg",
+ "parallel.svg",
+ "umans.svg",
+]);
+
+/** How a provider mark must be painted so it survives both themes. */
+export type ProviderIconPaint = "mask" | "plate" | "dark-plate" | "image";
+
+export function providerIconPaint(src: string | undefined): ProviderIconPaint {
+ if (!src) return "image";
+ const file = src.split("/").pop() ?? "";
+ if (MASKED_PROVIDER_ICONS.has(file)) return "mask";
+ if (PLATED_PROVIDER_ICONS.has(file)) return "plate";
+ if (DARK_PLATED_PROVIDER_ICONS.has(file)) return "dark-plate";
+ return "image";
+}
+
/** Display label with proper brand casing when known; otherwise original name. */
export function formatProviderDisplayName(provider: string, t: TFn): string {
const key = provider.toLowerCase();
diff --git a/gui/src/styles.css b/gui/src/styles.css
index 027388f1c4..df63631504 100644
--- a/gui/src/styles.css
+++ b/gui/src/styles.css
@@ -2153,6 +2153,35 @@ table.logs-table {
.prov-title { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; min-width: 0; }
.provider-icon { width: 31px; height: 31px; border-radius: var(--radius-xs); flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; background: var(--raised); border: 1px solid var(--border-soft); color: var(--text); }
.provider-icon img { width: 19px; height: 19px; object-fit: contain; display: block; }
+
+/* A single-ink mark painted with the surrounding text colour, so it follows the
+ theme instead of vanishing into one of the two tiles. The tile already sets
+ `color: var(--text)`, so this needs no property of its own. */
+.provider-icon-mask {
+ width: 19px;
+ height: 19px;
+ display: block;
+ background: currentColor;
+ mask-size: contain;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ -webkit-mask-size: contain;
+ -webkit-mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+}
+.provider-icon-sm .provider-icon-mask { width: 15px; height: 15px; }
+
+/* Artwork that carries real colour but is dominantly near-black: measured between
+ 1.04:1 and 1.59:1 against the dark tile, which is invisible. Masking would
+ flatten the palette, so the vendor's own colours are kept and given the light
+ plate every favicon already assumes it will sit on. */
+.provider-icon--plate { background: #f4f4f4; border-color: rgba(0, 0, 0, 0.12); }
+
+/* The mirror case: artwork drawn in near-white for a dark header, which is
+ invisible on the light tile at roughly 1.0:1. It gets the dark surface its own
+ designers assumed. A theme-following plate would not work -- it would put
+ light-ink art back on a light tile in light mode. */
+.provider-icon--plate-dark { background: #1c1c1c; border-color: rgba(255, 255, 255, 0.14); }
.prov-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; min-width: 0; }
.prov-meta > span { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.provider-quota { padding-left: 58px; }
diff --git a/gui/tests/provider-marks-assets.test.ts b/gui/tests/provider-marks-assets.test.ts
index b6350cafff..ebe143af44 100644
--- a/gui/tests/provider-marks-assets.test.ts
+++ b/gui/tests/provider-marks-assets.test.ts
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { PROVIDER_REGISTRY } from "../../src/providers/registry";
-import { providerIconSrc } from "../src/provider-icons";
+import { providerIconPaint, providerIconSrc } from "../src/provider-icons";
const PUBLIC_DIR = join(import.meta.dir, "..", "public", "provider-icons");
@@ -64,3 +64,90 @@ test("no wired provider mark is a horizontal wordmark", () => {
}
expect(lockups).toEqual([]);
});
+
+/** Relative luminance, so "would this vanish?" is measured rather than judged. */
+function luminance(hex: string): number {
+ const raw = hex.replace("#", "");
+ const full = raw.length === 3 ? [...raw].map(c => c + c).join("") : raw.slice(0, 6);
+ const channel = (pair: string): number => {
+ const v = parseInt(pair, 16) / 255;
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(full.slice(0, 2))
+ + 0.7152 * channel(full.slice(2, 4))
+ + 0.0722 * channel(full.slice(4, 6));
+}
+
+function inksOf(body: string): string[] {
+ const matches = body.match(/(?:fill|stop-color)\s*[:=]\s*"?(#[0-9a-fA-F]{3,8})/g) ?? [];
+ return [...new Set(matches.map(raw => raw.split(/[:=]/).pop()!.replace(/"/g, "").trim().toLowerCase()))];
+}
+
+/*
+ * The rule that has now shipped broken five times.
+ *
+ * `prime` was white-on-transparent and invisible in light mode; `opencode`
+ * (#211E1E) and `kimi` (#1A1A1A) invisible in dark; `grok` (#000000) sat at
+ * 1.9:1 on the dark card through two passes because a comment argued it away.
+ * Every one of those is a single fill of a near-neutral ink drawn as a plain
+ * image, which is the one combination that cannot survive both themes.
+ *
+ * The provider tile resolves to #f4f4f4 light and #303030 dark, so an ink at
+ * either extreme disappears into one of them. Such a mark must be masked (the ink
+ * comes from the theme) or plated (the tile is pinned to the surface the artwork
+ * assumes). What it must not be is `image`.
+ */
+test("a single-ink near-neutral mark is never left to be drawn plain", () => {
+ const vanishing: string[] = [];
+ for (const src of wiredAssets()) {
+ const body = bodyOf(src);
+ if (/<(linearGradient|radialGradient)[\s>]/.test(body)) continue;
+ if (/prefers-color-scheme/.test(body)) continue; // solves it itself, see digitalocean
+ const inks = inksOf(body);
+ if (inks.length !== 1) continue;
+ const hex = inks[0]!.replace("#", "");
+ const full = hex.length === 3 ? [...hex].map(c => c + c).join("") : hex.slice(0, 6);
+ const channels = [full.slice(0, 2), full.slice(2, 4), full.slice(4, 6)].map(p => parseInt(p, 16));
+ if (Math.max(...channels) - Math.min(...channels) > 24) continue; // a brand colour, not a neutral
+ const l = luminance(inks[0]!);
+ if (l > 0.12 && l < 0.75) continue; // a mid grey reads on both tiles
+ if (providerIconPaint(src) === "image") {
+ vanishing.push(`${src}: ${inks[0]} is drawn plain and vanishes against one tile`);
+ }
+ }
+ expect(vanishing).toEqual([]);
+});
+
+/*
+ * The inverse, and the more destructive direction. Masking discards every ink in
+ * the file and repaints the silhouette in one colour, so applying it to a palette
+ * flattens a brand -- and the result still renders, still looks deliberate, and is
+ * invisible in review.
+ */
+test("no multi-colour provider mark is masked", () => {
+ const flattened: string[] = [];
+ for (const src of wiredAssets()) {
+ if (providerIconPaint(src) !== "mask") continue;
+ const body = bodyOf(src);
+ const gradient = /<(linearGradient|radialGradient)[\s>]/.test(body);
+ const inks = inksOf(body);
+ if (gradient || inks.length > 1) {
+ flattened.push(`${src}: ${inks.length} ink(s)${gradient ? " + gradient" : ""}`);
+ }
+ }
+ expect(flattened).toEqual([]);
+});
+
+/*
+ * A mark that adapts to the theme in its own file must be left alone. A constant
+ * plate defeats its media query: `digitalocean.svg` repaints itself #F4F5F5 under
+ * dark, so plating it light produced light-on-light at 1.01:1 -- worse than doing
+ * nothing, and only visible by measuring the rendered result.
+ */
+test("a self-adapting mark is not plated", () => {
+ const overridden = wiredAssets()
+ .filter(src => /prefers-color-scheme/.test(bodyOf(src)))
+ .filter(src => providerIconPaint(src) !== "image")
+ .map(src => `${src}: carries its own media query but is painted ${providerIconPaint(src)}`);
+ expect(overridden).toEqual([]);
+});