Skip to content

Commit 15f92e3

Browse files
authored
fix(gui): make every provider mark survive both themes (#3098)
ProviderIcon rendered <img> unconditionally, so a mark only worked if its own artwork happened to contrast with both tiles. Measured against the real tile colours (#f4f4f4 light, #303030 dark), 18 of the 26 marks sourced in #3095 fell under 2:1 on one of them -- zai at 1.04:1, bizrouter 1.06, sambanova 1.02. Three paints, each chosen by measurement rather than by looking at the file. A single-ink neutral silhouette is masked: the ink comes from the theme, which is the mechanism ClientMark already uses. Eight qualify. Artwork that carries real colour cannot be masked -- that discards every ink and repaints the silhouette, flattening a brand while still looking deliberate. When such a mark is dominantly near-black it gets a light plate, and when it is dominantly near-white a dark one. Two plates rather than one theme-following plate on purpose: a plate that followed the theme would put light-ink artwork back on a light tile in light mode, which is the failure being fixed. digitalocean.svg is the exception that proves the rule. Its file carries its own @media (prefers-color-scheme: dark) rule, so plating it defeated the vendor and produced light-on-light at 1.01:1 -- worse than doing nothing, and only visible by measuring the rendered result. It is left alone. The luminance guard then found five older marks nobody had measured: opencode (#211e1e) and kimi (#1a1a1a) are the same files the Integrations page already masks, invisible here the whole time because the two surfaces had no shared decision; grok is the same story one PR later; ollama (#141414) and vercel-ai-gateway (#000000) were never caught by either pass. Measured after, per mark, in both themes: 26 of 26 at or above 1.73:1, none below. Before: 18 below 2:1. Verification: 5 pass, three new guards each driven red -- unmasking a neutral, masking a multi-colour mark, plating the self-adapting one. tsc 0 both roots, oxlint clean, privacy:scan clean.
1 parent 5f0b390 commit 15f92e3

4 files changed

Lines changed: 243 additions & 4 deletions

File tree

gui/src/components/provider-workspace/ProviderRail.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
type WorkspaceProvider,
1414
} from "../../provider-workspace/catalog";
1515
import { isLocalProvider } from "../../provider-workspace/kind";
16-
import { formatProviderDisplayName, providerIconSrc } from "../../provider-icons";
16+
import { formatProviderDisplayName, providerIconPaint, providerIconSrc } from "../../provider-icons";
1717

1818
export function statusLabel(p: WorkspaceProvider, t: TFn): string {
1919
const s = binProviderStatus(p);
@@ -50,9 +50,30 @@ export function ProviderIcon({ name, adapter, baseUrl, cls }: {
5050
}) {
5151
const t = useT();
5252
const src = providerIconSrc(name, { adapter, baseUrl });
53+
/*
54+
* Three ways to paint a mark, because two of them are wrong for most files.
55+
*
56+
* An <img> keeps the vendor's colours, which is what a brand deserves and what
57+
* works whenever the artwork has enough contrast against both tiles. A neutral
58+
* silhouette does not: one fill of near-black or near-white vanishes against
59+
* one of the two surfaces, so it is drawn as a themed mask instead. And a mark
60+
* that carries real colour but is dominantly dark can be neither -- masking
61+
* would flatten its palette, leaving it alone leaves it invisible -- so its own
62+
* artwork sits on a constant light plate, the way a favicon already assumes.
63+
*/
64+
const paint = providerIconPaint(src);
65+
const tileClass = paint === "plate" ? `${cls} provider-icon--plate`
66+
: paint === "dark-plate" ? `${cls} provider-icon--plate-dark`
67+
: cls;
5368
return (
54-
<span className={cls}>
55-
{src ? (
69+
<span className={tileClass}>
70+
{src && paint === "mask" ? (
71+
<span
72+
className="provider-icon-mask"
73+
style={{ maskImage: `url(${src})`, WebkitMaskImage: `url(${src})` }}
74+
aria-hidden="true"
75+
/>
76+
) : src ? (
5677
<img src={src} alt="" aria-hidden="true" />
5778
) : (
5879
<ProviderFallbackMark name={name} label={formatProviderDisplayName(name, t)} />

gui/src/provider-icons.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,108 @@ export function providerIconSrc(provider: string, _hints?: ProviderIconHints): s
169169
return icon ? `/provider-icons/${icon}` : undefined;
170170
}
171171

172+
/**
173+
* Marks whose artwork is one neutral ink, so the ink has to come from the theme.
174+
*
175+
* Keyed by asset path, deliberately, and for the same reason `MASKED_MARKS` is on
176+
* the client side: an asset reachable from two surfaces cannot be masked on one
177+
* and drawn plain on the other without looking like a bug.
178+
*
179+
* Membership is a measurement, not a guess. Each of these renders a single fill
180+
* that is either near-black or near-white, which means it disappears against one
181+
* of the two tile surfaces (`--raised` resolves to #f4f4f4 light, #303030 dark).
182+
* `zenmux` is #000, `synthetic` is #ffffff, `neuralwatt` is #081a17.
183+
*
184+
* A mark that carries real colour never belongs here. Masking discards every ink
185+
* in the file and repaints the silhouette, so applying it to a palette is
186+
* destructive in a way that still looks deliberate on screen.
187+
*/
188+
const MASKED_PROVIDER_ICONS: ReadonlySet<string> = new Set([
189+
"cerebras.svg",
190+
"deepinfra.svg",
191+
"neuralwatt.svg",
192+
"nous.svg",
193+
"novita.svg",
194+
"siliconflow.svg",
195+
"synthetic.svg",
196+
"zenmux.svg",
197+
198+
/*
199+
* Marks that predate this pass and were invisible on one tile the whole time.
200+
*
201+
* `opencode.svg` (#211e1e) and `kimi-color.svg` (#1a1a1a) are the same two files
202+
* the client surface already masks -- the Integrations page fixed them and the
203+
* provider rail kept drawing them plain, because the two surfaces had no shared
204+
* decision. `grok.svg` is the same story one PR later. `ollama-color.svg`
205+
* (#141414) and `vercel-ai-gateway-color.svg` (#000000) were never caught by
206+
* either pass; the luminance guard found all five at once.
207+
*/
208+
"grok.svg",
209+
"kimi-color.svg",
210+
"ollama-color.svg",
211+
"opencode.svg",
212+
"vercel-ai-gateway-color.svg",
213+
]);
214+
215+
/**
216+
* Marks that carry colour but whose dominant ink is near-black.
217+
*
218+
* These cannot be masked -- that would flatten a real palette -- and they cannot
219+
* be left alone either: measured against the dark tile they land between 1.04:1
220+
* and 1.59:1, which is invisible. `zai` is 1.04, `bizrouter` 1.08, `baseten` 1.59.
221+
*
222+
* The fix belongs to the tile rather than the file. A vendor's artwork is drawn
223+
* unchanged on a constant light plate, which is what a favicon assumes anyway:
224+
* every one of these was designed to sit on a page, not on a #303030 chip.
225+
*
226+
* `digitalocean.svg` looks like it belongs here and must not: its file carries
227+
* its own `@media (prefers-color-scheme: dark)` rule that repaints the glyph
228+
* #F4F5F5. A constant plate defeats that -- the file goes light-on-light and
229+
* measures 1.01:1 -- so the one mark that solves this problem itself is left
230+
* alone to do it. Check for an embedded media query before plating anything.
231+
*/
232+
const PLATED_PROVIDER_ICONS: ReadonlySet<string> = new Set([
233+
"baseten.svg",
234+
"kilo.svg",
235+
"sambanova.svg",
236+
"venice.svg",
237+
"zai.svg",
238+
]);
239+
240+
/**
241+
* The same problem pointing the other way: colour artwork whose dominant ink is
242+
* near-WHITE, drawn for a dark header and invisible on the light tile.
243+
*
244+
* Measured dominant luminance: `parallel` 1.00, `bizrouter` 0.98, `nebius` 0.87,
245+
* `featherless` 0.87, `umans` 0.84, `hyperbolic` 0.84 -- all of which land near
246+
* 1.0:1 against #f4f4f4. A light plate would make them worse, so they get a dark
247+
* one, which is the surface their own designers assumed.
248+
*
249+
* Two plates rather than one theme-following plate on purpose: a plate that
250+
* followed the theme would put light-ink art back on a light tile in light mode,
251+
* which is the exact failure being fixed.
252+
*/
253+
const DARK_PLATED_PROVIDER_ICONS: ReadonlySet<string> = new Set([
254+
"bizrouter.svg",
255+
"featherless.svg",
256+
"hyperbolic.svg",
257+
"nebius.svg",
258+
"parallel.svg",
259+
"umans.svg",
260+
]);
261+
262+
/** How a provider mark must be painted so it survives both themes. */
263+
export type ProviderIconPaint = "mask" | "plate" | "dark-plate" | "image";
264+
265+
export function providerIconPaint(src: string | undefined): ProviderIconPaint {
266+
if (!src) return "image";
267+
const file = src.split("/").pop() ?? "";
268+
if (MASKED_PROVIDER_ICONS.has(file)) return "mask";
269+
if (PLATED_PROVIDER_ICONS.has(file)) return "plate";
270+
if (DARK_PLATED_PROVIDER_ICONS.has(file)) return "dark-plate";
271+
return "image";
272+
}
273+
172274
/** Display label with proper brand casing when known; otherwise original name. */
173275
export function formatProviderDisplayName(provider: string, t: TFn): string {
174276
const key = provider.toLowerCase();

gui/src/styles.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2153,6 +2153,35 @@ table.logs-table {
21532153
.prov-title { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; min-width: 0; }
21542154
.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); }
21552155
.provider-icon img { width: 19px; height: 19px; object-fit: contain; display: block; }
2156+
2157+
/* A single-ink mark painted with the surrounding text colour, so it follows the
2158+
theme instead of vanishing into one of the two tiles. The tile already sets
2159+
`color: var(--text)`, so this needs no property of its own. */
2160+
.provider-icon-mask {
2161+
width: 19px;
2162+
height: 19px;
2163+
display: block;
2164+
background: currentColor;
2165+
mask-size: contain;
2166+
mask-repeat: no-repeat;
2167+
mask-position: center;
2168+
-webkit-mask-size: contain;
2169+
-webkit-mask-repeat: no-repeat;
2170+
-webkit-mask-position: center;
2171+
}
2172+
.provider-icon-sm .provider-icon-mask { width: 15px; height: 15px; }
2173+
2174+
/* Artwork that carries real colour but is dominantly near-black: measured between
2175+
1.04:1 and 1.59:1 against the dark tile, which is invisible. Masking would
2176+
flatten the palette, so the vendor's own colours are kept and given the light
2177+
plate every favicon already assumes it will sit on. */
2178+
.provider-icon--plate { background: #f4f4f4; border-color: rgba(0, 0, 0, 0.12); }
2179+
2180+
/* The mirror case: artwork drawn in near-white for a dark header, which is
2181+
invisible on the light tile at roughly 1.0:1. It gets the dark surface its own
2182+
designers assumed. A theme-following plate would not work -- it would put
2183+
light-ink art back on a light tile in light mode. */
2184+
.provider-icon--plate-dark { background: #1c1c1c; border-color: rgba(255, 255, 255, 0.14); }
21562185
.prov-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; min-width: 0; }
21572186
.prov-meta > span { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
21582187
.provider-quota { padding-left: 58px; }

gui/tests/provider-marks-assets.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
22
import { readFileSync, readdirSync } from "node:fs";
33
import { join } from "node:path";
44
import { PROVIDER_REGISTRY } from "../../src/providers/registry";
5-
import { providerIconSrc } from "../src/provider-icons";
5+
import { providerIconPaint, providerIconSrc } from "../src/provider-icons";
66

77
const PUBLIC_DIR = join(import.meta.dir, "..", "public", "provider-icons");
88

@@ -64,3 +64,90 @@ test("no wired provider mark is a horizontal wordmark", () => {
6464
}
6565
expect(lockups).toEqual([]);
6666
});
67+
68+
/** Relative luminance, so "would this vanish?" is measured rather than judged. */
69+
function luminance(hex: string): number {
70+
const raw = hex.replace("#", "");
71+
const full = raw.length === 3 ? [...raw].map(c => c + c).join("") : raw.slice(0, 6);
72+
const channel = (pair: string): number => {
73+
const v = parseInt(pair, 16) / 255;
74+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
75+
};
76+
return 0.2126 * channel(full.slice(0, 2))
77+
+ 0.7152 * channel(full.slice(2, 4))
78+
+ 0.0722 * channel(full.slice(4, 6));
79+
}
80+
81+
function inksOf(body: string): string[] {
82+
const matches = body.match(/(?:fill|stop-color)\s*[:=]\s*"?(#[0-9a-fA-F]{3,8})/g) ?? [];
83+
return [...new Set(matches.map(raw => raw.split(/[:=]/).pop()!.replace(/"/g, "").trim().toLowerCase()))];
84+
}
85+
86+
/*
87+
* The rule that has now shipped broken five times.
88+
*
89+
* `prime` was white-on-transparent and invisible in light mode; `opencode`
90+
* (#211E1E) and `kimi` (#1A1A1A) invisible in dark; `grok` (#000000) sat at
91+
* 1.9:1 on the dark card through two passes because a comment argued it away.
92+
* Every one of those is a single fill of a near-neutral ink drawn as a plain
93+
* image, which is the one combination that cannot survive both themes.
94+
*
95+
* The provider tile resolves to #f4f4f4 light and #303030 dark, so an ink at
96+
* either extreme disappears into one of them. Such a mark must be masked (the ink
97+
* comes from the theme) or plated (the tile is pinned to the surface the artwork
98+
* assumes). What it must not be is `image`.
99+
*/
100+
test("a single-ink near-neutral mark is never left to be drawn plain", () => {
101+
const vanishing: string[] = [];
102+
for (const src of wiredAssets()) {
103+
const body = bodyOf(src);
104+
if (/<(linearGradient|radialGradient)[\s>]/.test(body)) continue;
105+
if (/prefers-color-scheme/.test(body)) continue; // solves it itself, see digitalocean
106+
const inks = inksOf(body);
107+
if (inks.length !== 1) continue;
108+
const hex = inks[0]!.replace("#", "");
109+
const full = hex.length === 3 ? [...hex].map(c => c + c).join("") : hex.slice(0, 6);
110+
const channels = [full.slice(0, 2), full.slice(2, 4), full.slice(4, 6)].map(p => parseInt(p, 16));
111+
if (Math.max(...channels) - Math.min(...channels) > 24) continue; // a brand colour, not a neutral
112+
const l = luminance(inks[0]!);
113+
if (l > 0.12 && l < 0.75) continue; // a mid grey reads on both tiles
114+
if (providerIconPaint(src) === "image") {
115+
vanishing.push(`${src}: ${inks[0]} is drawn plain and vanishes against one tile`);
116+
}
117+
}
118+
expect(vanishing).toEqual([]);
119+
});
120+
121+
/*
122+
* The inverse, and the more destructive direction. Masking discards every ink in
123+
* the file and repaints the silhouette in one colour, so applying it to a palette
124+
* flattens a brand -- and the result still renders, still looks deliberate, and is
125+
* invisible in review.
126+
*/
127+
test("no multi-colour provider mark is masked", () => {
128+
const flattened: string[] = [];
129+
for (const src of wiredAssets()) {
130+
if (providerIconPaint(src) !== "mask") continue;
131+
const body = bodyOf(src);
132+
const gradient = /<(linearGradient|radialGradient)[\s>]/.test(body);
133+
const inks = inksOf(body);
134+
if (gradient || inks.length > 1) {
135+
flattened.push(`${src}: ${inks.length} ink(s)${gradient ? " + gradient" : ""}`);
136+
}
137+
}
138+
expect(flattened).toEqual([]);
139+
});
140+
141+
/*
142+
* A mark that adapts to the theme in its own file must be left alone. A constant
143+
* plate defeats its media query: `digitalocean.svg` repaints itself #F4F5F5 under
144+
* dark, so plating it light produced light-on-light at 1.01:1 -- worse than doing
145+
* nothing, and only visible by measuring the rendered result.
146+
*/
147+
test("a self-adapting mark is not plated", () => {
148+
const overridden = wiredAssets()
149+
.filter(src => /prefers-color-scheme/.test(bodyOf(src)))
150+
.filter(src => providerIconPaint(src) !== "image")
151+
.map(src => `${src}: carries its own media query but is painted ${providerIconPaint(src)}`);
152+
expect(overridden).toEqual([]);
153+
});

0 commit comments

Comments
 (0)