From 227c60a9c929c78b242e6f5a212a3d588762e9bf Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Sat, 18 Apr 2026 18:36:54 +0000 Subject: [PATCH 1/5] fix: restyle Watch link to amber outline to match palette Co-Authored-By: Claude Sonnet 4.6 --- src/components/movie-row.tsx | 6 +++--- tests/movie-row.test.tsx | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/movie-row.tsx b/src/components/movie-row.tsx index 9d86399..8c95374 100644 --- a/src/components/movie-row.tsx +++ b/src/components/movie-row.tsx @@ -9,7 +9,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import type { Movie } from "@/types"; +import type { Movie, StreamingProvider } from "@/types"; import { formatRuntime } from "@/lib/utils"; const SEERR_LABEL: Record = { @@ -32,7 +32,7 @@ interface MovieRowProps { movie: Movie; position: number; seerrUrl?: string | null; - streamingProviders: { providerId: number; providerName: string }[]; + streamingProviders: StreamingProvider[]; streamingLink: string | null; onMarkWatched: (movie: Movie) => void; onForceDownload: (movieId: number) => void; @@ -136,7 +136,7 @@ export function MovieRow({ href={streamingLink} target="_blank" rel="noopener noreferrer" - className="rounded border border-stone-600 bg-stone-800 text-white px-2 py-0.5 text-xs font-medium hover:bg-stone-700 transition-colors" + className="rounded border border-amber-400 bg-white text-amber-700 px-2 py-0.5 text-xs font-medium hover:bg-amber-50 transition-colors" > Watch ↗ diff --git a/tests/movie-row.test.tsx b/tests/movie-row.test.tsx index dc3ffae..91f245e 100644 --- a/tests/movie-row.test.tsx +++ b/tests/movie-row.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { MovieRow } from '@/components/movie-row' -import type { Movie, User } from '@/types' +import type { Movie } from '@/types' vi.mock('next/image', () => ({ default: ({ src, alt }: { src: string; alt: string }) => {alt}, @@ -101,4 +101,18 @@ describe('MovieRow layout', () => { const infoSection = screen.getByText('Jeanne Dielman').closest('div') expect(infoSection).toContainElement(screen.getByText('Queued')) }) + + it('renders Watch link with amber outline instead of dark stone', () => { + render( + + ) + const watchLink = screen.getByRole('link', { name: /watch/i }) + expect(watchLink).not.toHaveClass('bg-stone-800') + expect(watchLink).toHaveClass('border-amber-400') + }) }) From 1c39dcbc5e043ba4e4dc3adaae82eeec1bfaec8d Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Sat, 18 Apr 2026 18:38:58 +0000 Subject: [PATCH 2/5] fix: move Browse Criterion/IMDB links from sidebar to Add page Trims the sidebar utility footer by removing the Browse Criterion and Browse IMDB links, relocating them as helper links directly on the Add Movie page where they are contextually useful. Co-Authored-By: Claude Sonnet 4.6 --- src/app/add/page.tsx | 21 ++++++++++++++++++++- src/components/sidebar.tsx | 16 ---------------- tests/add-page.test.tsx | 25 +++++++++++++++++++++++++ tests/sidebar.test.tsx | 30 ++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 tests/add-page.test.tsx create mode 100644 tests/sidebar.test.tsx diff --git a/src/app/add/page.tsx b/src/app/add/page.tsx index f47fb02..d155d5f 100644 --- a/src/app/add/page.tsx +++ b/src/app/add/page.tsx @@ -81,10 +81,29 @@ export default function AddMoviePage() {

{error}

)} -

+

Supports imdb.com/title/... and criterion.com/films/... URLs

+ + {preview && (
diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index c3b3938..6677b94 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -46,22 +46,6 @@ export function Sidebar() { {/* Utility links */}
- - 🎞️ Browse Criterion - - - 🎬 Browse IMDB - diff --git a/tests/add-page.test.tsx b/tests/add-page.test.tsx new file mode 100644 index 0000000..e5706fd --- /dev/null +++ b/tests/add-page.test.tsx @@ -0,0 +1,25 @@ +// tests/add-page.test.tsx +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +vi.stubGlobal('fetch', vi.fn()) + +import AddMoviePage from '@/app/add/page' + +describe('AddMoviePage', () => { + it('shows Browse Criterion and Browse IMDB helper links', () => { + render() + expect(screen.getByRole('link', { name: /browse criterion/i })).toHaveAttribute( + 'href', + 'https://www.criterion.com/shop/browse/list?q=&format=all' + ) + expect(screen.getByRole('link', { name: /browse imdb/i })).toHaveAttribute( + 'href', + 'https://www.imdb.com/search/title/?title_type=feature' + ) + }) +}) diff --git a/tests/sidebar.test.tsx b/tests/sidebar.test.tsx new file mode 100644 index 0000000..012add3 --- /dev/null +++ b/tests/sidebar.test.tsx @@ -0,0 +1,30 @@ +// tests/sidebar.test.tsx +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/watchlist', +})) + +vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), +})) + +import { Sidebar } from '@/components/sidebar' + +describe('Sidebar', () => { + it('renders primary nav links', () => { + render() + expect(screen.getByRole('link', { name: /watch list/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /watched/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /add movie/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /recommend/i })).toBeInTheDocument() + }) + + it('does not render Browse Criterion or Browse IMDB in the utility footer', () => { + render() + expect(screen.queryByText(/browse criterion/i)).not.toBeInTheDocument() + expect(screen.queryByText(/browse imdb/i)).not.toBeInTheDocument() + }) +}) From c8cf0ed3aad4dcd2a81baa0196b26a58d029747d Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Sat, 18 Apr 2026 18:40:50 +0000 Subject: [PATCH 3/5] fix: strengthen mobile bottom nav active tab indicator Replace bg-amber-100 pill with bg-amber-600 for higher-contrast active state, and add font-bold to the active tab label. Update tests accordingly. Co-Authored-By: Claude Sonnet 4.6 --- src/components/mobile-bottom-nav.tsx | 6 ++++-- tests/mobile-bottom-nav.test.tsx | 15 ++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/mobile-bottom-nav.tsx b/src/components/mobile-bottom-nav.tsx index 1e9f8c5..4e0c1aa 100644 --- a/src/components/mobile-bottom-nav.tsx +++ b/src/components/mobile-bottom-nav.tsx @@ -30,12 +30,14 @@ export function MobileBottomNav() { {icon} - {label} + + {label} + ))} diff --git a/tests/mobile-bottom-nav.test.tsx b/tests/mobile-bottom-nav.test.tsx index 4075779..f42f718 100644 --- a/tests/mobile-bottom-nav.test.tsx +++ b/tests/mobile-bottom-nav.test.tsx @@ -32,16 +32,21 @@ describe('MobileBottomNav', () => { it('highlights the active tab', () => { render() - // usePathname returns '/watchlist' — the List link should have the active colour const listLink = screen.getByRole('link', { name: /list/i }) expect(listLink).toHaveClass('text-amber-600') - // Active icon span gets the background pill const iconSpan = listLink.querySelector('span') - expect(iconSpan).toHaveClass('bg-amber-100') - // Inactive tabs should not have the active colour or pill + expect(iconSpan).toHaveClass('bg-amber-600') const watchedLink = screen.getByRole('link', { name: /watched/i }) expect(watchedLink).not.toHaveClass('text-amber-600') const inactiveIconSpan = watchedLink.querySelector('span') - expect(inactiveIconSpan).not.toHaveClass('bg-amber-100') + expect(inactiveIconSpan).not.toHaveClass('bg-amber-600') + }) + + it('applies bold font weight to the active tab label', () => { + render() + const listLink = screen.getByRole('link', { name: /list/i }) + const spans = listLink.querySelectorAll('span') + const labelSpan = spans[spans.length - 1] + expect(labelSpan).toHaveClass('font-bold') }) }) From f7a66b0ee20b296ae502da536136afbb6f19a042 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Sat, 18 Apr 2026 18:44:34 +0000 Subject: [PATCH 4/5] fix: add aria-hidden to decorative emoji icons in nav components Co-Authored-By: Claude Sonnet 4.6 --- src/components/mobile-bottom-nav.tsx | 1 + src/components/sidebar-utils.tsx | 2 +- src/components/sidebar.tsx | 6 +++--- tests/mobile-bottom-nav.test.tsx | 9 +++++++++ tests/mobile-header.test.tsx | 2 +- tests/sidebar.test.tsx | 9 +++++++++ 6 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/components/mobile-bottom-nav.tsx b/src/components/mobile-bottom-nav.tsx index 4e0c1aa..47119da 100644 --- a/src/components/mobile-bottom-nav.tsx +++ b/src/components/mobile-bottom-nav.tsx @@ -28,6 +28,7 @@ export function MobileBottomNav() { )} >
diff --git a/tests/mobile-bottom-nav.test.tsx b/tests/mobile-bottom-nav.test.tsx index f42f718..4bed0cd 100644 --- a/tests/mobile-bottom-nav.test.tsx +++ b/tests/mobile-bottom-nav.test.tsx @@ -49,4 +49,13 @@ describe('MobileBottomNav', () => { const labelSpan = spans[spans.length - 1] expect(labelSpan).toHaveClass('font-bold') }) + + it('wraps tab emoji icons with aria-hidden', () => { + render() + const links = screen.getAllByRole('link') + links.forEach((link) => { + const iconSpan = link.querySelector('span[aria-hidden="true"]') + expect(iconSpan).not.toBeNull() + }) + }) }) diff --git a/tests/mobile-header.test.tsx b/tests/mobile-header.test.tsx index a2744ed..79d87bc 100644 --- a/tests/mobile-header.test.tsx +++ b/tests/mobile-header.test.tsx @@ -28,7 +28,7 @@ describe('MobileHeader', () => { expect(screen.getByText('Browse Criterion')).toBeInTheDocument() expect(screen.getByText('Browse IMDB')).toBeInTheDocument() expect(screen.getByText('🎭 Sync Plex')).toBeInTheDocument() - expect(screen.getByText('✨ Ask Claude')).toBeInTheDocument() + expect(screen.getByText(/ask claude/i)).toBeInTheDocument() expect(screen.getByRole('link', { name: /settings/i })).toBeInTheDocument() }) }) diff --git a/tests/sidebar.test.tsx b/tests/sidebar.test.tsx index 012add3..6165594 100644 --- a/tests/sidebar.test.tsx +++ b/tests/sidebar.test.tsx @@ -27,4 +27,13 @@ describe('Sidebar', () => { expect(screen.queryByText(/browse criterion/i)).not.toBeInTheDocument() expect(screen.queryByText(/browse imdb/i)).not.toBeInTheDocument() }) + + it('wraps nav emoji icons with aria-hidden', () => { + render() + const navLinks = screen.getAllByRole('link') + navLinks.forEach((link) => { + const iconSpan = link.querySelector('span[aria-hidden="true"]') + expect(iconSpan).not.toBeNull() + }) + }) }) From d2cd7c0abaf181b83ebe6701fd6caa43564f3725 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Sat, 18 Apr 2026 18:48:08 +0000 Subject: [PATCH 5/5] fix: add aria-hidden to decorative emoji icons in sidebar utility buttons Co-Authored-By: Claude Sonnet 4.6 --- src/components/sidebar-utils.tsx | 26 ++++++++++++++------------ tests/mobile-header.test.tsx | 2 +- tests/sidebar-utils.test.tsx | 16 ++++++++-------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/components/sidebar-utils.tsx b/src/components/sidebar-utils.tsx index 16467e3..c2e3ddd 100644 --- a/src/components/sidebar-utils.tsx +++ b/src/components/sidebar-utils.tsx @@ -17,11 +17,11 @@ export function PlexSyncButton() { } } - const label = - state === 'loading' ? '⏳ Syncing…' - : state === 'ok' ? '✅ Synced!' - : state === 'error' ? '❌ Failed' - : '🎭 Sync Plex' + const content: { icon: string; text: string } = + state === 'loading' ? { icon: '⏳', text: 'Syncing…' } + : state === 'ok' ? { icon: '✅', text: 'Synced!' } + : state === 'error' ? { icon: '❌', text: 'Failed' } + : { icon: '🎭', text: 'Sync Plex' } return ( ) } @@ -54,11 +55,11 @@ export function StreamingRefreshButton() { } } - const label = - state === 'loading' ? '⏳ Refreshing…' - : state === 'ok' ? '✅ Refreshed!' - : state === 'error' ? '❌ Failed' - : '📡 Refresh Streaming' + const content: { icon: string; text: string } = + state === 'loading' ? { icon: '⏳', text: 'Refreshing…' } + : state === 'ok' ? { icon: '✅', text: 'Refreshed!' } + : state === 'error' ? { icon: '❌', text: 'Failed' } + : { icon: '📡', text: 'Refresh Streaming' } return ( ) } diff --git a/tests/mobile-header.test.tsx b/tests/mobile-header.test.tsx index 79d87bc..acd39cd 100644 --- a/tests/mobile-header.test.tsx +++ b/tests/mobile-header.test.tsx @@ -27,7 +27,7 @@ describe('MobileHeader', () => { await waitFor(() => { expect(screen.getByText('Browse Criterion')).toBeInTheDocument() expect(screen.getByText('Browse IMDB')).toBeInTheDocument() - expect(screen.getByText('🎭 Sync Plex')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sync plex/i })).toBeInTheDocument() expect(screen.getByText(/ask claude/i)).toBeInTheDocument() expect(screen.getByRole('link', { name: /settings/i })).toBeInTheDocument() }) diff --git a/tests/sidebar-utils.test.tsx b/tests/sidebar-utils.test.tsx index 82666fa..d268f66 100644 --- a/tests/sidebar-utils.test.tsx +++ b/tests/sidebar-utils.test.tsx @@ -14,7 +14,7 @@ describe('PlexSyncButton', () => { it('renders in idle state', () => { render() - expect(screen.getByText('🎭 Sync Plex')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sync plex/i })).toBeInTheDocument() }) }) @@ -23,21 +23,21 @@ describe('StreamingRefreshButton', () => { it('renders in idle state', () => { render() - expect(screen.getByText('📡 Refresh Streaming')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /refresh streaming/i })).toBeInTheDocument() }) it('posts to /api/streaming-providers/refresh on click', async () => { vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) render() - fireEvent.click(screen.getByText('📡 Refresh Streaming')) + fireEvent.click(screen.getByRole('button', { name: /refresh streaming/i })) expect(global.fetch).toHaveBeenCalledWith('/api/streaming-providers/refresh', { method: 'POST' }) }) it('shows success state after refresh completes', async () => { vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) render() - fireEvent.click(screen.getByText('📡 Refresh Streaming')) - await waitFor(() => expect(screen.getByText('✅ Refreshed!')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: /refresh streaming/i })) + await waitFor(() => expect(screen.getByText('Refreshed!')).toBeInTheDocument()) }) it('dispatches streaming-refreshed event on success', async () => { @@ -45,7 +45,7 @@ describe('StreamingRefreshButton', () => { const listener = vi.fn() window.addEventListener('streaming-refreshed', listener) render() - fireEvent.click(screen.getByText('📡 Refresh Streaming')) + fireEvent.click(screen.getByRole('button', { name: /refresh streaming/i })) await waitFor(() => expect(listener).toHaveBeenCalled()) window.removeEventListener('streaming-refreshed', listener) }) @@ -53,7 +53,7 @@ describe('StreamingRefreshButton', () => { it('shows error state when refresh fails', async () => { vi.mocked(global.fetch).mockResolvedValueOnce({ ok: false } as any) render() - fireEvent.click(screen.getByText('📡 Refresh Streaming')) - await waitFor(() => expect(screen.getByText('❌ Failed')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: /refresh streaming/i })) + await waitFor(() => expect(screen.getByText('Failed')).toBeInTheDocument()) }) })