feat(ui): premium empty states + focus ring consistency

Empty states enhanced:
- EmptyState component gains variant prop (default/centered/card)
- Soft entry animation (fade + scale) via new CSS keyframe
- Icon wrapped in muted background circle
- Library: "Your library is empty" + "Upload Track" action
- Search: "No results found" + improved description
- Wishlist: "Explore the marketplace" + Browse button
- Queue: "Nothing in your queue" with autoplay context
- Chat: improved no-conversation and no-messages states

Focus ring consistency (6 files fixed):
- input.tsx: ring-primary/30 → ring-ring + ring-offset
- checkbox.tsx: peer-focus → peer-focus-visible + ring-ring
- textarea.tsx: focus:ring-1 → focus-visible:ring-2 + ring-ring
- List.tsx: added ring-offset-background
- TrackListRow.tsx: full focus-visible on rows + action buttons
- PlaylistCard.tsx: focus-visible on checkbox button

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-09 23:23:09 +01:00
parent b1f5fbf0bf
commit cd764d32cb
14 changed files with 186 additions and 84 deletions

View file

@ -97,11 +97,16 @@ export const WishlistView: React.FC = () => {
if (wishlist.length === 0) {
return (
<EmptyState
variant="centered"
icon={<Heart className="w-full h-full" />}
title="Your wishlist is empty"
description="Save items you want to listen to later or purchase in the future."
description="Explore the marketplace and save items you love."
action={{
label: 'Browse Marketplace',
onClick: () => (window.location.href = '/marketplace'),
}}
size="lg"
className="min-h-96 animate-fadeIn"
className="min-h-96"
/>
);
}

View file

@ -70,7 +70,7 @@ export function List({
variant === 'bordered' && 'px-4 py-4',
variant === 'spaced' && 'px-2 py-2',
variant === 'default' && 'px-2 py-2',
item.onClick && !item.disabled && 'cursor-pointer hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
item.onClick && !item.disabled && 'cursor-pointer hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
item.disabled && 'cursor-not-allowed opacity-50',
itemClassName,
)}

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useAudio } from '../../../context/AudioContext';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { EmptyState } from '../../ui/empty-state';
import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal';
import {
Play,
@ -157,17 +158,17 @@ export const QueueView: React.FC = () => {
<div className="space-y-2">
{queue.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-border rounded-xl text-muted-foreground">
<ListMusic className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">
Queue is empty. Add tracks to keep the vibe going.
</p>
{autoplay && (
<p className="text-xs text-success mt-2">
Autoplay is on. We'll pick a song for you.
</p>
)}
</div>
<EmptyState
variant="card"
icon={<ListMusic className="w-full h-full" />}
title="Nothing in your queue"
description={
autoplay
? 'Autoplay is on — we\u2019ll pick something for you.'
: 'Start playing music and add tracks to build your queue.'
}
size="md"
/>
) : (
queue.map((track, i) => (
<div

View file

@ -108,7 +108,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
className="
w-5 h-5 rounded border border-border bg-kodo-graphite
peer-checked:bg-primary peer-checked:border-border
peer-focus:ring-2 peer-focus:ring-kodo-steel/30
peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background
transition-all duration-[var(--duration-fast)]
"
></div>

View file

@ -83,6 +83,17 @@ export interface EmptyStateProps {
* @default 'md'
*/
size?: 'sm' | 'md' | 'lg';
/**
* Variante d'affichage de l'état vide
*
* - `default`: Affiché dans un Card (comportement actuel)
* - `centered`: Centré verticalement et horizontalement dans le parent
* - `card`: Affiché dans un Card avec bordure en pointillé
*
* @default 'default'
*/
variant?: 'default' | 'centered' | 'card';
}
/**
@ -90,6 +101,7 @@ export interface EmptyStateProps {
*
* Composant pour afficher un état vide lorsqu'une liste ou une section est vide.
* Design Kodo intégré avec support pour icône, titre, description et action.
* Inclut une animation d'entrée subtile (fade-in + scale) pour un rendu poli.
*
* FE-COMP-003: Add empty states to all list views
*
@ -104,8 +116,9 @@ export interface EmptyStateProps {
*
* @example
* ```tsx
* // État vide avec icône et action
* // État vide centré avec icône et action
* <EmptyState
* variant="centered"
* icon={<Inbox />}
* title="Aucun message"
* description="Vous n'avez pas encore de messages"
@ -128,6 +141,7 @@ export function EmptyState({
action,
className,
size = 'md',
variant = 'default',
}: EmptyStateProps) {
const sizeClasses = {
sm: 'py-6',
@ -141,33 +155,82 @@ export function EmptyState({
lg: 'h-16 w-16',
};
return (
<Card className={cn(className)}>
<CardContent className={cn('text-center', sizeClasses[size])}>
{icon && (
<div className="flex justify-center mb-4">
<div className={cn('text-muted-foreground', iconSizeClasses[size])}>
const iconBgSizeClasses = {
sm: 'p-3',
md: 'p-4',
lg: 'p-5',
};
const content = (
<div className="flex flex-col items-center animate-empty-state-in">
{icon && (
<div className="flex justify-center mb-4">
<div
className={cn(
'bg-muted rounded-full flex items-center justify-center',
iconBgSizeClasses[size],
)}
>
<div
className={cn('text-muted-foreground', iconSizeClasses[size])}
>
{icon}
</div>
</div>
</div>
)}
<h3 className="text-lg font-semibold mb-2 text-foreground font-display">
{title}
</h3>
{description && (
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto text-center">
{description}
</p>
)}
{action && (
<Button
onClick={action.onClick}
variant={action.variant || 'default'}
size={size === 'sm' ? 'sm' : 'default'}
>
{action.label}
</Button>
)}
</div>
);
if (variant === 'centered') {
return (
<div
className={cn(
'flex flex-1 items-center justify-center text-center',
sizeClasses[size],
className,
)}
<h3 className="text-lg font-semibold mb-2 text-foreground font-display">
{title}
</h3>
{description && (
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto">
{description}
</p>
)}
{action && (
<Button
onClick={action.onClick}
variant={action.variant || 'default'}
size={size === 'sm' ? 'sm' : 'default'}
>
{action.label}
</Button>
>
{content}
</div>
);
}
if (variant === 'card') {
return (
<div
className={cn(
'border-2 border-dashed border-border rounded-xl text-center',
sizeClasses[size],
className,
)}
>
{content}
</div>
);
}
return (
<Card className={cn(className)}>
<CardContent className={cn('text-center', sizeClasses[size])}>
{content}
</CardContent>
</Card>
);

View file

@ -33,7 +33,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={cn(
"flex h-11 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-white placeholder:text-muted-foreground/50 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
"backdrop-blur-sm transition-all duration-[var(--duration-fast)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"hover:bg-white/5 hover:border-white/20",
icon && "pl-10",
error && "border-destructive focus-visible:ring-destructive/30",

View file

@ -87,7 +87,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
'text-white placeholder-gray-500',
'font-body text-base',
'rounded-lg',
'focus:outline-none focus:border-border focus:ring-1 focus:ring-kodo-steel',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'transition-all duration-[var(--duration-fast)]',
'min-h-[100px] resize-y',
className,

View file

@ -7,7 +7,7 @@ import { TypingIndicator } from './TypingIndicator';
import {
Search, X, Disc,
Clock,
MessageSquare, Wifi
MessageSquare
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@ -61,13 +61,18 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
if (!conversationId) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground opacity-50 space-y-4">
<div className="w-24 h-24 rounded-full bg-white/5 flex items-center justify-center animate-pulse">
<Wifi className="w-10 h-10 text-kodo-steel opacity-50" />
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground space-y-4 animate-empty-state-in">
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center">
<MessageSquare className="w-10 h-10 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-foreground mb-1">
No conversation selected
</p>
<p className="text-xs text-muted-foreground">
Pick a channel from the sidebar to start chatting.
</p>
</div>
<p className="text-sm font-mono uppercase tracking-widest">
Awaiting Frequency Selection
</p>
</div>
);
}
@ -118,14 +123,14 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 scroll-smooth">
{/* Welcome Message for Empty Room */}
{currentMessages.length === 0 && (
<div className="flex flex-col items-center justify-center h-[50vh] text-center space-y-4 opacity-60">
<div className="w-12 h-12 rounded-xl bg-muted/10 flex items-center justify-center border border-border/20">
<MessageSquare className="w-6 h-6 text-kodo-steel" />
<div className="flex flex-col items-center justify-center h-[50vh] text-center space-y-4 animate-empty-state-in">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<MessageSquare className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<p className="text-white font-medium">Channel Established</p>
<p className="text-foreground font-medium">No messages yet</p>
<p className="text-sm text-muted-foreground mt-1">
Begin transmission on this frequency.
Send the first message to start the conversation.
</p>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { MessageSquarePlus } from 'lucide-react';
export interface ChatSidebarEmptyProps {
className?: string;
@ -8,13 +9,19 @@ export function ChatSidebarEmpty({ className }: ChatSidebarEmptyProps) {
return (
<div
className={cn(
'text-muted-foreground/70 text-sm p-4 text-center italic border border-dashed border-border rounded-xl m-2',
'flex flex-col items-center gap-3 text-sm p-6 text-center border border-dashed border-border rounded-xl m-2 animate-empty-state-in',
className,
)}
>
No active frequencies detected.
<br />
Initialize a new channel.
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<MessageSquarePlus className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<p className="text-foreground font-medium text-sm">No conversations yet</p>
<p className="text-muted-foreground text-xs mt-1">
Start a new conversation to get going.
</p>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { FileAudio } from 'lucide-react';
interface LibraryPageEmptyProps {
@ -7,21 +7,17 @@ interface LibraryPageEmptyProps {
export function LibraryPageEmpty({ onUploadClick }: LibraryPageEmptyProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center min-h-layout-page-sm animate-fadeIn">
<div className="w-24 h-24 bg-muted/10 rounded-full flex items-center justify-center mb-6 border border-border transition-colors duration-[var(--duration-normal)] hover:border-primary/20 hover:bg-muted/20">
<FileAudio className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-display font-bold text-foreground mb-2">It&apos;s empty here</h3>
<p className="text-muted-foreground max-w-sm mx-auto mb-8">
Start by uploading your first audio files to populate your library.
</p>
<Button
variant="outline"
onClick={onUploadClick}
className="border-primary/50 text-primary hover:bg-primary/10 transition-colors duration-[var(--duration-fast)]"
>
Upload File
</Button>
</div>
<EmptyState
variant="centered"
icon={<FileAudio className="w-full h-full" />}
title="Your library is empty"
description="Upload your first track or create a playlist to get started."
action={{
label: 'Upload Track',
onClick: onUploadClick,
}}
size="lg"
className="min-h-layout-page-sm"
/>
);
}

View file

@ -97,6 +97,7 @@ function PlaylistCardComponent({
className={cn(
'w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
'touch-manipulation min-h-6 min-w-6',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
selected
? 'bg-primary border-primary text-white'
: 'bg-white/90 dark:bg-kodo-graphite/90 border-border dark:border-border text-transparent hover:border-border/50',

View file

@ -1,11 +1,15 @@
import { EmptyState } from '@/components/ui/empty-state';
import { SearchX } from 'lucide-react';
export function SearchPageEmpty() {
return (
<div className="text-center py-20">
<div className="text-6xl mb-4" aria-hidden>
🔭
</div>
<h2 className="text-2xl font-bold mb-2">No signals found</h2>
<p className="text-muted-foreground">Try adjusting your search frequency.</p>
</div>
<EmptyState
variant="centered"
icon={<SearchX className="w-full h-full" />}
title="No results found"
description="Try adjusting your search or use different keywords."
size="lg"
className="py-20"
/>
);
}

View file

@ -111,9 +111,11 @@ export function TrackListRow({
role="row"
className={cn(
'hover:bg-muted/50 transition-colors duration-[var(--duration-normal)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
isSelected && 'bg-primary/10',
className,
)}
tabIndex={onTrackClick ? 0 : undefined}
onClick={() => onTrackClick?.(track)}
>
{showSelection && (
@ -159,7 +161,7 @@ export function TrackListRow({
<button
onClick={handlePlay}
aria-label={isPlaying ? 'Mettre en pause' : 'Lire'}
className="text-white"
className="text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm"
>
{PlayIcon}
</button>
@ -197,9 +199,11 @@ export function TrackListRow({
className={cn(
'flex items-center gap-4 p-2 rounded-[var(--radius-md)] hover:bg-muted/50 group h-14',
'transition-colors duration-[var(--duration-normal)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
isSelected && 'bg-primary/10',
className,
)}
tabIndex={onTrackClick ? 0 : undefined}
onClick={() => onTrackClick?.(track)}
>
{showSelection && (
@ -247,7 +251,7 @@ export function TrackListRow({
<button
aria-label={isPlaying ? 'Mettre en pause' : 'Lire'}
onClick={handlePlay}
className="text-white"
className="text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm"
>
{PlayIcon}
</button>
@ -277,14 +281,14 @@ export function TrackListRow({
<button
aria-label={isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris'}
onClick={handleLike}
className="hover:text-primary transition-colors duration-[var(--duration-normal)]"
className="hover:text-primary transition-colors duration-[var(--duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm"
>
{LikeIcon}
</button>
<button
aria-label="Plus d'options"
onClick={handleMore}
className="hover:text-primary transition-colors duration-[var(--duration-normal)]"
className="hover:text-primary transition-colors duration-[var(--duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm"
>
...
</button>

View file

@ -907,6 +907,10 @@
animation: fade-in var(--duration-normal) var(--ease-out);
}
.animate-empty-state-in {
animation: empty-state-in var(--duration-normal) var(--ease-out) both;
}
.animate-spin-slow {
animation: spin-slow 10s linear infinite;
}
@ -1067,6 +1071,18 @@
}
}
@keyframes empty-state-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;