Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ----------
Expand Down Expand Up @@ -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));
Expand Down
9 changes: 6 additions & 3 deletions src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
Expand Down
126 changes: 122 additions & 4 deletions src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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",
Expand All @@ -33,12 +46,117 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
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<HTMLButtonElement, ButtonProps>(
({ 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 <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
const inert = disabled || loading;
// Slotted children aren't real buttons — aria-disabled and
// pointer-events-none leave a focused <a> keyboard-activatable
// (Enter activates the href on keydown's default action) and a
// slotted <button> can still fire click on Space keyup. Block
// activation at the event level while inert, without touching
// other keys (Tab must still move focus on).
const guardClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const guardKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
}
};
// Slot calls the CHILD's own handlers before ours — an inert
// slotted element's onClick/onKeyDown would still run. While inert,
// replace activation handlers on the child itself; non-activation
// keys still fall through to the child's own handler.
const slottedChildren =
asChild &&
inert &&
React.isValidElement<{
onClick?: React.MouseEventHandler;
onKeyDown?: React.KeyboardEventHandler;
onKeyUp?: React.KeyboardEventHandler;
}>(children)
? React.cloneElement(children, {
onClick: guardClick,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
guardKeyDown(e);
} else {
children.props.onKeyDown?.(e);
}
},
onKeyUp: (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
} else {
children.props.onKeyUp?.(e);
}
},
})
: children;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }), loading && "pointer-events-none")}
ref={ref}
// Native `disabled` blocks re-clicks on real buttons; slotted
// children (usually <a>) get aria-disabled + pointer-events-none
// instead since anchors have no disabled attribute.
{...props}
// Spread props first so a caller's aria-busy/aria-disabled
// can't weaken the loading guards below.
disabled={asChild ? undefined : inert}
aria-disabled={asChild && inert ? true : undefined}
aria-busy={loading || undefined}
// An inert slotted control leaves the tab order entirely —
// matching native disabled semantics instead of letting focus
// land on a control that cannot act.
tabIndex={asChild && inert ? -1 : props.tabIndex}
onClick={asChild && inert ? guardClick : onClick}
onKeyDown={asChild && inert ? guardKeyDown : onKeyDown}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{loading && !asChild ? (
loadingText !== undefined ? (
<>
<Spinner />
Comment thread
spizeck marked this conversation as resolved.
{loadingText}
</>
) : (
<>
<span
className="inline-flex items-center justify-center gap-2 opacity-0"
aria-hidden="true"
>
{children}
</span>
<span
className="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<Spinner />
</span>
<span className="sr-only">Loading…</span>
</>
)
) : (
slottedChildren
)}
</Comp>
);
}
);
Button.displayName = "Button";
Expand Down
26 changes: 21 additions & 5 deletions src/components/ui/card.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import * as React from "react";
import { cn } from "@/lib/utils";

const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-xs", className)} {...props} />
)
);
// `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<HTMLDivElement> & { interactive?: boolean }
>(({ className, interactive = false, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-xs",
interactive &&
"transition-[transform,box-shadow,border-color] duration-fast hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-md active:translate-y-0 active:shadow-xs",
className
)}
{...props}
/>
));
Card.displayName = "Card";

const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
Expand Down
6 changes: 4 additions & 2 deletions src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background transition-colors duration-fast cursor-pointer focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
{/* The check mounts on check — a tiny zoom-in makes the state
change legible; suppressed entirely under reduced-motion. */}
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
className={cn("flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:fade-in-0 data-[state=checked]:zoom-in-75")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity duration-fast cursor-pointer hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors duration-fast file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground enabled:hover:border-foreground/30 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-invalid:border-destructive aria-invalid:ring-destructive/40 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/radio-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background transition-colors duration-fast cursor-pointer focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<RadioGroupPrimitive.Indicator className="flex items-center justify-center data-[state=checked]:animate-in data-[state=checked]:fade-in-0 data-[state=checked]:zoom-in-75">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
Expand Down
9 changes: 6 additions & 3 deletions src/components/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"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 placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"group 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",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
{/* Chevron flips while the menu is open so the trigger visibly
owns the open state. Scoped via `group` so svgs passed as
children aren't rotated too. */}
<ChevronDown className="h-4 w-4 opacity-50 transition-transform duration-fast group-data-[state=open]:rotate-180" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
Expand Down Expand Up @@ -98,7 +101,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
Expand Down
17 changes: 17 additions & 0 deletions src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";

// Loading placeholder (#280). Sized to the layout it replaces so
// content doesn't jump when real data lands. The pulse is
// motion-safe-only: under prefers-reduced-motion the block stays
// static rather than shimmering.
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
aria-hidden="true"
className={cn("rounded-md bg-muted motion-safe:animate-pulse", className)}
{...props}
/>
);
}

export { Skeleton };
27 changes: 27 additions & 0 deletions src/components/ui/spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { LoaderCircle } from "lucide-react";
import { cn } from "@/lib/utils";

// Shared loading indicator (#280). Decorative by default — Button
// renders it inside an aria-busy control. Standalone uses can pass
// `label` to get role="status" + sr-only text announcing what loads.
export function Spinner({
className,
label,
}: {
className?: string;
label?: string;
}) {
const icon = (
<LoaderCircle
className={cn("size-4 animate-spin", className)}
aria-hidden="true"
/>
);
if (!label) return icon;
return (
<span role="status" className="inline-flex">
{icon}
<span className="sr-only">{label}</span>
</span>
);
}
2 changes: 1 addition & 1 deletion src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors duration-fast placeholder:text-muted-foreground enabled:hover:border-foreground/30 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-invalid:border-destructive aria-invalid:ring-destructive/40 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
Expand Down
Loading
Loading