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