From 2f61c0a7b4ab735bf7ace70aa85ffc09206388e1 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Jun 2026 16:51:34 -0500 Subject: [PATCH] Gate StandardSite subscribe button custom theme on AAA contrast (#10735) Co-authored-by: Claude Opus 4.8 --- src/alf/index.tsx | 8 +- src/alf/util/colorGeneration.test.ts | 38 ++++++++- src/alf/util/colorGeneration.ts | 42 ++++++++++ .../Post/Embed/StandardSiteEmbed/index.tsx | 84 ++++++++++++------- 4 files changed, 141 insertions(+), 31 deletions(-) diff --git a/src/alf/index.tsx b/src/alf/index.tsx index 0cb5ceb3f7..a1a2d8adaa 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -9,7 +9,12 @@ import { setFontScale as persistFontScale, } from '#/alf/fonts' import {themes} from '#/alf/themes' -import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration' +import { + contrastRatio, + darken, + lighten, + rgbToHex, +} from '#/alf/util/colorGeneration' import {type Device} from '#/storage' export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf' @@ -26,6 +31,7 @@ export const utils = { rgbToHex, lighten, darken, + contrastRatio, } export type Alf = { diff --git a/src/alf/util/colorGeneration.test.ts b/src/alf/util/colorGeneration.test.ts index c4a2b0bbb5..8d330201e0 100644 --- a/src/alf/util/colorGeneration.test.ts +++ b/src/alf/util/colorGeneration.test.ts @@ -1,4 +1,10 @@ -import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration' +import { + contrastRatio, + darken, + hexToRgb, + lighten, + rgbToHex, +} from './colorGeneration' describe('hexToRgb', () => { it('parses 6-digit hex', () => { @@ -92,3 +98,33 @@ describe('lighten / darken', () => { expect(darken('#zzz', 10)).toBe('#zzz') }) }) + +describe('contrastRatio', () => { + it('returns 21 for black on white', () => { + expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5) + }) + + it('returns 1 for identical colors', () => { + expect(contrastRatio('#abcdef', '#abcdef')).toBeCloseTo(1, 5) + }) + + it('is symmetric regardless of argument order', () => { + expect(contrastRatio('#123456', '#fedcba')).toBeCloseTo( + contrastRatio('#fedcba', '#123456')!, + 5, + ) + }) + + it('clears AAA large text (4.5:1) for a high-contrast pairing', () => { + expect(contrastRatio('#1d3a5f', '#ffffff')!).toBeGreaterThanOrEqual(4.5) + }) + + it('fails AAA large text (4.5:1) for a low-contrast pairing', () => { + expect(contrastRatio('#777777', '#888888')!).toBeLessThan(4.5) + }) + + it('returns null for invalid hex input', () => { + expect(contrastRatio('not-a-color', '#ffffff')).toBeNull() + expect(contrastRatio('#ffffff', '#zzz')).toBeNull() + }) +}) diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts index 85659af25f..f3be07d502 100644 --- a/src/alf/util/colorGeneration.ts +++ b/src/alf/util/colorGeneration.ts @@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string { .slice(1)}` } +/** + * Computes the WCAG contrast ratio between two colors, ranging from 1 (no + * contrast) to 21 (maximum contrast, i.e. black on white). Returns null if + * either argument is not a valid hex color. + * + * @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio + */ +export function contrastRatio(hexA: string, hexB: string): number | null { + const rgbA = hexToRgb(hexA) + const rgbB = hexToRgb(hexB) + if (!rgbA || !rgbB) return null + const luminanceA = relativeLuminance(rgbA) + const luminanceB = relativeLuminance(rgbB) + const lighter = Math.max(luminanceA, luminanceB) + const darker = Math.min(luminanceA, luminanceB) + return (lighter + 0.05) / (darker + 0.05) +} + +/** + * Computes the WCAG relative luminance of an RGB color, ranging from 0 (black) + * to 1 (white). + * + * @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance + */ +function relativeLuminance({ + r, + g, + b, +}: { + r: number + g: number + b: number +}): number { + const toLinear = (channel: number) => { + const normalized = channel / 255 + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b) +} + function rgbToHsl( r: number, g: number, diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx index c7b8abf88c..2c38f3806f 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx @@ -427,6 +427,26 @@ export function SubscribeButton({ ? l`Subscribe on ${highlightedPublisher.name}` : l`View publication` + /* + * The custom site theme paints the button background with `accent` and the + * text with `accentForeground`. Only honor it when that pairing clears WCAG + * AAA (4.5:1) for large text, which the button's bold label qualifies as. + * Otherwise we fall through to the default `secondary_inverted` styling, + * which is guaranteed to be legible. + */ + const {accentRGB, accentForegroundRGB} = view.source?.theme || {} + let useCustomTheme = false + if (accentRGB && accentForegroundRGB) { + const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b) + const accentForeground = utils.rgbToHex( + accentForegroundRGB.r, + accentForegroundRGB.g, + accentForegroundRGB.b, + ) + const ratio = utils.contrastRatio(accent, accentForeground) + useCustomTheme = ratio !== null && ratio >= 4.5 + } + if (!view.source) return null const publicationTitle = view.source.title @@ -468,36 +488,42 @@ export function SubscribeButton({ } } + const button = ( + + {highlightedPublisher ? ( + <> + + + + {cta} + + ) : ( + <> + {cta} + + + )} + + ) + + if (!useCustomTheme) { + return button + } + return ( - - - {highlightedPublisher ? ( - <> - - - - {cta} - - ) : ( - <> - {cta} - - - )} - - + {button} ) }