From 6745fee9c42e7b4361f4465e17867107df6535c4 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 3 Oct 2026 21:49:17 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(ui):=20shared=20interaction=20primitiv?= =?UTF-8?q?es=20=E2=80=94=20press=20feedback,=20loading,=20motion=20tokens?= =?UTF-8?q?=20(#280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation layer for the interaction-polish pass: - globals.css: shared --duration-press/fast/ui + --ease-ui tokens so component code never scatters magic durations; the reduced-motion guard now also caps every CSS transition (~instant state changes) and disables smooth scrolling, while keyframe indicators (spinners) stay functional - Button: cursor-pointer (v4 preflight defaults to cursor:default), select-none, restrained active:scale-[0.98] press feedback, and a width-stable `loading` prop — spinner overlay + aria-busy, label keeps its layout box, accessible name becomes "Loading…", and the control can't double-fire - Spinner + Skeleton primitives (skeleton pulse is motion-safe only) - Input/Textarea/Select trigger: transitions, subtle hover border, aria-invalid styling; select chevron flips while open - Checkbox/Radio: cursor, color transition, tiny check zoom-in - Card: opt-in `interactive` treatment (2px lift + border emphasis) - Badge: remove hover styles — status chips are read-only, not controls - Dialog/Toast close buttons + Klaro buttons: cursor-pointer - Tests covering the Button loading contract, press affordances, Spinner/Skeleton semantics, and Checkbox toggling Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/app/globals.css | 30 +++++++++ src/components/ui/badge.tsx | 9 ++- src/components/ui/button.tsx | 58 +++++++++++++++-- src/components/ui/card.tsx | 26 ++++++-- src/components/ui/checkbox.tsx | 6 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/input.tsx | 2 +- src/components/ui/radio-group.tsx | 4 +- src/components/ui/select.tsx | 8 ++- src/components/ui/skeleton.tsx | 17 +++++ src/components/ui/spinner.tsx | 15 +++++ src/components/ui/textarea.tsx | 2 +- src/components/ui/toast.tsx | 2 +- tests/ui-interactions.test.tsx | 104 ++++++++++++++++++++++++++++++ 14 files changed, 262 insertions(+), 23 deletions(-) create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/spinner.tsx create mode 100644 tests/ui-interactions.test.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 53cd031..0f33f13 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -44,6 +44,17 @@ --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); + + /* Shared interaction timing (#280). Three durations cover every + micro-interaction so component code never scatters magic numbers: + press — pointer-down/active feedback (immediate) + fast — hover, focus, colour/state transitions + ui — menus, popovers, small panel entrances + Larger entrances keep tw-animate-css's defaults. */ + --duration-press: 100ms; + --duration-fast: 150ms; + --duration-ui: 200ms; + --ease-ui: cubic-bezier(0.2, 0, 0, 1); } @layer base { @@ -148,6 +159,24 @@ .animate-out { animation: none; } + + /* State changes go instant (#280): every CSS transition collapses to + ~0ms so hover/press/focus feedback still applies without motion. + Keyframe animations are left alone on purpose — the entrance + animations above are already suppressed, and remaining keyframe + uses (loading spinners) are functional indicators, not decoration. + The skeleton pulse opt-outs via motion-safe: instead. */ + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + transition-delay: 0ms !important; + } + + /* Anchored scrolling counts as decorative motion here. */ + html { + scroll-behavior: auto; + } } /* ---------- Klaro consent UI ---------- @@ -212,6 +241,7 @@ min-height: 2.5rem; padding: 0.5rem 1rem; margin-right: 0; + cursor: pointer; border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px); background-color: hsl(var(--secondary)); diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index f6eae7c..30de126 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -3,17 +3,20 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" +// Status chips are read-only — every Badge in the app reports state, +// none are controls. Hover styles would promise an interaction that +// isn't there (#280), so variants intentionally carry no hover effect. const badgeVariants = cva( "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + "border-transparent bg-primary text-primary-foreground", secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + "border-transparent bg-secondary text-secondary-foreground", destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + "border-transparent bg-destructive text-destructive-foreground", outline: "text-foreground", }, }, diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index f51ad82..ab04677 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -2,9 +2,21 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; +import { Spinner } from "@/components/ui/spinner"; +// Interaction layer (#280): +// - cursor-pointer: Tailwind v4 preflight leaves buttons on +// `cursor: default`, which made every action read as non-interactive. +// - select-none: rapid clicks must never select the label text. +// - active:scale-[0.98] at --duration-press: the button physically +// acknowledges the press before the network round-trip. Kept to 2% +// so it reads as weight, not a bounce. The `link` variant opts out — +// a text link should underline, not shrink. +// - transition-all + --duration-fast: colour/hover/active changes ride +// the shared timing tokens. Reduced-motion users get the same states +// instantly via the global transition cap in globals.css. const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-fast active:duration-press cursor-pointer select-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { @@ -13,7 +25,7 @@ const buttonVariants = cva( outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + link: "text-primary underline-offset-4 hover:underline active:scale-100", }, size: { default: "h-10 px-4 py-2", @@ -33,12 +45,50 @@ export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; + // True while an async action is in flight. The button becomes + // non-interactive (duplicate submissions can't fire), gains + // aria-busy, and shows a centred spinner over the hidden label — + // the label keeps its layout box so the button never changes size + // mid-interaction, and the accessible name becomes "Loading…". + loading?: boolean; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, loading = false, disabled, children, ...props }, ref) => { const Comp = asChild ? Slot : "button"; - return ; + return ( + ) get aria-disabled + pointer-events-none + // instead since anchors have no disabled attribute. + disabled={asChild ? undefined : disabled || loading} + aria-disabled={asChild && (disabled || loading) ? true : undefined} + aria-busy={loading || undefined} + {...props} + > + {loading ? ( + <> + + + Loading… + + ) : ( + children + )} + + ); } ); Button.displayName = "Button"; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 564155a..3159982 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,11 +1,27 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -const Card = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ) -); +// `interactive` marks a card whose whole surface activates something +// (wrapped in a Link, or an onClick target). The affordance stays +// restrained — a 2px lift, border emphasis, slightly deeper shadow — +// and static cards keep the plain treatment so informational panels +// don't beg for clicks (#280). Consumers wrap the card in the actual +// interactive element (which carries the focus-visible ring). +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { interactive?: boolean } +>(({ className, interactive = false, ...props }, ref) => ( +
+)); Card.displayName = "Card"; const CardHeader = React.forwardRef>( diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index b15bc2f..c0b1f18 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -11,13 +11,15 @@ const Checkbox = React.forwardRef< + {/* The check mounts on check — a tiny zoom-in makes the state + change legible; suppressed entirely under reduced-motion. */} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 11d4d54..32775ca 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 1dd2d91..fc13534 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -8,7 +8,7 @@ const Input = React.forwardRef(({ className, type, - + diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 87e5a44..60fb702 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -14,14 +14,16 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors duration-fast cursor-pointer placeholder:text-muted-foreground enabled:hover:border-foreground/30 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 data-[state=open]:border-ring data-[state=open]:[&_svg]:rotate-180", className )} {...props} > {children} - + {/* Chevron flips while the menu is open so the trigger visibly + owns the open state — data-[state=open] lives on the trigger. */} + )); @@ -98,7 +100,7 @@ const SelectItem = React.forwardRef< ) { + return ( +