From dcddc5639a613f688ea60e8cb5112a65a09a94ca Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 01:22:19 +0200 Subject: [PATCH] fix(ui): 44px touch targets and readable descriptions on tinted rows On coarse pointers, buttons, switches, checkboxes and tabs get an invisible hit area of at least 44x44 centred on them, without changing how they look. A checked switch row's description steps up to ink-2, since ink-3 falls short of 4.5:1 on the accent tint. Adds contrastOnWhite to the colour utilities. --- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/checkbox.tsx | 2 +- packages/ui/src/components/switch.tsx | 8 ++++++-- packages/ui/src/components/tabs.tsx | 2 +- packages/ui/src/styles/globals.css | 18 ++++++++++++++++++ packages/utils/src/color.test.ts | 16 +++++++++++++++- packages/utils/src/color.ts | 16 ++++++++++++++++ 7 files changed, 58 insertions(+), 6 deletions(-) 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); +}