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}
)
}