veza/apps/web/src/components/ui/card.tsx
senke 39b2b642d2 feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):

- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
  for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
  (max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
  replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
  AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
  TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:15:58 +01:00

189 lines
4.8 KiB
TypeScript

import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const cardVariants = cva(
'flex flex-col rounded-[var(--radius-xl)] text-card-foreground transition-[box-shadow,background-color,border-color,transform] duration-[var(--duration-normal)] ease-out relative overflow-hidden',
{
variants: {
variant: {
default:
'bg-card border border-border shadow-lg shadow-black/10 hover:shadow-xl hover:shadow-black/15',
elevated:
'bg-card border border-border shadow-lg hover:shadow-xl',
ghost:
'bg-transparent border-0',
outline:
'bg-transparent border border-border',
muted:
'bg-muted/50 border border-border',
glass:
'glass border border-white/10 hover:bg-[var(--glass-bg)] hover:border-white/15',
interactive:
'bg-card border-0 shadow-lg shadow-black/5 cursor-pointer hover:shadow-xl hover:-translate-y-0.5',
glow:
'bg-card border-0 shadow-lg hover:shadow-card-glow-cyan',
glowMagenta:
'bg-card border-0 shadow-lg hover:shadow-card-glow-magenta',
spotlight:
'bg-black/40 border border-white/10 hover:border-white/20',
/* Immersive surface: subtle border, lighter + diffuse shadow on hover */
surface:
'bg-card border border-white/5 shadow-none hover:bg-card/90 hover:border-white/10 hover:shadow-card-hover transition-all duration-[var(--duration-immersive)] ease-in-out',
},
padding: {
none: '',
sm: 'p-4',
default: 'p-6',
lg: 'p-8',
},
},
defaultVariants: {
variant: 'default',
padding: 'none',
},
},
)
interface CardProps
extends React.ComponentProps<'div'>,
VariantProps<typeof cardVariants> {
spotlight?: boolean;
spotlightColor?: string;
}
function Card({ className, variant, padding, spotlight, spotlightColor = 'rgba(255, 255, 255, 0.1)', ...props }: CardProps) {
const divRef = React.useRef<HTMLDivElement>(null);
const [position, setPosition] = React.useState({ x: 0, y: 0 });
const [opacity, setOpacity] = React.useState(0);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!divRef.current || (variant !== 'spotlight' && !spotlight)) return;
const div = divRef.current;
const rect = div.getBoundingClientRect();
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
const handleMouseEnter = () => {
setOpacity(1);
};
const handleMouseLeave = () => {
setOpacity(0);
};
const isSpotlight = variant === 'spotlight' || spotlight;
return (
<div
ref={divRef}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
data-slot="card"
className={cn(cardVariants({ variant, padding }), className)}
{...props}
>
{isSpotlight && (
<div
className="pointer-events-none absolute -inset-px opacity-0 transition duration-[var(--duration-normal)]"
style={{
opacity,
background: `radial-gradient(600px circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 40%)`,
}}
/>
)}
<div className="relative z-10 w-full h-full flex flex-col">{props.children}</div>
</div>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'flex flex-col gap-1.5 p-6 pb-0',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'h3'>) {
return (
<h3
data-slot="card-title"
className={cn('text-lg font-semibold leading-tight tracking-tight text-foreground', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="card-description"
className={cn('text-sm text-muted-foreground/90', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'absolute top-4 right-4',
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('p-6 pt-4', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center gap-3 p-6 pt-0', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
cardVariants,
}