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:
+7
-1
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source.uri}
|
||||
label={label}
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
style={[
|
||||
style,
|
||||
a.gap_sm,
|
||||
preview ? a.pointer_events_none : a.pointer_events_auto,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}>
|
||||
{highlightedPublisher ? (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
|
||||
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
|
||||
</View>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
<ButtonIcon icon={ArrowTopRightIcon} />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
|
||||
if (!useCustomTheme) {
|
||||
return button
|
||||
}
|
||||
|
||||
return (
|
||||
<StandardSiteThemeProvider view={view}>
|
||||
<Link
|
||||
shouldProxy
|
||||
to={view.source.uri}
|
||||
label={label}
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
style={[
|
||||
style,
|
||||
a.gap_sm,
|
||||
preview ? a.pointer_events_none : a.pointer_events_auto,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}>
|
||||
{highlightedPublisher ? (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
|
||||
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
|
||||
</View>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ButtonText>{cta}</ButtonText>
|
||||
<ButtonIcon icon={ArrowTopRightIcon} />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</StandardSiteThemeProvider>
|
||||
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user