From 4c33951663df1f4aa10576a36335436183cb7a56 Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:40:46 -0700 Subject: [PATCH] fix(branding): use black as the primary color for monochrome brands (#5098) Brands with a black logo and black calls to action, with color only in links or artwork, had their primary color reported as whatever link or artwork color the model saw, because the merge step rejects near-black on light pages to keep dark nav bars out. When the chosen primary button is a neutral near-black on a light page, and the chosen color doesn't also fill a button, the primary color is now that black and the colored pick moves to accent. Navy buttons, dark pages, and brands that pair a black button with colored ones are unchanged. Co-authored-by: Claude Opus 5.5 --- .../lib/branding/color-roles.test.ts | 65 +++++++++++++++++++ apps/api/src/lib/branding/color-roles.ts | 19 ++++++ apps/api/src/lib/branding/merge.ts | 30 +++++++++ 3 files changed, 114 insertions(+) diff --git a/apps/api/src/__tests__/lib/branding/color-roles.test.ts b/apps/api/src/__tests__/lib/branding/color-roles.test.ts index 7bea2d974..8eea51eba 100644 --- a/apps/api/src/__tests__/lib/branding/color-roles.test.ts +++ b/apps/api/src/__tests__/lib/branding/color-roles.test.ts @@ -251,3 +251,68 @@ describe("processRawBranding primary", () => { expect(profile.colors?.primary).toBe("#635BFF"); }); }); + +describe("monochrome brands", () => { + const button = (background: string, textColor: string, index: number) => ({ + index, + text: `Button ${index}`, + html: "", + classes: "", + background, + textColor, + }); + const llmPicking = (primaryColor: string) => ({ + ...emptyLlm, + buttonClassification: { + primaryButtonIndex: 0, + primaryButtonReasoning: "n/a", + secondaryButtonIndex: -1, + secondaryButtonReasoning: "n/a", + confidence: 0.9, + }, + colorRoles: { + primaryColor, + accentColor: "", + backgroundColor: "#FFFFFF", + textPrimary: "#171717", + confidence: 0.8, + }, + }); + + it("uses the black call to action when the chosen color is only a link color", () => { + const merged = mergeBrandingResults( + { colorScheme: "light", colors: {} }, + llmPicking("#0072F5"), + [button("#171717", "#FFFFFF", 0), button("#FFFFFF", "#171717", 1)], + ); + expect(merged.colors?.primary).toBe("#171717"); + expect(merged.colors?.accent).toBe("#0072F5"); + }); + + it("keeps a color that also fills a button", () => { + const merged = mergeBrandingResults( + { colorScheme: "light", colors: {} }, + llmPicking("#0071E3"), + [button("#000000", "#FFFFFF", 0), button("#0071E3", "#FFFFFF", 1)], + ); + expect(merged.colors?.primary).toBe("#0071E3"); + }); + + it("keeps a navy call to action from replacing the brand color", () => { + const merged = mergeBrandingResults( + { colorScheme: "light", colors: {} }, + llmPicking("#635BFF"), + [button("#0A2540", "#FFFFFF", 0)], + ); + expect(merged.colors?.primary).toBe("#635BFF"); + }); + + it("leaves dark pages alone", () => { + const merged = mergeBrandingResults( + { colorScheme: "dark", colors: {} }, + llmPicking("#1ED760"), + [button("#111111", "#FFFFFF", 0)], + ); + expect(merged.colors?.primary).toBe("#1ED760"); + }); +}); diff --git a/apps/api/src/lib/branding/color-roles.ts b/apps/api/src/lib/branding/color-roles.ts index 044fd2911..75402bbfb 100644 --- a/apps/api/src/lib/branding/color-roles.ts +++ b/apps/api/src/lib/branding/color-roles.ts @@ -32,6 +32,25 @@ export function isNearBlack(hex: string): boolean { return contrastYIQ(hex) < 40 && max < 140; } +/** Near-black with no hue: a monochrome brand's black, not navy chrome. */ +export function isNeutralDark(hex: string): boolean { + const h = hex.replace("#", ""); + if (h.length < 6) return false; + const channels = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); + return isNearBlack(hex) && Math.max(...channels) - Math.min(...channels) < 30; +} + +/** Straight-line RGB distance; Infinity unless both are #RRGGBB. */ +export function colorDistance(a?: string | null, b?: string | null): number { + const x = normalizeRoleHex(a); + const y = normalizeRoleHex(b); + if (!x || !y || x.length !== 7 || y.length !== 7) return Infinity; + const channels = (hex: string) => + [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)); + const [p, q] = [channels(x), channels(y)]; + return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]); +} + /** Chromatic brand color — not gray chrome, and not a near-black nav on light pages. */ export function isUsableBrandPrimary( hex: string, diff --git a/apps/api/src/lib/branding/merge.ts b/apps/api/src/lib/branding/merge.ts index fa9e3ced5..90596ba01 100644 --- a/apps/api/src/lib/branding/merge.ts +++ b/apps/api/src/lib/branding/merge.ts @@ -1,5 +1,7 @@ import { BrandingProfile } from "../../types/branding"; import { + colorDistance, + isNeutralDark, isUsableBrandPrimary, normalizeRoleHex, shouldApplyLlmColorRoles, @@ -318,6 +320,34 @@ export function mergeBrandingResults( } } + // Monochrome brands (black logo and calls to action, color only in links or + // artwork) have black as their primary color, which the chrome filters above + // reject on light pages. A near-black primary button is that signal, unless + // the chosen color also fills a button, as when a brand pairs a black call to + // action with colored ones. + const primaryFill = normalizeRoleHex( + merged.components?.buttonPrimary?.background, + ); + const primaryColor = normalizeRoleHex(merged.colors?.primary); + if ( + merged.colorScheme !== "dark" && + primaryFill && + isNeutralDark(primaryFill) && + primaryColor && + primaryColor !== primaryFill && + !buttonSnapshots.some( + b => + colorDistance(b.background, primaryColor) < 40 || + colorDistance(b.borderColor, primaryColor) < 40, + ) + ) { + merged.colors = { + ...merged.colors, + primary: primaryFill, + accent: merged.colors?.accent ?? primaryColor, + }; + } + if (llm.personality) { (merged as any).personality = llm.personality; }