diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 9ec1e70e1..a2686f474 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@reactive-resume/ui/components/spinner"; import { cn } from "@reactive-resume/utils/style"; const buttonVariants = cva( - "group/button relative inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap border border-transparent font-medium text-sm transition-[background-color,border-color,color,filter] duration-quick ease-enter disabled:pointer-events-none aria-busy:cursor-progress [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "group/button touch-target relative inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap border border-transparent font-medium text-sm transition-[background-color,border-color,color,filter] duration-quick ease-enter disabled:pointer-events-none aria-busy:cursor-progress [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { variant: { diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx index 9949d85bc..bc3286e46 100644 --- a/packages/ui/src/components/checkbox.tsx +++ b/packages/ui/src/components/checkbox.tsx @@ -7,7 +7,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { {description && ( - + // A checked row may sit on the accent tint, where ink-3 falls short of 4.5:1; ink-2 keeps it readable. + {description} )} diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 88b13baca..bb296fbcb 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -70,7 +70,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { { it("converts opaque rgb to hex", () => { @@ -123,3 +123,17 @@ describe("parseColorString", () => { }); }); }); + +describe("contrastOnWhite", () => { + it("measures WCAG contrast against white", () => { + expect(contrastOnWhite("rgba(0, 0, 0, 1)")).toBeCloseTo(21, 0); + expect(contrastOnWhite("#ffffff")).toBeCloseTo(1, 5); + // The sample template blue, just short of 4.5:1 for body text. + expect(contrastOnWhite("rgba(0, 132, 209, 1)")).toBeCloseTo(4.02, 1); + }); + + it("lays transparency over white, and treats unreadable colours as no contrast", () => { + expect(contrastOnWhite("rgba(0, 0, 0, 0)")).toBeCloseTo(1, 5); + expect(contrastOnWhite("hsl(0 0% 0%)")).toBe(1); + }); +}); diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index 0ae17a0ee..e3a1b142a 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -69,3 +69,19 @@ export function isDarkColor(colorString: string): boolean { const luminance = 0.299 * r + 0.587 * g + 0.114 * b; return luminance < 128; } + +// WCAG 2 relative luminance of an sRGB channel (0–255). +const linear = (channel: number) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +}; + +/** WCAG contrast of a colour against white, with any transparency laid over white first. 1 when it can't be read. */ +export function contrastOnWhite(colorString: string): number { + const color = parseColorString(colorString); + if (!color) return 1; + const alpha = Math.max(0, Math.min(1, color.a)); + const [r, g, b] = [color.r, color.g, color.b].map((channel) => linear(channel * alpha + 255 * (1 - alpha))); + const luminance = 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0); + return 1.05 / (luminance + 0.05); +}