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.
This commit is contained in:
Amruth Pillai
2026-09-29 01:22:19 +02:00
parent 55a3284360
commit dcddc5639a
7 changed files with 58 additions and 6 deletions
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -7,7 +7,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border-[1.5px] border-line-2 bg-raised outline-none transition-[background-color,border-color] duration-quick aria-invalid:border-danger data-disabled:cursor-not-allowed data-checked:border-accent data-indeterminate:border-accent data-checked:bg-accent data-indeterminate:bg-accent data-checked:text-on-accent data-indeterminate:text-on-accent data-disabled:opacity-50",
"peer touch-target relative flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border-[1.5px] border-line-2 bg-raised outline-none transition-[background-color,border-color] duration-quick aria-invalid:border-danger data-disabled:cursor-not-allowed data-checked:border-accent data-indeterminate:border-accent data-checked:bg-accent data-indeterminate:bg-accent data-checked:text-on-accent data-indeterminate:text-on-accent data-disabled:opacity-50",
className,
)}
{...props}
+6 -2
View File
@@ -28,7 +28,7 @@ function Switch({ className, size = "default", ...props }: SwitchProps) {
data-slot="switch"
data-size={size}
className={cn(
"group/switch peer inline-flex shrink-0 rounded-full outline-none data-disabled:cursor-not-allowed",
"group/switch peer touch-target relative inline-flex shrink-0 rounded-full outline-none data-disabled:cursor-not-allowed",
className,
)}
{...props}
@@ -66,7 +66,11 @@ function SwitchRow({ className, label, description, size = "default", ...props }
{label}
</span>
{description && (
<span id={descriptionId} className="text-[13px] text-ink-3 leading-[18px]">
// A checked row may sit on the accent tint, where ink-3 falls short of 4.5:1; ink-2 keeps it readable.
<span
id={descriptionId}
className="text-[13px] text-ink-3 leading-[18px] group-data-checked/switch:text-ink-2"
>
{description}
</span>
)}
+1 -1
View File
@@ -70,7 +70,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex min-w-fit items-center justify-center gap-1.5 whitespace-nowrap font-medium transition-colors duration-quick hover:text-ink disabled:pointer-events-none disabled:text-ink-3 aria-disabled:pointer-events-none aria-disabled:text-ink-3 data-active:text-ink group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"touch-target relative inline-flex min-w-fit items-center justify-center gap-1.5 whitespace-nowrap font-medium transition-colors duration-quick hover:text-ink disabled:pointer-events-none disabled:text-ink-3 aria-disabled:pointer-events-none aria-disabled:text-ink-3 data-active:text-ink group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=default]/tabs-list:h-full group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:rounded-sm group-data-[variant=default]/tabs-list:px-3 group-data-[variant=default]/tabs-list:text-[13px]",
"group-data-[variant=line]/tabs-list:h-full group-data-[variant=line]/tabs-list:text-sm group-data-[variant=line]/tabs-list:data-active:shadow-[inset_0_-2px_0_var(--ink)]",
className,
+18
View File
@@ -241,6 +241,24 @@
}
}
/*
* Touch screens: a control's hit area grows to at least 44×44, centred on it, without changing how it looks
* (README §4.7). The control needs `position: relative`.
*/
@utility touch-target {
@media (pointer: coarse) {
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: max(100%, 44px);
height: max(100%, 44px);
translate: -50% -50%;
}
}
}
@utility material-symbol {
font-family: "Material Symbols Rounded", sans-serif;
font-weight: 300;
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isDarkColor, parseColorString, rgbaStringToHex } from "./color";
import { contrastOnWhite, isDarkColor, parseColorString, rgbaStringToHex } from "./color";
describe("rgbaStringToHex", () => {
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);
});
});
+16
View File
@@ -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);
}