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 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-10-12 09:40:46 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 94ba4a3eb1
commit 4c33951663
3 changed files with 114 additions and 0 deletions
@@ -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");
});
});
+19
View File
@@ -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,
+30
View File
@@ -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;
}