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..ab484e2 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -2,9 +2,22 @@ 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. +// - explicit transition list + --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-[color,background-color,border-color,box-shadow,opacity,transform] 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 +26,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 +46,117 @@ 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…". + // Pass loadingText when the in-flight verb matters ("Deleting…"): + // it replaces the label outright — allowed to shift width, callers + // pick it deliberately for consequential actions. + loading?: boolean; + loadingText?: string; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, loading = false, loadingText, disabled, children, onClick, onKeyDown, ...props }, ref) => { const Comp = asChild ? Slot : "button"; - return ; + const inert = disabled || loading; + // Slotted children aren't real buttons — aria-disabled and + // pointer-events-none leave a focused keyboard-activatable + // (Enter activates the href on keydown's default action) and a + // slotted