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,
|
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 = {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user