Gate StandardSite subscribe button custom theme on AAA contrast (#10735)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eric Bailey
2026-06-04 16:51:34 -05:00
committed by GitHub
parent f84c85aac6
commit 2f61c0a7b4
4 changed files with 141 additions and 31 deletions
+7 -1
View File
@@ -9,7 +9,12 @@ import {
setFontScale as persistFontScale, setFontScale as persistFontScale,
} from '#/alf/fonts' } from '#/alf/fonts'
import {themes} from '#/alf/themes' 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' import {type Device} from '#/storage'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf' export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
@@ -26,6 +31,7 @@ export const utils = {
rgbToHex, rgbToHex,
lighten, lighten,
darken, darken,
contrastRatio,
} }
export type Alf = { export type Alf = {
+37 -1
View File
@@ -1,4 +1,10 @@
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration' import {
contrastRatio,
darken,
hexToRgb,
lighten,
rgbToHex,
} from './colorGeneration'
describe('hexToRgb', () => { describe('hexToRgb', () => {
it('parses 6-digit hex', () => { it('parses 6-digit hex', () => {
@@ -92,3 +98,33 @@ describe('lighten / darken', () => {
expect(darken('#zzz', 10)).toBe('#zzz') 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()
})
})
+42
View File
@@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string {
.slice(1)}` .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( function rgbToHsl(
r: number, r: number,
g: number, g: number,
@@ -427,6 +427,26 @@ export function SubscribeButton({
? l`Subscribe on ${highlightedPublisher.name}` ? l`Subscribe on ${highlightedPublisher.name}`
: l`View publication` : 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 if (!view.source) return null
const publicationTitle = view.source.title const publicationTitle = view.source.title
@@ -468,8 +488,7 @@ export function SubscribeButton({
} }
} }
return ( const button = (
<StandardSiteThemeProvider view={view}>
<Link <Link
shouldProxy shouldProxy
to={view.source.uri} to={view.source.uri}
@@ -497,7 +516,14 @@ export function SubscribeButton({
</> </>
)} )}
</Link> </Link>
</StandardSiteThemeProvider> )
if (!useCustomTheme) {
return button
}
return (
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
) )
} }