Comprehensive Web Interface Guidelines audit: accessibility, motion, and typography

- Add global MotionConfig reducedMotion="user" in root layout
- Add color-scheme: dark, theme-color meta, skip-to-main-content link
- Add aria-hidden={true} to ~60+ decorative icons across 33 files
- Add aria-label to all icon-only buttons (logout, star rating, play, delete, etc.)
- Replace transition-all with specific properties (11 files)
- Add motion-safe: prefix for CSS hover transforms and animate-pulse
- Add prefers-reduced-motion: reduce override in globals.css
- Add useReducedMotion guard to status-dot pulsing animation
- Fix focus-visible styles on auth form inputs (focus → focus-visible, stronger ring)
- Add text-balance to all headings (13 files)
- Replace "..." with "…" (U+2026) in all loading/placeholder text
- Add autocomplete, spellCheck, role="alert" to auth form
- Add aria-label to Switch controls and filmography select
- Fix heading hierarchy (h3 → h2 where h2 was skipped)
- Add break-words to title overview, overscroll-contain to drawer
- Add confirmation dialog for destructive library removal
- Add group-focus-within:opacity-100 for keyboard-accessible backup actions
- Add role="radio" + aria-checked to star rating buttons
- Add aria-label to carousel region, role="img" to TMDB logo SVG
- Add text-foreground to native select for dark mode consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 18:20:25 -05:00
co-authored by Claude Opus 4.6
parent 999b690796
commit fc982bee1b
49 changed files with 342 additions and 143 deletions
+2 -2
View File
@@ -22,10 +22,10 @@ export default async function RegisterPage() {
<div className="absolute -inset-4 rounded-2xl bg-primary/3 blur-2xl" />
<div className="relative space-y-6 rounded-xl border border-border/50 bg-card/80 p-8 text-center backdrop-blur-sm">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<IconLock className="size-6 text-primary" />
<IconLock aria-hidden={true} className="size-6 text-primary" />
</div>
<div className="space-y-2">
<h1 className="font-display text-xl tracking-tight">
<h1 className="font-display text-xl tracking-tight text-balance">
Registration Closed
</h1>
<p className="text-sm text-muted-foreground">
+16 -10
View File
@@ -24,7 +24,7 @@ const steps = [
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
>
themoviedb.org
<IconExternalLink className="size-3.5" />
<IconExternalLink aria-hidden={true} className="size-3.5" />
</a>{" "}
and sign up for a free account.
</>
@@ -43,7 +43,7 @@ const steps = [
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
>
Settings &rarr; API
<IconExternalLink className="size-3.5" />
<IconExternalLink aria-hidden={true} className="size-3.5" />
</a>{" "}
and request an API key. Choose &ldquo;Developer&rdquo; when asked. You
need the{" "}
@@ -133,10 +133,10 @@ export default function SetupPage() {
{/* Header */}
<motion.div variants={sectionVariants} className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary">
<IconKey className="size-3.5" />
<IconKey aria-hidden={true} className="size-3.5" />
Setup required
</div>
<h1 className="font-display text-3xl tracking-tight sm:text-4xl">
<h1 className="font-display text-3xl tracking-tight text-balance sm:text-4xl">
Connect to TMDB
</h1>
<p className="max-w-lg text-muted-foreground leading-relaxed">
@@ -164,7 +164,7 @@ export default function SetupPage() {
</span>
</div>
<div className="space-y-1">
<h3 className="font-medium">{step.title}</h3>
<h2 className="font-medium">{step.title}</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
{step.description}
</p>
@@ -188,12 +188,15 @@ export default function SetupPage() {
>
{copiedIdx === idx ? (
<>
<IconCheck className="size-3 text-green-400" />
<IconCheck
aria-hidden={true}
className="size-3 text-green-400"
/>
Copied
</>
) : (
<>
<IconCopy className="size-3" />
<IconCopy aria-hidden={true} className="size-3" />
Copy
</>
)}
@@ -217,14 +220,17 @@ export default function SetupPage() {
{configured === true ? (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
<IconCheck className="size-5 text-green-400" />
<IconCheck
aria-hidden={true}
className="size-5 text-green-400"
/>
</div>
<div>
<p className="font-medium text-green-400">
TMDB API key detected
</p>
<p className="text-sm text-muted-foreground">
Redirecting you to Sofa...
Redirecting you to Sofa
</p>
</div>
</div>
@@ -247,7 +253,7 @@ export default function SetupPage() {
{checking ? (
<>
<span className="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground" />
Checking...
Checking
</>
) : (
"Check configuration"
@@ -44,18 +44,21 @@ export function ContinueWatchingCard({
alt={item.nextEpisode?.name ?? item.title.title}
width={500}
height={281}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
className="h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
<IconPlayerPlay className="size-8 text-muted-foreground/30" />
<IconPlayerPlay
aria-hidden={true}
className="size-8 text-muted-foreground/30"
/>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
{item.nextEpisode && (
<div className="absolute bottom-2.5 left-3 right-3">
<p className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-primary">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />
<span className="inline-block h-1.5 w-1.5 motion-safe:animate-pulse rounded-full bg-primary" />
Up next
</p>
<p className="mt-0.5 truncate text-sm font-medium text-white">
@@ -76,13 +79,13 @@ export function ContinueWatchingCard({
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<IconPlayerPlay className="size-3.5" />
<IconPlayerPlay aria-hidden={true} className="size-3.5" />
</div>
</div>
{progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted">
<div
className="h-full bg-primary transition-all"
className="h-full bg-primary transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
@@ -14,8 +14,10 @@ export function FeedSection({
return (
<section className="space-y-4">
<div className="flex items-center gap-2">
{icon}
<h2 className="font-display text-xl tracking-tight">{title}</h2>
<span aria-hidden={true}>{icon}</span>
<h2 className="font-display text-xl tracking-tight text-balance">
{title}
</h2>
</div>
{children}
</section>
@@ -67,7 +67,7 @@ function StatCard({
<div
className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}
>
<Icon className={`size-[13px] ${color}`} />
<Icon aria-hidden={true} className={`size-[13px] ${color}`} />
</div>
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
@@ -75,7 +75,7 @@ function StatCard({
</div>
<p
suppressHydrationWarning
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity duration-300`}
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} motion-safe:transition-opacity motion-safe:duration-300`}
>
{value}
</p>
@@ -98,7 +98,7 @@ function PeriodSelector({
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-0.5 border-b border-dotted border-muted-foreground/50 uppercase text-foreground/80 transition-colors hover:text-foreground">
{periodLabels[period]}
<IconChevronDown className="size-2.5" />
<IconChevronDown aria-hidden={true} className="size-2.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
@@ -18,7 +18,7 @@ export async function StatsSection({ userId }: { userId: string }) {
{isEmpty && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
<div className="rounded-full bg-primary/10 p-4">
<IconDeviceTv className="size-8 text-primary" />
<IconDeviceTv aria-hidden={true} className="size-8 text-primary" />
</div>
<div className="space-y-1">
<p className="font-medium">Your library is empty</p>
@@ -28,7 +28,7 @@ export async function StatsSection({ userId }: { userId: string }) {
</div>
<Link
href="/explore"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-shadow hover:shadow-md hover:shadow-primary/20"
>
Start exploring
</Link>
@@ -1,7 +1,7 @@
export function WelcomeHeader({ name }: { name?: string | null }) {
return (
<div>
<h1 className="font-display text-3xl tracking-tight">
<h1 className="font-display text-3xl tracking-tight text-balance">
Welcome back{name ? `, ${name}` : ""}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
+4 -2
View File
@@ -106,7 +106,9 @@ function FilterableTitleRowInner({
<section className="space-y-4">
<div className="flex items-center gap-2">
{icon}
<h2 className="font-display text-xl tracking-tight">{heading}</h2>
<h2 className="font-display text-xl tracking-tight text-balance">
{heading}
</h2>
</div>
{/* Genre chips */}
@@ -116,7 +118,7 @@ function FilterableTitleRowInner({
key={genre.id}
type="button"
onClick={() => toggleGenre(genre.id)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-all ${
className={`shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
selectedGenre === genre.id
? "border-primary bg-primary/10 text-primary"
: "border-border/50 bg-card/50 text-muted-foreground hover:border-primary/20 hover:text-foreground"
+7 -4
View File
@@ -56,7 +56,10 @@ export function HeroBanner({
</span>
{voteAverage > 0 && (
<span className="flex items-center gap-1 text-sm text-primary">
<IconStar className="size-3.5 fill-primary" />
<IconStar
aria-hidden={true}
className="size-3.5 fill-primary"
/>
{voteAverage.toFixed(1)}
</span>
)}
@@ -65,7 +68,7 @@ export function HeroBanner({
</span>
</div>
<Link href={href} className="group/title">
<h2 className="font-display text-3xl tracking-tight sm:text-4xl transition-colors group-hover/title:text-primary">
<h2 className="font-display text-3xl tracking-tight text-balance sm:text-4xl transition-colors group-hover/title:text-primary">
{title}
</h2>
</Link>
@@ -74,9 +77,9 @@ export function HeroBanner({
</p>
<Link
href={href}
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-shadow hover:shadow-md hover:shadow-primary/20"
>
<IconPlus className="size-4" />
<IconPlus aria-hidden={true} className="size-4" />
Add to Library
</Link>
</div>
+5 -3
View File
@@ -91,7 +91,7 @@ export default async function ExplorePage() {
<TitleRow
heading="Trending Today"
icon={<IconFlame className="size-5 text-primary" />}
icon={<IconFlame aria-hidden={true} className="size-5 text-primary" />}
items={trendingItems.slice(0, 20)}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
@@ -99,7 +99,7 @@ export default async function ExplorePage() {
<FilterableTitleRow
heading="Popular Movies"
icon={<IconMovie className="size-5 text-primary" />}
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
mediaType="movie"
defaultItems={popularMovieItems.slice(0, 20)}
genres={movieGenres.genres}
@@ -109,7 +109,9 @@ export default async function ExplorePage() {
<FilterableTitleRow
heading="Popular TV Shows"
icon={<IconDeviceTv className="size-5 text-primary" />}
icon={
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
}
mediaType="tv"
defaultItems={popularTvItems.slice(0, 20)}
genres={tvGenres.genres}
+3 -1
View File
@@ -38,7 +38,9 @@ export function TitleRow({
<section className="space-y-4">
<div className="flex items-center gap-2">
{icon}
<h2 className="font-display text-xl tracking-tight">{heading}</h2>
<h2 className="font-display text-xl tracking-tight text-balance">
{heading}
</h2>
</div>
<Carousel
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
+4 -1
View File
@@ -22,7 +22,10 @@ export default async function PagesLayout({
<NavBar />
{/* Ambient glow */}
<div className="pointer-events-none fixed left-1/2 top-1/4 -translate-x-1/2 -translate-y-1/2 h-[600px] w-[800px] rounded-full bg-primary/3 blur-[200px]" />
<main className="relative mx-auto max-w-6xl px-4 py-6 sm:px-6">
<main
id="main-content"
className="relative mx-auto max-w-6xl px-4 py-6 sm:px-6"
>
{children}
</main>
</div>
@@ -53,8 +53,10 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconMovie className="size-5 text-primary" />
<h2 className="font-display text-xl tracking-tight">Filmography</h2>
<IconMovie aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-xl tracking-tight text-balance">
Filmography
</h2>
<span className="text-sm text-muted-foreground">
({filtered.length})
</span>
@@ -63,6 +65,7 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
<select
value={sort}
onChange={(e) => setSort(e.target.value as Sort)}
aria-label="Sort filmography"
className="rounded-lg border border-border/50 bg-card px-2 py-1 text-xs text-foreground"
>
<option value="newest">Newest</option>
@@ -110,7 +113,7 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground/30">
<IconMovie className="size-10" />
<IconMovie aria-hidden={true} className="size-10" />
</div>
)}
</div>
@@ -48,7 +48,7 @@ export function PersonHero({ person }: PersonHeroProps) {
</div>
<div className="min-w-0 flex-1 space-y-3">
<h1 className="font-display text-3xl tracking-tight sm:text-5xl">
<h1 className="font-display text-3xl tracking-tight text-balance sm:text-5xl">
{person.name}
</h1>
@@ -61,7 +61,7 @@ export function PersonHero({ person }: PersonHeroProps) {
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
{person.birthday && (
<span className="flex items-center gap-1.5">
<IconCalendar className="size-3.5" />
<IconCalendar aria-hidden={true} className="size-3.5" />
{person.birthday}
{age !== null && (
<span className="text-muted-foreground/60">
@@ -72,7 +72,7 @@ export function PersonHero({ person }: PersonHeroProps) {
)}
{person.placeOfBirth && (
<span className="flex items-center gap-1.5">
<IconMapPin className="size-3.5" />
<IconMapPin aria-hidden={true} className="size-3.5" />
{person.placeOfBirth}
</span>
)}
+4 -2
View File
@@ -3,14 +3,16 @@ import Link from "next/link";
export default function PersonNotFound() {
return (
<div className="flex flex-col items-center gap-4 py-24 text-center">
<h1 className="font-display text-4xl tracking-tight">Person not found</h1>
<h1 className="font-display text-4xl tracking-tight text-balance">
Person not found
</h1>
<p className="text-muted-foreground">
The person you&apos;re looking for doesn&apos;t exist or may have been
removed.
</p>
<Link
href="/explore"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-shadow hover:shadow-md hover:shadow-primary/20"
>
Explore titles
</Link>
@@ -27,7 +27,7 @@ export function AccountSection({
return (
<div>
<div className="mb-3 flex items-center gap-2">
<IconUser className="size-4 text-muted-foreground" />
<IconUser aria-hidden={true} className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Account
</h2>
@@ -59,7 +59,7 @@ export function AccountSection({
router.refresh();
}}
>
<IconLogout />
<IconLogout aria-hidden={true} />
Sign out
</Button>
</CardContent>
@@ -52,7 +52,10 @@ export function BackupRestoreSection() {
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCloudUpload className="size-4 text-primary" />
<IconCloudUpload
aria-hidden={true}
className="size-4 text-primary"
/>
</div>
<div>
<CardTitle>Restore</CardTitle>
@@ -115,8 +118,8 @@ export function BackupRestoreSection() {
onClick={() => fileInputRef.current?.click()}
disabled={restoring}
>
{restoring ? <Spinner /> : <IconCloudUpload />}
{restoring ? "Restoring..." : "Upload"}
{restoring ? <Spinner /> : <IconCloudUpload aria-hidden={true} />}
{restoring ? "Restoring\u2026" : "Upload"}
</Button>
</div>
</CardContent>
@@ -147,7 +147,10 @@ function BackupScheduleInner() {
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCalendarWeek className="size-4 text-primary" />
<IconCalendarWeek
aria-hidden={true}
className="size-4 text-primary"
/>
</div>
<div>
<CardTitle>Backup schedule</CardTitle>
@@ -163,7 +166,10 @@ function BackupScheduleInner() {
{maxRetention === 0
? "unlimited"
: `last ${maxRetention}`}
<IconChevronDown className="size-2.5" />
<IconChevronDown
aria-hidden={true}
className="size-2.5"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
@@ -190,6 +196,7 @@ function BackupScheduleInner() {
checked={enabled}
onCheckedChange={toggleScheduled}
disabled={togglingSchedule}
aria-label="Toggle scheduled backups"
/>
</div>
</CardContent>
@@ -246,7 +253,10 @@ function BackupScheduleInner() {
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 disabled:opacity-50">
{DAYS_OF_WEEK[dow]}
<IconChevronDown className="size-3 text-muted-foreground" />
<IconChevronDown
aria-hidden={true}
className="size-3 text-muted-foreground"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
@@ -297,7 +307,10 @@ function BackupScheduleInner() {
),
"h:mm a",
)}
<IconChevronDown className="size-3 text-muted-foreground" />
<IconChevronDown
aria-hidden={true}
className="size-3 text-muted-foreground"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
@@ -99,7 +99,10 @@ export function BackupSection({
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconDatabaseExport className="size-4 text-primary" />
<IconDatabaseExport
aria-hidden={true}
className="size-4 text-primary"
/>
</div>
<div>
<CardTitle>Database backups</CardTitle>
@@ -111,8 +114,12 @@ export function BackupSection({
</div>
</div>
<Button onClick={handleCreateBackup} disabled={creating}>
{creating ? <Spinner className="size-3" /> : <IconPlus />}
{creating ? "Creating..." : "New backup"}
{creating ? (
<Spinner className="size-3" />
) : (
<IconPlus aria-hidden={true} />
)}
{creating ? "Creating\u2026" : "New backup"}
</Button>
</div>
</CardContent>
@@ -139,11 +146,17 @@ export function BackupSection({
}
>
{backup.source === "scheduled" ? (
<IconClock className="size-3.5" />
<IconClock aria-hidden={true} className="size-3.5" />
) : backup.source === "pre-restore" ? (
<IconShieldCheck className="size-3.5" />
<IconShieldCheck
aria-hidden={true}
className="size-3.5"
/>
) : (
<IconPointer className="size-3.5" />
<IconPointer
aria-hidden={true}
className="size-3.5"
/>
)}
</TooltipTrigger>
<TooltipContent>
@@ -172,7 +185,7 @@ export function BackupSection({
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<Tooltip>
<TooltipTrigger
render={
@@ -204,6 +217,7 @@ export function BackupSection({
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete backup"
className="text-muted-foreground hover:text-destructive"
disabled={deleting === backup.filename}
/>
@@ -15,7 +15,10 @@ export function IntegrationsSection({
return (
<div>
<div className="mb-3 flex items-center gap-2">
<IconWebhook className="size-4 text-muted-foreground" />
<IconWebhook
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Integrations
</h2>
@@ -37,7 +37,7 @@ export function RegistrationSection({
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconDoorEnter className="size-4 text-primary" />
<IconDoorEnter aria-hidden={true} className="size-4 text-primary" />
</div>
<div>
<CardTitle>Open registration</CardTitle>
@@ -50,6 +50,7 @@ export function RegistrationSection({
checked={registrationOpen}
onCheckedChange={handleToggle}
disabled={toggling}
aria-label="Toggle open registration"
/>
</div>
</CardContent>
@@ -32,8 +32,10 @@ export function SettingsShell({
>
<motion.div variants={sectionVariants}>
<div className="flex items-center gap-2">
<IconSettings className="size-5 text-primary" />
<h1 className="font-display text-3xl tracking-tight">Settings</h1>
<IconSettings aria-hidden={true} className="size-5 text-primary" />
<h1 className="font-display text-3xl tracking-tight text-balance">
Settings
</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Manage your account and preferences
@@ -149,7 +149,10 @@ export function SystemHealthCards() {
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconActivity className="size-4 text-primary" />
<IconActivity
aria-hidden={true}
className="size-4 text-primary"
/>
</div>
<div>
<CardTitle>Health status</CardTitle>
@@ -164,6 +167,7 @@ export function SystemHealthCards() {
<Button
variant="ghost"
size="icon"
aria-label="Refresh system health"
onClick={() => refresh()}
disabled={isValidating}
/>
@@ -236,9 +240,15 @@ export function SystemHealthCards() {
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
Environment
{data.environment.dataDirWritable ? (
<IconCheck className="size-3 text-green-500" />
<IconCheck
aria-hidden={true}
className="size-3 text-green-500"
/>
) : (
<IconAlertTriangle className="size-3 text-destructive" />
<IconAlertTriangle
aria-hidden={true}
className="size-3 text-destructive"
/>
)}
</span>
<div className="space-y-1">
@@ -270,7 +280,10 @@ export function SystemHealthCards() {
<CardContent>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconDatabase className="size-4 text-primary" />
<IconDatabase
aria-hidden={true}
className="size-4 text-primary"
/>
</div>
<div>
<CardTitle>Storage</CardTitle>
@@ -386,7 +399,10 @@ function BackgroundJobsCard({
<CardContent>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCalendarCheck className="size-4 text-primary" />
<IconCalendarCheck
aria-hidden={true}
className="size-4 text-primary"
/>
</div>
<div>
<CardTitle>Background jobs</CardTitle>
@@ -535,6 +551,7 @@ function BackgroundJobsCard({
<Button
variant="ghost"
size="icon"
aria-label="Trigger job"
className="size-6"
disabled={isRunning}
onClick={() => handleTrigger(job.jobName)}
@@ -544,7 +561,10 @@ function BackgroundJobsCard({
{isRunning ? (
<Spinner className="size-3" />
) : (
<IconPlayerPlay className="size-3 text-muted-foreground/50" />
<IconPlayerPlay
aria-hidden={true}
className="size-3 text-muted-foreground/50"
/>
)}
</TooltipTrigger>
<TooltipContent>Run now</TooltipContent>
@@ -37,7 +37,10 @@ export function UpdateCheckSection({
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconWorldUpload className="size-4 text-primary" />
<IconWorldUpload
aria-hidden={true}
className="size-4 text-primary"
/>
</div>
<div>
<CardTitle>Automatic update checks</CardTitle>
@@ -50,6 +53,7 @@ export function UpdateCheckSection({
checked={enabled}
onCheckedChange={handleToggle}
disabled={toggling}
aria-label="Toggle automatic update checks"
/>
</div>
</CardContent>
@@ -108,6 +108,7 @@ export function WebhookCard({
</div>
</div>
<IconChevronDown
aria-hidden={true}
className={`size-4 text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
/>
</CollapsibleTrigger>
@@ -117,7 +118,10 @@ export function WebhookCard({
<CardContent className="space-y-3 border-t border-border/30 pt-4">
{isPlex && (
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
<IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-primary" />
<IconInfoCircle
aria-hidden={true}
className="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<p className="text-xs leading-relaxed text-foreground/80">
Requires an active{" "}
<a
@@ -127,7 +131,10 @@ export function WebhookCard({
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
<span>Plex Pass</span>
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
subscription.
</p>
@@ -136,7 +143,10 @@ export function WebhookCard({
{isEmby && (
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
<IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-primary" />
<IconInfoCircle
aria-hidden={true}
className="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<p className="text-xs leading-relaxed text-foreground/80">
Requires{" "}
<span className="font-medium text-foreground">
@@ -150,7 +160,10 @@ export function WebhookCard({
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
<span>Emby Premiere</span>
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
license.
</p>
@@ -164,7 +177,7 @@ export function WebhookCard({
size="lg"
className="w-full"
>
{connecting ? "Connecting..." : `Connect ${label}`}
{connecting ? "Connecting\u2026" : `Connect ${label}`}
</Button>
) : (
<AnimatePresence>
@@ -236,6 +249,7 @@ export function WebhookCard({
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
<IconChevronDown
aria-hidden={true}
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
/>
Setup instructions
@@ -254,7 +268,10 @@ export function WebhookCard({
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Settings &gt; Webhooks
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>
</li>
<li>
@@ -293,7 +310,10 @@ export function WebhookCard({
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Webhook plugin
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>{" "}
from Jellyfin&apos;s plugin catalog
</li>
@@ -325,7 +345,10 @@ export function WebhookCard({
</li>
</ol>
<p className="mt-2 -ml-0.5">
<IconBook2 className="inline-block size-3 translate-y-[-1px] mr-1" />
<IconBook2
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px] mr-1"
/>
Need more help?{" "}
<a
href={
@@ -340,7 +363,10 @@ export function WebhookCard({
className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline"
>
Open docs{" "}
<IconExternalLink className="inline-block size-3 translate-y-[-1px]" />
<IconExternalLink
aria-hidden={true}
className="inline-block size-3 translate-y-[-1px]"
/>
</a>
</p>
</div>
+12 -3
View File
@@ -162,7 +162,10 @@ export default async function SettingsPage() {
{/* Server health */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconServer2 className="size-4 text-muted-foreground" />
<IconServer2
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Server
</h2>
@@ -176,7 +179,10 @@ export default async function SettingsPage() {
{/* Security */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconShieldLock className="size-4 text-muted-foreground" />
<IconShieldLock
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Security
</h2>
@@ -199,7 +205,10 @@ export default async function SettingsPage() {
{/* Backups */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconDatabaseExport className="size-4 text-muted-foreground" />
<IconDatabaseExport
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Backups
</h2>
@@ -20,7 +20,7 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
return (
<section className="space-y-4">
<div className="flex items-center gap-2">
<IconUsers className="size-5 text-primary" />
<IconUsers aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-xl tracking-tight">Cast</h2>
</div>
@@ -55,11 +55,14 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
alt={member.name}
width={96}
height={96}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
className="h-full w-full object-cover motion-safe:transition-transform motion-safe:group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
<IconUser className="size-8 text-muted-foreground/50" />
<IconUser
aria-hidden={true}
className="size-8 text-muted-foreground/50"
/>
</div>
)}
</div>
@@ -12,7 +12,7 @@ export function RecommendationsGrid({
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<IconSparkles className="size-5 text-primary" />
<IconSparkles aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-2xl tracking-tight">Recommended</h2>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
@@ -25,6 +25,9 @@ export function StarRating({ value, onChange }: StarRatingProps) {
<motion.button
key={star}
type="button"
role="radio"
aria-checked={star === value}
aria-label={`Rate ${star} star${star !== 1 ? "s" : ""}`}
onClick={() => onChange(star === value ? 0 : star)}
onMouseEnter={() => setHover(star)}
className="p-0.5"
@@ -50,7 +50,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
transition={{ duration: 0.15 }}
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 text-sm font-medium text-primary ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
>
<IconPlus className="size-3.5" strokeWidth={2.5} />
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
Watchlist
</motion.button>
) : (
@@ -66,8 +66,14 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:ring-destructive/30 hover:bg-destructive/10 hover:text-destructive`}
>
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
<config.icon className="size-3.5 transition-opacity group-hover:opacity-0" />
<IconX className="size-3.5 opacity-0 text-destructive transition-opacity group-hover:opacity-100" />
<config.icon
aria-hidden={true}
className="size-3.5 transition-opacity group-hover:opacity-0"
/>
<IconX
aria-hidden={true}
className="size-3.5 opacity-0 text-destructive transition-opacity group-hover:opacity-100"
/>
</span>
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
<span className="transition-opacity group-hover:opacity-0">
@@ -30,7 +30,7 @@ export function TitleActions() {
onClick={handleWatchMovie}
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all active:scale-[0.97] hover:shadow-md hover:shadow-primary/20"
>
<IconCheck className="size-3.5" />
<IconCheck aria-hidden={true} className="size-3.5" />
Mark Watched
</button>
)}
@@ -25,7 +25,7 @@ function ProviderBadge({
}) {
return (
<Tooltip>
<TooltipTrigger className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card transition-transform hover:scale-105">
<TooltipTrigger className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card motion-safe:transition-transform motion-safe:hover:scale-105">
{logoPath ? (
<Image
src={logoPath}
@@ -62,9 +62,9 @@ export function TitleAvailability({
return (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Where to Watch
</h3>
</h2>
<div className="flex flex-wrap gap-4">
{Object.entries(availByType).map(([type, offers]) => (
<div key={type} className="space-y-1.5">
@@ -96,7 +96,7 @@ export function TitleHero({
<div className="flex-1 space-y-5">
<div>
<h1 className="font-display text-2xl tracking-tight sm:text-5xl">
<h1 className="font-display text-2xl tracking-tight text-balance sm:text-5xl">
{title.title}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
@@ -123,6 +123,7 @@ export function TitleHero({
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank"
rel="noopener noreferrer"
aria-label="View on TMDB"
className="inline-flex h-5 items-center rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<TmdbLogo className="h-2.5 w-auto" />
@@ -131,7 +132,7 @@ export function TitleHero({
</div>
{title.overview && (
<p className="max-w-2xl leading-relaxed text-muted-foreground">
<p className="max-w-2xl break-words leading-relaxed text-muted-foreground">
{title.overview}
</p>
)}
@@ -63,7 +63,7 @@ export function TitleSeasons({
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconDeviceTvOld className="size-5 text-primary" />
<IconDeviceTvOld aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-2xl tracking-tight">Seasons</h2>
</div>
{userStatus && userStatus !== "completed" && (
@@ -74,7 +74,7 @@ export function TitleSeasons({
type="button"
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<IconChecks className="size-3.5" />
<IconChecks aria-hidden={true} className="size-3.5" />
Mark All Watched
</button>
}
@@ -180,9 +180,15 @@ export function TitleSeasons({
</span>
)}
{isOpen ? (
<IconChevronUp className="size-4 text-muted-foreground" />
<IconChevronUp
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
) : (
<IconChevronDown className="size-4 text-muted-foreground" />
<IconChevronDown
aria-hidden={true}
className="size-4 text-muted-foreground"
/>
)}
</div>
</div>
@@ -210,6 +216,7 @@ export function TitleSeasons({
>
<button
type="button"
aria-label={`Mark episode ${ep.episodeNumber} as ${isWatched ? "unwatched" : "watched"}`}
onClick={() =>
handleWatchEpisode(
ep.id,
@@ -26,8 +26,9 @@ export function TrailerDialog({
{variant === "backdrop" ? (
<button
type="button"
aria-label="Play trailer"
onClick={() => setOpen(true)}
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-all duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
className="group flex size-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/20 backdrop-blur-md transition-[transform,background-color,box-shadow] duration-300 hover:scale-105 hover:bg-white/20 hover:ring-white/30 active:scale-100 sm:size-16"
>
<IconPlayerPlayFilled className="size-6 text-white drop-shadow-lg sm:size-8" />
</button>
@@ -37,7 +38,7 @@ export function TrailerDialog({
onClick={() => setOpen(true)}
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<IconPlayerPlayFilled className="h-2.5 w-2.5" />
<IconPlayerPlayFilled aria-hidden={true} className="h-2.5 w-2.5" />
Trailer
</button>
)}
+7
View File
@@ -166,3 +166,10 @@
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.animate-stagger-item,
.animate-pulse {
animation: none;
}
}
+13 -2
View File
@@ -1,3 +1,4 @@
import { MotionConfig } from "motion/react";
import type { Metadata } from "next";
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -24,6 +25,7 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Sofa",
description: "Track your movies and TV shows",
themeColor: "#090706",
};
export default function RootLayout({
@@ -32,11 +34,20 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" style={{ colorScheme: "dark" }}>
<body
className={`${dmSans.variable} ${dmSerif.variable} ${geistMono.variable} overflow-x-hidden font-sans antialiased`}
style={{ touchAction: "manipulation" }}
>
<TooltipProvider>{children}</TooltipProvider>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md"
>
Skip to main content
</a>
<MotionConfig reducedMotion="user">
<TooltipProvider>{children}</TooltipProvider>
</MotionConfig>
<Toaster position="bottom-right" />
</body>
</html>