diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 2d42e3ece8..692d868879 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -14,7 +14,7 @@ import { localizeWindowLabel } from "../lib/windowLabels"; import { inlineDashboardModel } from "../lib/inlineDashboard"; import { useMonthlyLimitBlockNow } from "../hooks/useMonthlyLimitBlockNow"; import type { UsageThresholdSettings } from "../lib/metricRowModel"; -import { getProviderIcon } from "./providers/providerIcons"; +import { providerBarAccent } from "./providers/providerIcons"; import CardHeader, { displayPlanName } from "./card/CardHeader"; import CardMetricRow, { type MetricEntry } from "./card/CardMetricRow"; import DetailSections from "./card/DetailSections"; @@ -232,7 +232,7 @@ export default function MenuCard({ ] .filter(Boolean) .join(" "); - const accent = accentColor ?? getProviderIcon(provider.providerId).brandColor; + const accent = providerBarAccent(provider.providerId, accentColor); return (
{ ); }); + it("gives xAI its own token now that Grok uses the Mac green", () => { + expect(providerCostColor("xai")).toBe( + "var(--chart-xai, var(--provider-accent, var(--chart-cost)))", + ); + expect(providerCostColor("grok")).toBe( + "var(--chart-grok, var(--provider-accent, var(--chart-cost)))", + ); + }); + it("falls back to the generic cost/credits token for unknown providers", () => { expect(providerCostColor("unknown-provider-xyz")).toBe( "var(--provider-accent, var(--chart-cost))", diff --git a/apps/desktop-tauri/src/components/charts/chartPalette.ts b/apps/desktop-tauri/src/components/charts/chartPalette.ts index beae1d7a0d..9097a88a5f 100644 --- a/apps/desktop-tauri/src/components/charts/chartPalette.ts +++ b/apps/desktop-tauri/src/components/charts/chartPalette.ts @@ -45,7 +45,7 @@ const PROVIDER_TOKEN: Record = { "eleven labs": "--chart-elevenlabs", deepgram: "--chart-deepgram", grok: "--chart-grok", - xai: "--chart-grok", + xai: "--chart-xai", supergrok: "--chart-grok", groq: "--chart-groq", groqcloud: "--chart-groq", diff --git a/apps/desktop-tauri/src/components/providers/ProviderIcon.tsx b/apps/desktop-tauri/src/components/providers/ProviderIcon.tsx index 27f70290d6..3d8b41d1ba 100644 --- a/apps/desktop-tauri/src/components/providers/ProviderIcon.tsx +++ b/apps/desktop-tauri/src/components/providers/ProviderIcon.tsx @@ -1,4 +1,4 @@ -import { getProviderIcon } from "./providerIcons"; +import { brandColorOnDark, getProviderIcon } from "./providerIcons"; interface Props { providerId: string; @@ -19,6 +19,13 @@ export function ProviderIcon({ title, }: Props) { const entry = getProviderIcon(providerId); + const onDark = brandColorOnDark(entry.brandColor); + const tint = { + ["--provider-brand" as string]: entry.brandColor, + // Only near-black brands get a dark-surface tint; CSS picks it on dark + // surfaces and keeps the brand color on light ones. + ...(onDark ? { ["--provider-brand-dark" as string]: onDark } : {}), + }; const dims = { width: size, height: size }; if (entry.svgPath) { @@ -27,7 +34,7 @@ export function ProviderIcon({ className={`provider-icon provider-icon--svg${className ? " " + className : ""}`} style={{ ...dims, - ["--provider-brand" as string]: entry.brandColor, + ...tint, }} title={title} aria-hidden={title ? undefined : true} @@ -42,7 +49,7 @@ export function ProviderIcon({ className={`provider-icon provider-icon--letter${className ? " " + className : ""}`} style={{ ...dims, - ["--provider-brand" as string]: entry.brandColor, + ...tint, }} title={title} aria-hidden={title ? undefined : true} diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts index 332d358c25..5a737c2664 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts @@ -1,8 +1,8 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { TEST_PROVIDER_CATALOG } from "../../test/providerCatalog"; -import { loadStyles } from "../../test/styles"; -import { PROVIDER_ICON_REGISTRY } from "./providerIcons"; +import { loadStyles, loadStylesheet, ruleBlock } from "../../test/styles"; +import { PROVIDER_ICON_REGISTRY, brandColorOnDark, providerBarAccent } from "./providerIcons"; // Repository root, from apps/desktop-tauri/src/components/providers. const REPO_ROOT = import.meta.dirname + "/../../../../../"; @@ -64,6 +64,35 @@ const ADOPTED_ACCENTS: Array<[string, string]> = [ ["venice", "#3c8fdd"], ]; +// Mac CodexBar 0.70.0 brand colors adopted for parity (card PR 4, +// tray-card-spec section 4). Vercel is the one deliberate exception. +const MAC_PARITY_ACCENTS: Array<[string, string]> = [ + ["chutes", "#3184ff"], + ["deepgram", "#6467f2"], + ["doubao", "#3370ff"], + ["grok", "#10a37f"], + ["groq", "#f56844"], + ["helmcode", "#4934e1"], + ["kilo", "#f27027"], + ["litellm", "#4c89f0"], + ["llmproxy", "#24b47e"], + ["ollama", "#888888"], + ["openaiapi", "#0f826e"], + ["openrouter", "#6467f2"], + ["perplexity", "#20b2aa"], + ["poe", "#5d5cde"], + ["qoder", "#10b981"], + ["sakana", "#2975db"], + ["stepfun", "#2196f2"], + ["t3chat", "#f56647"], + ["typesafe", "#111111"], + ["v0", "#111111"], + ["warp", "#938bb4"], + ["wayfinder", "#10a37f"], + ["windsurf", "#34e8bb"], + ["zed", "#084eff"], +]; + describe("provider icon registry", () => { it("has explicit icon metadata for every provider in the catalog", () => { for (const [id] of TEST_PROVIDER_CATALOG) { @@ -88,6 +117,85 @@ describe("provider icon registry", () => { } }); + it("uses the Mac 0.70.0 brand colors", () => { + for (const [id, color] of MAC_PARITY_ACCENTS) { + expect(PROVIDER_ICON_REGISTRY[id]?.brandColor.toLowerCase(), id).toBe(color); + } + }); + + it("keeps Vercel on a mid neutral instead of Mac white", () => { + // User decision 2026-10-10: Mac #FFFFFF is invisible on the light track. + expect(PROVIDER_ICON_REGISTRY.vercel.brandColor.toLowerCase()).toBe("#737373"); + }); + + it("paints ElevenLabs bars in the label color, as Mac does", () => { + expect(providerBarAccent("elevenlabs")).toBe("var(--mac-text, rgba(0, 0, 0, 0.85))"); + expect(providerBarAccent("ElevenLabs", null)).toBe("var(--mac-text, rgba(0, 0, 0, 0.85))"); + // The icon keeps its brand color. + expect(PROVIDER_ICON_REGISTRY.elevenlabs.brandColor.toLowerCase()).toBe("#111827"); + }); + + it("uses the user's override, else the brand color, for card bars", () => { + expect(providerBarAccent("claude")).toBe("#cc7c5e"); + expect(providerBarAccent("claude", "#123456")).toBe("#123456"); + expect(providerBarAccent("elevenlabs", "#ff0000")).toBe("#ff0000"); + }); + + it("lifts only near-black brand colors on dark surfaces", () => { + // Literal values: #111111 is 1.19:1 on #222222, #707070 is the first + // 10% white mix that reaches 3:1. + expect(brandColorOnDark("#111111")).toBe("#707070"); + expect(brandColorOnDark("#000000")).toBe("#808080"); + expect(brandColorOnDark("#111827")).toBe("#70747d"); + // Colors that already read on dark keep their exact value. + expect(brandColorOnDark("#10a37f")).toBeNull(); + expect(brandColorOnDark("#2563eb")).toBeNull(); + expect(brandColorOnDark("#737373")).toBeNull(); + expect(brandColorOnDark("#4c00ff")).toBeNull(); + expect(brandColorOnDark("var(--mac-text)")).toBeNull(); + + const lifted = Object.entries(PROVIDER_ICON_REGISTRY) + .filter(([, icon]) => brandColorOnDark(icon.brandColor) !== null) + .map(([id]) => id) + .sort(); + expect(lifted).toEqual([ + "aixy", + "deepinfra", + "elevenlabs", + "manus", + "replicate", + "typesafe", + "v0", + ]); + }); + + it("keeps the brand color in light scopes and the lifted tint in dark ones", () => { + const css = loadStyles(); + const dark = css.indexOf('[data-theme="dark"] .provider-icon {'); + const light = css.indexOf('[data-theme="light"] .provider-icon {'); + expect(dark).toBeGreaterThan(-1); + // The tray panel nests data-theme="light" inside a dark document, so the + // light rule must come later to win at equal specificity. + expect(light).toBeGreaterThan(dark); + expect(ruleBlock(css, '[data-theme="dark"] .provider-icon')).toContain( + "var(--provider-brand-dark, var(--provider-brand", + ); + expect(ruleBlock(css, '[data-theme="light"] .provider-icon')).not.toContain( + "--provider-brand-dark", + ); + + const floatbar = loadStylesheet("floatbar/FloatBar.css"); + expect(ruleBlock(floatbar, ".floatbar .floatbar__provider-icon .provider-icon")).not.toContain( + "--provider-brand-dark", + ); + expect( + ruleBlock( + floatbar, + ".floatbar--floating:not(.floatbar--light-bg) .floatbar__provider-icon .provider-icon", + ), + ).toContain("var(--provider-brand-dark, var(--provider-brand"); + }); + it("matches the Rust brand_color table for every provider", () => { const rust = rustBrandColors(); expect(rust.size).toBeGreaterThan(0); @@ -98,13 +206,23 @@ describe("provider icon registry", () => { }); it("keeps provider chart tokens on the brand color", () => { - // Grok's chart token has been pure black since before the registry - // existed (xAI shares it), so it is the one intentional exception. - const checked = chartTokens().filter( - ([id]) => id !== "grok" && PROVIDER_ICON_REGISTRY[id] !== undefined, - ); + const checked = chartTokens().filter(([id]) => PROVIDER_ICON_REGISTRY[id] !== undefined); expect(checked.map(([id]) => id)).toEqual( - expect.arrayContaining(["amp", "augment", "codebuff", "cursor", "deepseek", "kiro", "mistral"]), + expect.arrayContaining([ + "amp", + "augment", + "codebuff", + "cursor", + "deepgram", + "deepseek", + "grok", + "groq", + "kiro", + "llmproxy", + "mistral", + "windsurf", + "xai", + ]), ); for (const [id, color] of checked) { expect(color, `--chart-${id}`).toBe(PROVIDER_ICON_REGISTRY[id].brandColor.toLowerCase()); diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 082672f4d2..f3caba1f48 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -2,6 +2,7 @@ // rust/src/native_ui/theme.rs::{provider_color, provider_icon}. // Keep in sync with the Rust registries when new providers are added. +import { contrastRatio, mixWithWhite } from "../../lib/color"; import abacus from "./icons/ProviderIcon-abacus.svg?raw"; import alibaba from "./icons/ProviderIcon-alibaba.svg?raw"; import amp from "./icons/ProviderIcon-amp.svg?raw"; @@ -184,7 +185,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { codex: { id: "codex", brandColor: "#49a3b0", fallbackLetter: "◆", svgPath: RAW.codex }, copilot: { id: "copilot", brandColor: "#a855f7", fallbackLetter: "⬡", svgPath: RAW.copilot }, cursor: { id: "cursor", brandColor: "#f54e00", fallbackLetter: "▸", svgPath: RAW.cursor }, - deepgram: { id: "deepgram", brandColor: "#13ef93", fallbackLetter: "D", svgPath: RAW.deepgram }, + deepgram: { id: "deepgram", brandColor: "#6467f2", fallbackLetter: "D", svgPath: RAW.deepgram }, deepinfra: { id: "deepinfra", brandColor: "#2a3275", fallbackLetter: "D", svgPath: RAW.deepinfra }, devpass: { id: "devpass", brandColor: "#2563eb", fallbackLetter: "D", svgPath: RAW.devpass }, fireworks: { id: "fireworks", brandColor: "#f25b1c", fallbackLetter: "F", svgPath: RAW.fireworks }, @@ -198,38 +199,38 @@ export const PROVIDER_ICON_REGISTRY: Record = { elevenlabs: { id: "elevenlabs", brandColor: "#111827", fallbackLetter: "E", svgPath: RAW.elevenlabs }, factory: { id: "factory", brandColor: "#ff6b35", fallbackLetter: "◎", svgPath: RAW.factory }, gemini: { id: "gemini", brandColor: "#ab87ea", fallbackLetter: "✦", svgPath: RAW.gemini }, - grok: { id: "grok", brandColor: "#111827", fallbackLetter: "G", svgPath: RAW.grok }, - groq: { id: "groq", brandColor: "#f55036", fallbackLetter: "G", svgPath: RAW.groq }, + grok: { id: "grok", brandColor: "#10a37f", fallbackLetter: "G", svgPath: RAW.grok }, + groq: { id: "groq", brandColor: "#f56844", fallbackLetter: "G", svgPath: RAW.groq }, bifrost: { id: "bifrost", brandColor: "#33c09e", fallbackLetter: "B" }, aixy: { id: "aixy", brandColor: "#123650", fallbackLetter: "A", svgPath: RAW.aixy }, gitkraken: { id: "gitkraken", brandColor: "#179287", fallbackLetter: "G" }, huggingface: { id: "huggingface", brandColor: "#ffd21e", fallbackLetter: "H", svgPath: RAW.huggingface }, hyper: { id: "hyper", brandColor: "#ff60ff", fallbackLetter: "H" }, - helmcode: { id: "helmcode", brandColor: "#4f46e5", fallbackLetter: "H" }, - v0: { id: "v0", brandColor: "#111827", fallbackLetter: "V" }, - typesafe: { id: "typesafe", brandColor: "#2563eb", fallbackLetter: "T" }, + helmcode: { id: "helmcode", brandColor: "#4934e1", fallbackLetter: "H" }, + v0: { id: "v0", brandColor: "#111111", fallbackLetter: "V" }, + typesafe: { id: "typesafe", brandColor: "#111111", fallbackLetter: "T" }, jetbrains: { id: "jetbrains", brandColor: "#ff3399", fallbackLetter: "J", svgPath: RAW.jetbrains }, - kilo: { id: "kilo", brandColor: "#5d87ff", fallbackLetter: "K", svgPath: RAW.kilo }, + kilo: { id: "kilo", brandColor: "#f27027", fallbackLetter: "K", svgPath: RAW.kilo }, bedrock: { id: "bedrock", brandColor: "#01a88d", fallbackLetter: "B", svgPath: RAW.bedrock }, kimi: { id: "kimi", brandColor: "#fe603c", fallbackLetter: "☽", svgPath: RAW.kimi }, kimik2: { id: "kimik2", brandColor: "#4c00ff", fallbackLetter: "☽", svgPath: RAW.kimi }, kiro: { id: "kiro", brandColor: "#9046ff", fallbackLetter: "K", svgPath: RAW.kiro }, llmman: { id: "llmman", brandColor: "#6CC5B0", fallbackLetter: "L", svgPath: RAW.llmman }, - llmproxy: { id: "llmproxy", brandColor: "#4f46e5", fallbackLetter: "L", svgPath: RAW.llmproxy }, + llmproxy: { id: "llmproxy", brandColor: "#24b47e", fallbackLetter: "L", svgPath: RAW.llmproxy }, minimax: { id: "minimax", brandColor: "#fe603c", fallbackLetter: "M", svgPath: RAW.minimax }, mistral: { id: "mistral", brandColor: "#ff5229", fallbackLetter: "M", svgPath: RAW.mistral }, muse: { id: "muse", brandColor: "#0668e1", fallbackLetter: "M", svgPath: RAW.muse }, - ollama: { id: "ollama", brandColor: "#8b95b0", fallbackLetter: "○", svgPath: RAW.ollama }, + ollama: { id: "ollama", brandColor: "#888888", fallbackLetter: "○", svgPath: RAW.ollama }, azureopenai: { id: "azureopenai", brandColor: "#0078d4", fallbackLetter: "A" }, - t3chat: { id: "t3chat", brandColor: "#8b5cf6", fallbackLetter: "T", svgPath: RAW.t3chat }, + t3chat: { id: "t3chat", brandColor: "#f56647", fallbackLetter: "T", svgPath: RAW.t3chat }, opencode: { id: "opencode", brandColor: "#3b82f6", fallbackLetter: "○", svgPath: RAW.opencode }, opencodego: { id: "opencodego", brandColor: "#3b82f6", fallbackLetter: "○", svgPath: RAW.opencodego }, - openrouter: { id: "openrouter", brandColor: "#6b7280", fallbackLetter: "R", svgPath: RAW.openrouter }, - perplexity: { id: "perplexity", brandColor: "#1fb8cd", fallbackLetter: "P", svgPath: RAW.perplexity }, + openrouter: { id: "openrouter", brandColor: "#6467f2", fallbackLetter: "R", svgPath: RAW.openrouter }, + perplexity: { id: "perplexity", brandColor: "#20b2aa", fallbackLetter: "P", svgPath: RAW.perplexity }, vertexai: { id: "vertexai", brandColor: "#4285f4", fallbackLetter: "△", svgPath: RAW.vertexai }, - warp: { id: "warp", brandColor: "#6366f1", fallbackLetter: "W", svgPath: RAW.warp }, - windsurf: { id: "windsurf", brandColor: "#22c55e", fallbackLetter: "W", svgPath: RAW.windsurf }, - wayfinder: { id: "wayfinder", brandColor: "#14b8a6", fallbackLetter: "W" }, + warp: { id: "warp", brandColor: "#938bb4", fallbackLetter: "W", svgPath: RAW.warp }, + windsurf: { id: "windsurf", brandColor: "#34e8bb", fallbackLetter: "W", svgPath: RAW.windsurf }, + wayfinder: { id: "wayfinder", brandColor: "#10a37f", fallbackLetter: "W" }, zai: { id: "zai", brandColor: "#e85a6a", fallbackLetter: "Z", svgPath: RAW.zai }, // Aliases / Rust-side normalizations without their own SVG. nanogpt: { id: "nanogpt", brandColor: "#687fa1", fallbackLetter: "N" }, @@ -238,24 +239,24 @@ export const PROVIDER_ICON_REGISTRY: Record = { atlascloud: { id: "atlascloud", brandColor: "#5975F5", fallbackLetter: "A", svgPath: RAW.atlascloud }, manus: { id: "manus", brandColor: "#34322d", fallbackLetter: "M", svgPath: RAW.manus }, mimo: { id: "mimo", brandColor: "#ff6900", fallbackLetter: "M", svgPath: RAW.mimo }, - doubao: { id: "doubao", brandColor: "#2563eb", fallbackLetter: "D", svgPath: RAW.doubao }, + doubao: { id: "doubao", brandColor: "#3370ff", fallbackLetter: "D", svgPath: RAW.doubao }, commandcode: { id: "commandcode", brandColor: "#8c4edd", fallbackLetter: "C", svgPath: RAW.commandcode }, crossmodel: { id: "crossmodel", brandColor: "#c084fc", fallbackLetter: "X", svgPath: RAW.crossmodel }, - qoder: { id: "qoder", brandColor: "#2563eb", fallbackLetter: "Q", svgPath: RAW.qoder }, + qoder: { id: "qoder", brandColor: "#10b981", fallbackLetter: "Q", svgPath: RAW.qoder }, raycast: { id: "raycast", brandColor: "#FF6363", fallbackLetter: "R", svgPath: RAW.raycast }, replicate: { id: "replicate", brandColor: "#000000", fallbackLetter: "R", svgPath: RAW.replicate }, codebuddy: { id: "codebuddy", brandColor: "#0052d9", fallbackLetter: "C" }, - sakana: { id: "sakana", brandColor: "#0ea5e9", fallbackLetter: "S", svgPath: RAW.sakana }, - stepfun: { id: "stepfun", brandColor: "#999999", fallbackLetter: "S", svgPath: RAW.stepfun }, + sakana: { id: "sakana", brandColor: "#2975db", fallbackLetter: "S", svgPath: RAW.sakana }, + stepfun: { id: "stepfun", brandColor: "#2196f2", fallbackLetter: "S", svgPath: RAW.stepfun }, sub2api: { id: "sub2api", brandColor: "#14b8a6", fallbackLetter: "S", svgPath: RAW.sub2api }, venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, - openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" }, - chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" }, - litellm: { id: "litellm", brandColor: "#0ea5e9", fallbackLetter: "L" }, - poe: { id: "poe", brandColor: "#5d5fef", fallbackLetter: "P" }, + openaiapi: { id: "openaiapi", brandColor: "#0f826e", fallbackLetter: "O" }, + chutes: { id: "chutes", brandColor: "#3184ff", fallbackLetter: "C" }, + litellm: { id: "litellm", brandColor: "#4c89f0", fallbackLetter: "L" }, + poe: { id: "poe", brandColor: "#5d5cde", fallbackLetter: "P" }, devin: { id: "devin", brandColor: "#317cff", fallbackLetter: "D" }, - zed: { id: "zed", brandColor: "#084ccf", fallbackLetter: "Z" }, + zed: { id: "zed", brandColor: "#084eff", fallbackLetter: "Z" }, qwencloud: { id: "qwencloud", brandColor: "#615CED", fallbackLetter: "Q" }, notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, nous: { id: "nous", brandColor: "#D6A55C", fallbackLetter: "N", svgPath: RAW.nous }, @@ -362,6 +363,48 @@ function normalize(id: string): string { return lower.replace(/[ \-]/g, ""); } +/** + * Providers whose card bars use the label color instead of the brand color, + * as Mac `progressColorStyle: .label` (only ElevenLabs, whose brand color is + * near-white). The fallback is the light card's `--mac-text`. + */ +const LABEL_COLORED_BARS = new Set(["elevenlabs"]); +const LABEL_BAR_COLOR = "var(--mac-text, rgba(0, 0, 0, 0.85))"; + +/** + * The card's `--provider-accent`: the user's override when set, otherwise the + * label color for label-styled providers, otherwise the brand color. + */ +export function providerBarAccent(id: string, override?: string | null): string { + if (override) return override; + if (LABEL_COLORED_BARS.has(normalize(id))) return LABEL_BAR_COLOR; + return getProviderIcon(id).brandColor; +} + +/** Reference dark surface (upstream's palette audit uses the same #222222). */ +const DARK_SURFACE = "#222222"; +/** Below this a brand color is near-invisible on dark surfaces. */ +const NEAR_INVISIBLE_ON_DARK = 1.5; +/** A lifted color reaches the WCAG 3:1 non-text minimum. */ +const LIFTED_ON_DARK = 3; + +/** + * The tint for a near-black brand color on a dark surface (Settings in the + * dark theme, the dark floating bar): the brand mixed toward white in 10% + * steps until it reaches 3:1 on #222222. Returns null when the brand already + * reads on dark, so every other provider keeps its exact color. Light + * surfaces, the tray card above all, always use the brand color. + */ +export function brandColorOnDark(brandColor: string): string | null { + const contrast = contrastRatio(brandColor, DARK_SURFACE); + if (contrast === null || contrast >= NEAR_INVISIBLE_ON_DARK) return null; + for (let step = 1; step <= 10; step += 1) { + const lifted = mixWithWhite(brandColor, step / 10); + if ((contrastRatio(lifted, DARK_SURFACE) ?? 0) >= LIFTED_ON_DARK) return lifted; + } + return null; +} + /** Return the registry entry for a provider id, falling back to a generic one. */ export function getProviderIcon(id: string): ProviderIcon { const key = normalize(id); diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.css b/apps/desktop-tauri/src/floatbar/FloatBar.css index aeb03ddd92..65e5d81b6a 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.css +++ b/apps/desktop-tauri/src/floatbar/FloatBar.css @@ -116,6 +116,15 @@ body.floatbar-window #root { align-items: center; justify-content: center; } +/* The window theme says nothing about the pill: taskbar and light-bg pills + are light glass and keep the brand color; only the dark floating glass + takes the lifted tint for near-black brands. */ +.floatbar .floatbar__provider-icon .provider-icon { + color: var(--provider-brand, var(--accent)); +} +.floatbar--floating:not(.floatbar--light-bg) .floatbar__provider-icon .provider-icon { + color: var(--provider-brand-dark, var(--provider-brand, var(--accent))); +} .floatbar__cost-pill { display: inline-flex; align-items: center; @@ -300,7 +309,7 @@ body.floatbar-window #root { background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); - border: 1px solid var(--brand, rgba(255, 255, 255, 0.22)); + border: 1px solid var(--brand-dark, var(--brand, rgba(255, 255, 255, 0.22))); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); } .floatbar--floating .floatbar__pill--warn { diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 1e3c6d5b74..7d2850aabd 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -716,6 +716,40 @@ describe("FloatBar", () => { }); }); + it("gives near-black brands a lifted tint for the dark floating glass only", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("typesafe", "TypeSafe", 60), + snapshot("grok", "Grok", 40), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ enabledProviders: ["typesafe", "grok"] }), + ); + + const { container } = renderFloatBar( + bootstrap({ enabledProviders: ["typesafe", "grok"] }), + ); + await waitFor(() => { + expect(container.querySelectorAll(".floatbar__pill").length).toBe(2); + }); + const [typesafe, grok] = Array.from( + container.querySelectorAll(".floatbar__pill"), + ); + // TypeSafe keeps the Mac #111111 and adds the dark-surface tint. + expect(typesafe.style.getPropertyValue("--brand")).toBe("#111111"); + expect(typesafe.style.getPropertyValue("--brand-dark")).toBe("#707070"); + const icon = typesafe.querySelector(".provider-icon")!; + expect(icon.style.getPropertyValue("--provider-brand")).toBe("#111111"); + expect(icon.style.getPropertyValue("--provider-brand-dark")).toBe("#707070"); + // Grok's Mac green already reads on dark and gets no tint. + expect(grok.style.getPropertyValue("--brand")).toBe("#10a37f"); + expect(grok.style.getPropertyValue("--brand-dark")).toBe(""); + expect( + grok.querySelector(".provider-icon")!.style.getPropertyValue( + "--provider-brand-dark", + ), + ).toBe(""); + }); + it("does not show stale cached providers when all providers are disabled", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("claude", "Claude", 30), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index c5bdf8a996..dd31f17ced 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -19,7 +19,7 @@ import { refreshProvidersIfStale, } from "../lib/tauri"; import { ProviderIcon } from "../components/providers/ProviderIcon"; -import { getProviderIcon } from "../components/providers/providerIcons"; +import { brandColorOnDark, getProviderIcon } from "../components/providers/providerIcons"; import { costPeriodShortLabel } from "../lib/costPeriod"; import { describeProviderState } from "../lib/providerState"; import { resetDescriptionFallback, windowDetailText } from "../lib/usageWindows"; @@ -146,7 +146,7 @@ function CostPill({ className="floatbar__cost-pill" title={`${summary.displayName}: ${title} (${estimateLabel})`} data-tauri-drag-region - style={{ "--brand": brand } as CSSProperties} + style={brandStyle(brand)} > @@ -179,6 +179,12 @@ function CostPill({ ); } +/** Pill border variables: the brand, plus a lifted tint for near-black brands on the dark glass. */ +function brandStyle(brand: string): CSSProperties { + const onDark = brandColorOnDark(brand); + return { "--brand": brand, ...(onDark ? { "--brand-dark": onDark } : {}) } as CSSProperties; +} + /** * The capacity pill shown for a single provider. * @@ -265,7 +271,7 @@ function ProviderPill({ : `${provider.displayName}: ${label} ${displaySuffix}${resetSuffix}${detailSuffix}` } data-tauri-drag-region - style={{ "--brand": brand } as CSSProperties} + style={brandStyle(brand)} > diff --git a/apps/desktop-tauri/src/lib/color.test.ts b/apps/desktop-tauri/src/lib/color.test.ts new file mode 100644 index 0000000000..b1703a1634 --- /dev/null +++ b/apps/desktop-tauri/src/lib/color.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { contrastRatio, mixWithWhite, relativeLuminance } from "./color"; + +describe("color helpers", () => { + it("computes WCAG luminance and contrast for hex colors", () => { + expect(relativeLuminance("#ffffff")).toBe(1); + expect(relativeLuminance("#000000")).toBe(0); + expect(contrastRatio("#000000", "#FFFFFF")).toBe(21); + expect(contrastRatio("#111111", "#222222")).toBeCloseTo(1.19, 2); + }); + + it("rejects anything that is not #rrggbb", () => { + expect(relativeLuminance("accent")).toBeNull(); + expect(relativeLuminance("#fff")).toBeNull(); + expect(contrastRatio("var(--x)", "#222222")).toBeNull(); + }); + + it("mixes toward white", () => { + expect(mixWithWhite("#111111", 0)).toBe("#111111"); + expect(mixWithWhite("#111111", 0.4)).toBe("#707070"); + expect(mixWithWhite("#000000", 1)).toBe("#ffffff"); + expect(mixWithWhite("nope", 0.5)).toBe("nope"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/color.ts b/apps/desktop-tauri/src/lib/color.ts new file mode 100644 index 0000000000..63265fd336 --- /dev/null +++ b/apps/desktop-tauri/src/lib/color.ts @@ -0,0 +1,38 @@ +const HEX_COLOR = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i; + +function srgbByteToLinear(byte: number): number { + const c = byte / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +function parseHex(hex: string): [number, number, number] | null { + const match = HEX_COLOR.exec(hex); + if (!match) return null; + const [r, g, b] = match.slice(1).map((part) => parseInt(part, 16)); + return [r, g, b]; +} + +/** WCAG relative luminance of a `#rrggbb` color, or null for anything else. */ +export function relativeLuminance(hex: string): number | null { + const rgb = parseHex(hex); + if (!rgb) return null; + const [r, g, b] = rgb.map(srgbByteToLinear); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** WCAG contrast ratio between two `#rrggbb` colors, or null if either is not hex. */ +export function contrastRatio(a: string, b: string): number | null { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + if (la === null || lb === null) return null; + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} + +/** Mix a `#rrggbb` color toward white by `amount` (0..1). */ +export function mixWithWhite(hex: string, amount: number): string { + const rgb = parseHex(hex); + if (!rgb) return hex; + return `#${rgb + .map((c) => Math.round(c + (255 - c) * amount).toString(16).padStart(2, "0")) + .join("")}`; +} diff --git a/apps/desktop-tauri/src/lib/menuSelection.ts b/apps/desktop-tauri/src/lib/menuSelection.ts index 4e8d4586ca..f9076c4952 100644 --- a/apps/desktop-tauri/src/lib/menuSelection.ts +++ b/apps/desktop-tauri/src/lib/menuSelection.ts @@ -1,17 +1,9 @@ import type { CSSProperties } from "react"; - -const HEX_COLOR = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i; - -function srgbByteToLinear(byte: number): number { - const c = byte / 255; - return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; -} +import { relativeLuminance } from "./color"; export function accentSelectionStyle(accent: string | null): CSSProperties { - const match = accent ? HEX_COLOR.exec(accent) : null; - if (!match) return {}; - const [r, g, b] = match.slice(1).map((hex) => srgbByteToLinear(parseInt(hex, 16))); - const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const luminance = accent ? relativeLuminance(accent) : null; + if (accent === null || luminance === null) return {}; const whiteContrast = 1.05 / (luminance + 0.05); return { "--mac-selection-bg": accent, diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index d6a478c5fb..f6e6812dd3 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -118,12 +118,13 @@ --chart-mistral: rgb(255, 82, 41); --chart-codebuff: rgb(0, 255, 149); --chart-deepseek: rgb(77, 107, 254); - --chart-windsurf: rgb(34, 197, 94); + --chart-windsurf: rgb(52, 232, 187); --chart-elevenlabs: rgb(17, 24, 39); - --chart-deepgram: rgb(19, 239, 147); - --chart-grok: rgb(0, 0, 0); - --chart-groq: rgb(245, 80, 54); - --chart-llmproxy: rgb(79, 70, 229); + --chart-deepgram: rgb(100, 103, 242); + --chart-grok: rgb(16, 163, 127); + --chart-xai: rgb(142, 142, 147); + --chart-groq: rgb(245, 104, 68); + --chart-llmproxy: rgb(36, 180, 126); /* Default cost/credits series colors (match egui credits teal). */ --chart-cost: rgb(93, 135, 255); @@ -1898,6 +1899,17 @@ body:has(.tray-panel-reveal), text-transform: none; } +/* Near-black brands (TypeSafe, V0, Replicate...) vanish on dark surfaces, so + the dark theme uses ProviderIcon's lifted --provider-brand-dark when it sets + one. Light scopes, including the tray panel's nested data-theme="light", + keep the exact brand color; the light rule comes second so it wins there. */ +[data-theme="dark"] .provider-icon { + color: var(--provider-brand-dark, var(--provider-brand, var(--accent))); +} +[data-theme="light"] .provider-icon { + color: var(--provider-brand, var(--accent)); +} + /* ── Providers sidebar status dot ──────────────────────────────────── */ .providers-sidebar__status { diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 7a4aba44da..e18e67acbf 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -1212,7 +1212,9 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { /// /// Upstream 0.70.0 audited the palette (#4075, `docs/provider-palette.md`). /// The 16 accents it adopted are pinned in the tests below; changing one -/// must not materially reduce contrast on white or `#222222`. +/// must not materially reduce contrast on white or `#222222`. The Mac 0.70.0 +/// parity pass adopted 24 more Mac descriptor colors, also pinned below; +/// Vercel is the one Mac color Windows does not take. pub fn brand_color(id: ProviderId) -> &'static str { match id { ProviderId::Codex => "#49A3B0", @@ -1232,55 +1234,55 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Kimi => "#FE603C", ProviderId::KimiK2 => "#4C00FF", ProviderId::Amp => "#F34E3F", - ProviderId::Warp => "#6366F1", - ProviderId::Ollama => "#8B95B0", + ProviderId::Warp => "#938BB4", + ProviderId::Ollama => "#888888", ProviderId::AzureOpenAI => "#0078D4", - ProviderId::T3Chat => "#8B5CF6", - ProviderId::OpenRouter => "#6B7280", + ProviderId::T3Chat => "#F56647", + ProviderId::OpenRouter => "#6467F2", ProviderId::JetBrains => "#FF3399", ProviderId::Alibaba => "#FF6A00", ProviderId::AlibabaTokenPlan => "#FF6A00", ProviderId::NanoGPT => "#687FA1", ProviderId::Infini => "#687FA1", - ProviderId::Perplexity => "#1FB8CD", + ProviderId::Perplexity => "#20B2AA", ProviderId::Abacus => "#814EE8", ProviderId::Mistral => "#FF5229", ProviderId::OpenCodeGo => "#3B82F6", - ProviderId::Kilo => "#5D87FF", + ProviderId::Kilo => "#F27027", ProviderId::Bedrock => "#01A88D", ProviderId::Codebuff => "#00FF95", ProviderId::CodeRabbit => "#FF5C35", ProviderId::DeepSeek => "#4D6BFE", ProviderId::DeepInfra => "#2A3275", ProviderId::AiAnd => "#E25C2B", - ProviderId::Windsurf => "#22C55E", + ProviderId::Windsurf => "#34E8BB", ProviderId::Manus => "#34322D", ProviderId::MiMo => "#FF6900", - ProviderId::Doubao => "#2563EB", + ProviderId::Doubao => "#3370FF", ProviderId::CommandCode => "#8C4EDD", - ProviderId::StepFun => "#999999", + ProviderId::StepFun => "#2196F2", ProviderId::Venice => "#3C8FDD", - ProviderId::OpenAIApi => "#10A37F", - ProviderId::Grok => "#111827", + ProviderId::OpenAIApi => "#0F826E", + ProviderId::Grok => "#10A37F", ProviderId::ElevenLabs => "#111827", - ProviderId::Deepgram => "#13EF93", - ProviderId::Groq => "#F55036", + ProviderId::Deepgram => "#6467F2", + ProviderId::Groq => "#F56844", ProviderId::HuggingFace => "#FFD21E", - ProviderId::Helmcode => "#4F46E5", - ProviderId::V0 => "#111827", - ProviderId::TypeSafe => "#2563EB", - ProviderId::LLMProxy => "#4F46E5", - ProviderId::Chutes => "#FF5C35", - ProviderId::LiteLLM => "#0EA5E9", - ProviderId::Poe => "#5D5FEF", + ProviderId::Helmcode => "#4934E1", + ProviderId::V0 => "#111111", + ProviderId::TypeSafe => "#111111", + ProviderId::LLMProxy => "#24B47E", + ProviderId::Chutes => "#3184FF", + ProviderId::LiteLLM => "#4C89F0", + ProviderId::Poe => "#5D5CDE", ProviderId::Devin => "#317CFF", - ProviderId::Zed => "#084CCF", + ProviderId::Zed => "#084EFF", ProviderId::CrossModel => "#C084FC", - ProviderId::Qoder => "#2563EB", + ProviderId::Qoder => "#10B981", ProviderId::CodeBuddy => "#0052D9", - ProviderId::Sakana => "#0EA5E9", + ProviderId::Sakana => "#2975DB", ProviderId::Sub2Api => "#14B8A6", - ProviderId::Wayfinder => "#14B8A6", + ProviderId::Wayfinder => "#10A37F", ProviderId::ZenMux => "#6C5CE7", ProviderId::ClinePass => "#5487C8", ProviderId::LongCat => "#29E154", @@ -1303,7 +1305,9 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::DevPass => "#2563EB", ProviderId::XKiro => "#52C99B", ProviderId::Raycast => "#FF6363", - // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. + // Mac uses white, which is invisible on the light bar track; a mid + // neutral keeps contrast on light and dark surfaces (user decision + // 2026-10-10, Mac-parity palette pass). ProviderId::Vercel => "#737373", } } @@ -1737,10 +1741,8 @@ mod tests { ]; /// Audited providers whose accent upstream kept, where the Windows accent - /// already equals upstream's final value. Chutes, Deepgram, Doubao, Groq, - /// Kilo, LiteLLM, Perplexity, Qoder, Sakana, T3 Chat and Warp keep older - /// Windows accents that differ from upstream; aligning them is out of - /// scope for the 0.70.0 port. + /// already equals upstream's final value. The audited providers whose + /// older Windows accents differed are aligned in `MAC_PARITY_ACCENTS`. const RETAINED_ACCENTS: [(ProviderId, &str); 7] = [ (ProviderId::AiAnd, "#E25C2B"), (ProviderId::Copilot, "#A855F7"), @@ -1751,6 +1753,51 @@ mod tests { (ProviderId::OpenCode, "#3B82F6"), ]; + /// Mac CodexBar 0.70.0 descriptor colors adopted for parity: (provider, + /// previous Windows accent, Mac color). Source: the `color:` of each + /// `CodexBarCore/Providers//ProviderDescriptor.swift`. + const MAC_PARITY_ACCENTS: [(ProviderId, &str, &str); 24] = [ + (ProviderId::Chutes, "#FF5C35", "#3184FF"), + (ProviderId::Deepgram, "#13EF93", "#6467F2"), + (ProviderId::Doubao, "#2563EB", "#3370FF"), + (ProviderId::Grok, "#111827", "#10A37F"), + (ProviderId::Groq, "#F55036", "#F56844"), + (ProviderId::Helmcode, "#4F46E5", "#4934E1"), + (ProviderId::Kilo, "#5D87FF", "#F27027"), + (ProviderId::LiteLLM, "#0EA5E9", "#4C89F0"), + (ProviderId::LLMProxy, "#4F46E5", "#24B47E"), + (ProviderId::Ollama, "#8B95B0", "#888888"), + (ProviderId::OpenAIApi, "#10A37F", "#0F826E"), + (ProviderId::OpenRouter, "#6B7280", "#6467F2"), + (ProviderId::Perplexity, "#1FB8CD", "#20B2AA"), + (ProviderId::Poe, "#5D5FEF", "#5D5CDE"), + (ProviderId::Qoder, "#2563EB", "#10B981"), + (ProviderId::Sakana, "#0EA5E9", "#2975DB"), + (ProviderId::StepFun, "#999999", "#2196F2"), + (ProviderId::T3Chat, "#8B5CF6", "#F56647"), + (ProviderId::TypeSafe, "#2563EB", "#111111"), + (ProviderId::V0, "#111827", "#111111"), + (ProviderId::Warp, "#6366F1", "#938BB4"), + (ProviderId::Wayfinder, "#14B8A6", "#10A37F"), + (ProviderId::Windsurf, "#22C55E", "#34E8BB"), + (ProviderId::Zed, "#084CCF", "#084EFF"), + ]; + + /// Mac-parity accents that fail the regression rule on one surface. They + /// are bar fills and icon tints, not text, and the Mac ships them as is; + /// the user chose exact Mac colors (2026-10-10). Each entry names the + /// only surface it is exempt on; every other check still applies. + const MAC_PARITY_CONTRAST_EXEMPTIONS: [(ProviderId, &str); 4] = [ + // Mac green, 6.29 -> 2.66 on white. + (ProviderId::LLMProxy, "#FFFFFF"), + // Mac emerald, 5.17 -> 2.54 on white. + (ProviderId::Qoder, "#FFFFFF"), + // Mac near-black, 3.08 -> 1.19 on #222222. + (ProviderId::TypeSafe, "#222222"), + // Mac mint, 2.28 -> 1.57 on white. + (ProviderId::Windsurf, "#FFFFFF"), + ]; + /// WCAG relative luminance of a `#RRGGBB` color, as upstream's /// `ProviderPaletteRegressionTests` computes it. fn relative_luminance(hex: &str) -> f64 { @@ -1797,6 +1844,33 @@ mod tests { } } + #[test] + fn mac_parity_accents_are_pinned() { + for (id, _, mac) in MAC_PARITY_ACCENTS { + assert_eq!(brand_color(id), mac, "{id:?}"); + } + // Mac ships #FFFFFF, invisible on the light track (user decision 2026-10-10). + assert_eq!(brand_color(ProviderId::Vercel), "#737373"); + } + + #[test] + fn mac_parity_accents_do_not_materially_regress_contrast() { + // Same rule as the upstream audit below, applied to the Mac-parity + // pass. Only the listed provider/surface pairs are exempt. + for (id, previous, _) in MAC_PARITY_ACCENTS { + for background in ["#FFFFFF", "#222222"] { + let current = contrast_ratio(brand_color(id), background); + let before = contrast_ratio(previous, background); + let passes = current >= 3.0 || before - current < 0.5; + let exempt = MAC_PARITY_CONTRAST_EXEMPTIONS.contains(&(id, background)); + assert!( + passes != exempt, + "{id:?} on {background}: {before:.2} -> {current:.2} (exempt: {exempt})" + ); + } + } + } + #[test] fn adopted_palette_accents_do_not_materially_regress_contrast() { // Upstream's controlled light and dark menu surfaces. A material