mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user