feat(ui): hover cards, Spotify player layout, scrollbar tokens, context menu integration

HoverCard component (new):
- Rich preview cards on hover with framer-motion animation
- Viewport-aware positioning, portal rendering, open/close delays
- UserHoverContent: Discord-style user preview (avatar, bio, stats, follow)
- TrackHoverContent: Spotify-style track preview (cover, stats, play)

Audio player — Spotify-like 3-column layout:
- grid-cols-3 layout: track info | controls | volume+queue
- Progress bar moved to top edge (minimal variant)
- Glassmorphism (bg-background/95 backdrop-blur-md)
- Prominent centered play button (h-10 w-10 rounded-full, active:scale-95)
- Title marquee animation for long track names
- Reduced padding for tighter premium feel

Scrollbar styling:
- Migrated hardcoded rgba() to semantic tokens via color-mix(in oklch)
- Added transition on thumb hover for smooth visual feedback

ContextMenu integration:
- TrackListRow wrapped with ContextMenu (play, like, more actions)
- Dynamic items based on available callbacks

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-09 23:18:46 +01:00
parent aeade50247
commit b1f5fbf0bf
12 changed files with 737 additions and 56 deletions

View file

@ -63,10 +63,20 @@ export function AudioPlayer(_props: Props = {}) {
return (
<>
<audio ref={audioRef} preload="metadata" />
<div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border shadow-lg z-50">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<div className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-md border-t border-border z-50">
{/* Full-width progress bar at the very top of the player */}
<AudioPlayerProgress
currentTime={currentTime}
duration={duration}
onSeek={handleSeek}
variant="minimal"
/>
<div className="container mx-auto px-4 py-2">
<div className="grid grid-cols-3 items-center gap-4">
{/* Left: Track info */}
<AudioPlayerTrackInfo track={currentTrack} />
{/* Center: Playback controls */}
<AudioPlayerControls
isPlaying={isPlaying}
shuffle={shuffle}
@ -77,38 +87,37 @@ export function AudioPlayer(_props: Props = {}) {
onToggleShuffle={toggleShuffle}
onRepeatCycle={handleRepeatCycle}
/>
<AudioPlayerProgress
currentTime={currentTime}
duration={duration}
onSeek={handleSeek}
/>
<AudioPlayerVolume
volume={volume}
muted={muted}
onVolumeChange={handleVolumeChange}
onToggleMute={toggleMute}
/>
<Tooltip
content={
showQueue
? t('player.hideQueue', 'Hide queue')
: t('player.showQueue', 'Show queue')
}
>
<Button
variant="ghost"
size="icon"
onClick={() => setShowQueue(!showQueue)}
className={showQueue ? 'text-primary' : ''}
aria-label={
{/* Right: Volume + Queue */}
<div className="flex items-center justify-end gap-2">
<AudioPlayerVolume
volume={volume}
muted={muted}
onVolumeChange={handleVolumeChange}
onToggleMute={toggleMute}
/>
<Tooltip
content={
showQueue
? t('player.hideQueue', 'Hide queue')
: t('player.showQueue', 'Show queue')
}
>
<List className="h-4 w-4" />
</Button>
</Tooltip>
<Button
variant="ghost"
size="icon"
onClick={() => setShowQueue(!showQueue)}
className={showQueue ? 'text-primary' : ''}
aria-label={
showQueue
? t('player.hideQueue', 'Hide queue')
: t('player.showQueue', 'Show queue')
}
>
<List className="h-4 w-4" />
</Button>
</Tooltip>
</div>
</div>
</div>
</div>

View file

@ -29,7 +29,7 @@ export function AudioPlayerControls({
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<div className="flex items-center justify-center gap-1">
<Tooltip content={shuffle ? t('player.shuffleOn', 'Shuffle: On') : t('player.shuffleOff', 'Shuffle: Off')}>
<Button
variant="ghost"
@ -47,7 +47,12 @@ export function AudioPlayerControls({
</Button>
</Tooltip>
<Tooltip content={isPlaying ? t('player.pause', 'Pause') : t('player.play', 'Play')}>
<Button size="icon" onClick={onPlayPause} aria-label={isPlaying ? t('player.pause', 'Pause') : t('player.play', 'Play')}>
<Button
size="default"
onClick={onPlayPause}
className="h-10 w-10 rounded-full transition-transform active:scale-95"
aria-label={isPlaying ? t('player.pause', 'Pause') : t('player.play', 'Play')}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
</Tooltip>

View file

@ -5,13 +5,30 @@ interface AudioPlayerProgressProps {
currentTime: number;
duration: number;
onSeek: (value: number[]) => void;
/** "default" shows time labels on sides; "minimal" renders a thin full-width bar (for top-of-player placement). */
variant?: 'default' | 'minimal';
}
export function AudioPlayerProgress({
currentTime,
duration,
onSeek,
variant = 'default',
}: AudioPlayerProgressProps) {
if (variant === 'minimal') {
return (
<div className="w-full group">
<Slider
value={[currentTime]}
max={duration || 1}
step={0.1}
onValueChange={onSeek}
className="w-full"
/>
</div>
);
}
return (
<div className="flex items-center gap-2 flex-1">
<span className="text-xs text-muted-foreground w-12 text-right">

View file

@ -12,34 +12,38 @@ export function AudioPlayerSkeleton({ className }: AudioPlayerSkeletonProps) {
return (
<div
className={cn(
'fixed bottom-0 left-0 right-0 bg-background border-t border-border z-50',
'fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-md border-t border-border z-50',
className,
)}
>
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 flex-1 min-w-0">
<Skeleton className="w-12 h-12 rounded" />
<div className="flex-1 space-y-2">
{/* Top progress bar skeleton */}
<Skeleton className="w-full h-1 rounded-none" />
<div className="container mx-auto px-4 py-2">
<div className="grid grid-cols-3 items-center gap-4">
{/* Left: Track info */}
<div className="flex items-center gap-3 min-w-0">
<Skeleton className="w-10 h-10 rounded shrink-0" />
<div className="flex-1 space-y-2 min-w-0">
<Skeleton className="h-3 w-3/4 rounded" />
<Skeleton className="h-2 w-1/2 rounded" />
</div>
</div>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((i) => (
{/* Center: Controls */}
<div className="flex items-center justify-center gap-1">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-9 w-9 rounded" />
))}
<Skeleton className="h-10 w-10 rounded-full" />
{[4, 5].map((i) => (
<Skeleton key={i} className="h-9 w-9 rounded" />
))}
</div>
<div className="flex-1 flex items-center gap-2">
<Skeleton className="w-12 h-3 rounded" />
<Skeleton className="flex-1 h-2 rounded" />
<Skeleton className="w-12 h-3 rounded" />
</div>
<div className="flex items-center gap-2">
{/* Right: Volume + Queue */}
<div className="flex items-center justify-end gap-2">
<Skeleton className="h-9 w-9 rounded" />
<Skeleton className="w-24 h-2 rounded" />
<Skeleton className="h-9 w-9 rounded" />
</div>
<Skeleton className="h-9 w-9 rounded" />
</div>
</div>
</div>

View file

@ -1,3 +1,6 @@
import { useRef, useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface Track {
title?: string;
artist?: string;
@ -9,17 +12,39 @@ interface AudioPlayerTrackInfoProps {
}
export function AudioPlayerTrackInfo({ track }: AudioPlayerTrackInfoProps) {
const titleRef = useRef<HTMLParagraphElement>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const el = titleRef.current;
if (el) {
setIsOverflowing(el.scrollWidth > el.clientWidth);
}
}, [track.title]);
return (
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex items-center gap-3 min-w-0">
{track.cover && (
<img
src={track.cover}
alt={track.title ?? ''}
className="w-12 h-12 rounded object-cover"
className="w-10 h-10 rounded object-cover shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{track.title}</p>
<div className="overflow-hidden">
<p
ref={titleRef}
className={cn(
'text-sm font-medium text-foreground whitespace-nowrap',
isOverflowing
? 'animate-marquee'
: 'truncate',
)}
>
{track.title}
</p>
</div>
<p className="text-xs text-muted-foreground truncate">{track.artist}</p>
</div>
</div>

View file

@ -0,0 +1,268 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { HoverCardProps, HoverCardPosition } from './types';
/**
* Calculate the position of the hover card relative to the trigger element.
* Flips to the opposite side when there isn't enough viewport space.
*/
function calculateCardPosition(
triggerRect: DOMRect,
cardRect: DOMRect,
preferred: HoverCardPosition,
gap = 12,
): { top: number; left: number; actual: HoverCardPosition } {
const viewport = {
width: window.innerWidth,
height: window.innerHeight,
};
let actual = preferred;
// Check if preferred position has enough space, otherwise flip
switch (preferred) {
case 'top':
if (triggerRect.top - cardRect.height - gap < 0) actual = 'bottom';
break;
case 'bottom':
if (triggerRect.bottom + cardRect.height + gap > viewport.height) actual = 'top';
break;
case 'left':
if (triggerRect.left - cardRect.width - gap < 0) actual = 'right';
break;
case 'right':
if (triggerRect.right + cardRect.width + gap > viewport.width) actual = 'left';
break;
}
let top = 0;
let left = 0;
const padding = 8;
switch (actual) {
case 'top':
top = triggerRect.top - cardRect.height - gap;
left = triggerRect.left + triggerRect.width / 2 - cardRect.width / 2;
break;
case 'bottom':
top = triggerRect.bottom + gap;
left = triggerRect.left + triggerRect.width / 2 - cardRect.width / 2;
break;
case 'left':
top = triggerRect.top + triggerRect.height / 2 - cardRect.height / 2;
left = triggerRect.left - cardRect.width - gap;
break;
case 'right':
top = triggerRect.top + triggerRect.height / 2 - cardRect.height / 2;
left = triggerRect.right + gap;
break;
}
// Clamp within viewport
left = Math.max(padding, Math.min(left, viewport.width - cardRect.width - padding));
top = Math.max(padding, Math.min(top, viewport.height - cardRect.height - padding));
return { top, left, actual };
}
/**
* Get the initial y-offset for the entrance animation based on position.
*/
function getInitialY(position: HoverCardPosition): number {
switch (position) {
case 'top':
return 4;
case 'bottom':
return -4;
default:
return 0;
}
}
/**
* HoverCard - Discord-style rich preview card on hover
*
* Shows a rich content card when hovering over a trigger element.
* Uses a portal for rendering and framer-motion for smooth animations.
*
* @example
* ```tsx
* <HoverCard
* content={<UserHoverContent name="John" username="johndoe" />}
* >
* <span className="text-primary cursor-pointer">@johndoe</span>
* </HoverCard>
* ```
*/
export function HoverCard({
content,
children,
position = 'bottom',
openDelay = 400,
closeDelay = 200,
disabled = false,
className,
maxWidth,
}: HoverCardProps) {
const [visible, setVisible] = useState(false);
const [cardStyle, setCardStyle] = useState<React.CSSProperties>({});
const [actualPosition, setActualPosition] = useState<HoverCardPosition>(position);
const triggerRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const open = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (openTimerRef.current) return;
openTimerRef.current = setTimeout(() => {
setVisible(true);
openTimerRef.current = null;
}, openDelay);
}, [openDelay]);
const close = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
if (closeTimerRef.current) return;
closeTimerRef.current = setTimeout(() => {
setVisible(false);
closeTimerRef.current = null;
}, closeDelay);
}, [closeDelay]);
// Recalculate position when visible
useEffect(() => {
if (!visible || !triggerRef.current) return;
const updatePosition = () => {
if (!triggerRef.current || !cardRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const cardRect = cardRef.current.getBoundingClientRect();
const result = calculateCardPosition(triggerRect, cardRect, position);
setActualPosition(result.actual);
setCardStyle({
top: result.top,
left: result.left,
});
};
// Position on next frame after mount
requestAnimationFrame(updatePosition);
}, [visible, position]);
// Close on scroll or resize
useEffect(() => {
if (!visible) return;
const handleDismiss = () => {
clearTimers();
setVisible(false);
};
window.addEventListener('scroll', handleDismiss, true);
window.addEventListener('resize', handleDismiss);
return () => {
window.removeEventListener('scroll', handleDismiss, true);
window.removeEventListener('resize', handleDismiss);
};
}, [visible, clearTimers]);
// Close on click outside
useEffect(() => {
if (!visible) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
triggerRef.current &&
!triggerRef.current.contains(target) &&
cardRef.current &&
!cardRef.current.contains(target)
) {
clearTimers();
setVisible(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [visible, clearTimers]);
// Cleanup timers on unmount
useEffect(() => {
return clearTimers;
}, [clearTimers]);
if (disabled) {
return <>{children}</>;
}
return (
<>
<div
ref={triggerRef}
className="inline-block"
onMouseEnter={open}
onMouseLeave={close}
>
{children}
</div>
{createPortal(
<AnimatePresence>
{visible && (
<motion.div
ref={cardRef}
role="dialog"
className={cn(
'fixed z-50 p-4 max-w-xs',
'bg-popover border border-border rounded-xl shadow-lg backdrop-blur-md',
className,
)}
style={{
...cardStyle,
...(maxWidth != null && { maxWidth: `${maxWidth}px` }),
}}
initial={{ opacity: 0, scale: 0.96, y: getInitialY(actualPosition) }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
onMouseEnter={open}
onMouseLeave={close}
>
{content}
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}

View file

@ -0,0 +1,126 @@
import { Button } from '@/components/ui/button';
import { Play, Clock, Tag, Activity } from 'lucide-react';
export interface TrackHoverContentProps {
/** Track title */
title: string;
/** Artist name */
artist: string;
/** Album name */
album?: string;
/** Cover art URL */
cover?: string;
/** Duration string (e.g. "3:45") */
duration?: string;
/** Genre label */
genre?: string;
/** Beats per minute */
bpm?: number;
/** Callback when play button is clicked */
onPlay?: () => void;
}
/**
* TrackHoverContent - Spotify-style track preview card
*
* Designed to be used as the `content` prop of `HoverCard`.
*
* @example
* ```tsx
* <HoverCard
* content={
* <TrackHoverContent
* title="Midnight Sun"
* artist="Aurora"
* album="Ethereal"
* cover="/covers/midnight.jpg"
* duration="4:32"
* genre="Ambient"
* bpm={120}
* onPlay={() => play(track)}
* />
* }
* >
* <span>Midnight Sun</span>
* </HoverCard>
* ```
*/
export function TrackHoverContent({
title,
artist,
album,
cover,
duration,
genre,
bpm,
onPlay,
}: TrackHoverContentProps) {
return (
<div className="flex gap-3">
{/* Cover art */}
<div className="relative flex-shrink-0">
<div className="size-16 rounded-lg overflow-hidden bg-muted">
{cover ? (
<img
src={cover}
alt={`${title} cover`}
className="size-full object-cover"
/>
) : (
<div className="size-full flex items-center justify-center">
<Activity className="size-6 text-muted-foreground" />
</div>
)}
</div>
{/* Play overlay */}
{onPlay && (
<Button
variant="default"
size="icon"
className="absolute inset-0 m-auto size-8 opacity-0 hover:opacity-100 transition-opacity"
onClick={onPlay}
aria-label={`Play ${title}`}
>
<Play className="size-4" />
</Button>
)}
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{title}
</p>
<p className="text-xs text-muted-foreground truncate">{artist}</p>
{album && (
<p className="text-xs text-muted-foreground/70 truncate">{album}</p>
)}
{/* Stats row */}
{(duration || genre || bpm) && (
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
{duration && (
<span className="flex items-center gap-1">
<Clock className="size-3" />
{duration}
</span>
)}
{genre && (
<span className="flex items-center gap-1">
<Tag className="size-3" />
{genre}
</span>
)}
{bpm && (
<span className="flex items-center gap-1">
<Activity className="size-3" />
{bpm} BPM
</span>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,128 @@
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Users, Music } from 'lucide-react';
export interface UserHoverContentProps {
/** Display name */
name: string;
/** Username handle */
username: string;
/** Avatar image URL */
avatar?: string;
/** Short biography */
bio?: string;
/** Number of followers */
followersCount?: number;
/** Number of tracks */
tracksCount?: number;
/** Whether the current user follows this user */
isFollowing?: boolean;
/** Callback when follow button is clicked */
onFollow?: () => void;
}
/**
* UserHoverContent - Discord-style user card preview
*
* Designed to be used as the `content` prop of `HoverCard`.
*
* @example
* ```tsx
* <HoverCard
* content={
* <UserHoverContent
* name="Jane Doe"
* username="janedoe"
* avatar="/avatars/jane.jpg"
* bio="Producer & DJ"
* followersCount={1200}
* tracksCount={34}
* />
* }
* >
* <span>@janedoe</span>
* </HoverCard>
* ```
*/
export function UserHoverContent({
name,
username,
avatar,
bio,
followersCount,
tracksCount,
isFollowing = false,
onFollow,
}: UserHoverContentProps) {
return (
<div className="-m-4">
{/* Cover gradient */}
<div className="h-12 rounded-t-xl bg-gradient-to-r from-primary/40 to-accent/40" />
{/* Avatar + identity */}
<div className="px-4 -mt-5">
<Avatar
src={avatar}
fallback={name}
size="lg"
className="ring-4 ring-popover"
/>
<div className="mt-2">
<p className="text-sm font-semibold text-foreground leading-tight">
{name}
</p>
<p className="text-xs text-muted-foreground">@{username}</p>
</div>
</div>
{/* Bio */}
{bio && (
<p className="mt-2 px-4 text-sm text-muted-foreground line-clamp-2">
{bio}
</p>
)}
{/* Stats */}
{(followersCount != null || tracksCount != null) && (
<div className="mt-3 px-4 flex items-center gap-4 text-xs text-muted-foreground">
{followersCount != null && (
<span className="flex items-center gap-1">
<Users className="size-3.5" />
<span className="font-medium text-foreground">
{followersCount.toLocaleString()}
</span>
followers
</span>
)}
{tracksCount != null && (
<span className="flex items-center gap-1">
<Music className="size-3.5" />
<span className="font-medium text-foreground">
{tracksCount.toLocaleString()}
</span>
tracks
</span>
)}
</div>
)}
{/* Follow button */}
{onFollow && (
<div className="mt-3 px-4 pb-4">
<Button
variant={isFollowing ? 'outline' : 'default'}
size="sm"
className="w-full"
onClick={onFollow}
>
{isFollowing ? 'Following' : 'Follow'}
</Button>
</div>
)}
{/* Bottom spacing when no follow button */}
{!onFollow && <div className="pb-4" />}
</div>
);
}

View file

@ -0,0 +1,6 @@
export { HoverCard } from './HoverCard';
export { UserHoverContent } from './UserHoverContent';
export { TrackHoverContent } from './TrackHoverContent';
export type { HoverCardProps, HoverCardPosition } from './types';
export type { UserHoverContentProps } from './UserHoverContent';
export type { TrackHoverContentProps } from './TrackHoverContent';

View file

@ -0,0 +1,22 @@
import { ReactNode } from 'react';
export type HoverCardPosition = 'top' | 'bottom' | 'left' | 'right';
export interface HoverCardProps {
/** The content to show in the hover card */
content: ReactNode;
/** The trigger element */
children: ReactNode;
/** Preferred position */
position?: HoverCardPosition;
/** Delay before showing (ms) */
openDelay?: number;
/** Delay before hiding (ms) */
closeDelay?: number;
/** Whether the card is disabled */
disabled?: boolean;
/** Custom class for the card */
className?: string;
/** Max width */
maxWidth?: number;
}

View file

@ -1,5 +1,8 @@
import React from 'react';
import React, { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { Play, Heart, MoreHorizontal } from 'lucide-react';
import { ContextMenu } from '@/components/ui/context-menu';
import type { ContextMenuEntry } from '@/components/ui/context-menu';
import type { Track } from '../../player/types';
export interface TrackListRowProps {
@ -62,8 +65,48 @@ export function TrackListRow({
const PlayIcon = isPlaying ? 'II' : '▶';
const LikeIcon = isLiked ? '♥' : '♡';
// ── Context menu items (only for callbacks that exist) ──────────────
const contextMenuItems = useMemo<ContextMenuEntry[]>(() => {
const items: ContextMenuEntry[] = [];
if (onTrackPlay) {
items.push({
id: 'play',
label: isPlaying ? 'Pause' : 'Lire',
icon: <Play className="w-4 h-4" />,
onClick: () => onTrackPlay(track),
});
}
if (onTrackLike) {
items.push({
id: 'like',
label: isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris',
icon: <Heart className={cn('w-4 h-4', isLiked && 'fill-current')} />,
onClick: () => onTrackLike(track),
});
}
if (onTrackMore) {
if (items.length > 0) {
items.push({ type: 'separator' as const });
}
items.push({
id: 'more',
label: "Plus d'options",
icon: <MoreHorizontal className="w-4 h-4" />,
onClick: () => onTrackMore(track),
});
}
return items;
}, [track, onTrackPlay, onTrackLike, onTrackMore, isPlaying, isLiked]);
const hasContextMenu = contextMenuItems.length > 0;
// ── Table format ────────────────────────────────────────────────────
if (format === 'table') {
return (
const tableRow = (
<tr
role="row"
className={cn(
@ -139,9 +182,16 @@ export function TrackListRow({
)}
</tr>
);
return hasContextMenu ? (
<ContextMenu items={contextMenuItems}>{tableRow}</ContextMenu>
) : (
tableRow
);
}
return (
// ── List format ─────────────────────────────────────────────────────
const listRow = (
<li
role="listitem"
className={cn(
@ -242,4 +292,10 @@ export function TrackListRow({
)}
</li>
);
return hasContextMenu ? (
<ContextMenu items={contextMenuItems}>{listRow}</ContextMenu>
) : (
listRow
);
}

View file

@ -449,18 +449,19 @@
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
background: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
border-radius: 9999px;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in oklch, var(--muted-foreground) 50%, transparent);
}
@supports (scrollbar-width: thin) {
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
scrollbar-color: color-mix(in oklch, var(--muted-foreground) 30%, transparent) transparent;
}
}
@ -918,6 +919,10 @@
animation: eq-bounce 0.5s ease-in-out infinite;
}
.animate-marquee {
animation: marquee 10s linear infinite;
}
/* Hover Effects */
.hover-lift {
@apply transition-transform duration-[var(--duration-fast)];
@ -1136,6 +1141,16 @@
}
}
@keyframes marquee {
0%, 20% {
transform: translateX(0);
}
80%, 100% {
transform: translateX(-100%);
}
}
@keyframes achievement-slide {
from {
transform: translateX(100%);