`. The footer should then contain only: `
`, `
`, `
`, and the Settings `
`.
+
+- [ ] **Step 4: Add Browse links to add/page.tsx**
+
+In `src/app/add/page.tsx`, after the hint paragraph (`Supports imdb.com/title/... and criterion.com/films/... URLs`), add:
+
+```tsx
+
+```
+
+Also remove the `
` hint paragraph (the one that said `Supports imdb.com/...`) since the Browse links now carry that context. Or keep it if you prefer — either way the tests pass.
+
+- [ ] **Step 5: Run all tests**
+
+```bash
+npm run test:run
+```
+
+Expected: all PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/sidebar.tsx src/app/add/page.tsx \
+ tests/sidebar.test.tsx tests/add-page.test.tsx
+git commit -m "fix: move Browse Criterion/IMDB links from sidebar to Add page"
+```
+
+---
+
+### Task 9: Mobile bottom nav — stronger active state (Fix 9)
+
+**Files:**
+- Modify: `tests/mobile-bottom-nav.test.tsx`
+- Modify: `src/components/mobile-bottom-nav.tsx`
+
+- [ ] **Step 1: Update the failing active-state test**
+
+In `tests/mobile-bottom-nav.test.tsx`, update the `'highlights the active tab'` test. Change `bg-amber-100` → `bg-amber-600`:
+
+```tsx
+ it('highlights the active tab', () => {
+ render()
+ const listLink = screen.getByRole('link', { name: /list/i })
+ expect(listLink).toHaveClass('text-amber-600')
+ const iconSpan = listLink.querySelector('span')
+ 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-600')
+ })
+```
+
+Also update the label active-class check — add a `'bold label on active tab'` test:
+
+```tsx
+ it('applies bold font weight to the active tab label', () => {
+ render()
+ const listLink = screen.getByRole('link', { name: /list/i })
+ // The label is the second span child
+ const spans = listLink.querySelectorAll('span')
+ const labelSpan = spans[spans.length - 1]
+ expect(labelSpan).toHaveClass('font-bold')
+ })
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+npm run test:run -- tests/mobile-bottom-nav.test.tsx
+```
+
+Expected: `'highlights the active tab'` FAIL — icon span currently has `bg-amber-100`.
+
+- [ ] **Step 3: Update mobile-bottom-nav.tsx active styles**
+
+In `src/components/mobile-bottom-nav.tsx`, update the JSX inside the `tabs.map` to use stronger active styles:
+
+```tsx
+
+
+ {icon}
+
+
+ {label}
+
+
+```
+
+- [ ] **Step 4: Run all tests**
+
+```bash
+npm run test:run
+```
+
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/mobile-bottom-nav.tsx tests/mobile-bottom-nav.test.tsx
+git commit -m "fix: strengthen mobile bottom nav active tab indicator"
+```
+
+---
+
+### Task 10: aria-hidden on decorative emoji icons (Fix 10)
+
+**Files:**
+- Modify: `tests/sidebar.test.tsx`
+- Modify: `tests/mobile-bottom-nav.test.tsx`
+- Modify: `src/components/sidebar.tsx`
+- Modify: `src/components/mobile-bottom-nav.tsx`
+
+- [ ] **Step 1: Add aria-hidden assertions to existing tests**
+
+Append inside `describe('Sidebar')` in `tests/sidebar.test.tsx`:
+
+```tsx
+ 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"]')
+ // Every nav link should have at least one aria-hidden emoji span
+ expect(iconSpan).not.toBeNull()
+ })
+ })
+```
+
+Append inside `describe('MobileBottomNav')` in `tests/mobile-bottom-nav.test.tsx`:
+
+```tsx
+ 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()
+ })
+ })
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+npm run test:run -- tests/sidebar.test.tsx tests/mobile-bottom-nav.test.tsx
+```
+
+Expected: FAIL — emoji spans don't have `aria-hidden="true"`.
+
+- [ ] **Step 3: Add aria-hidden to sidebar.tsx**
+
+In `src/components/sidebar.tsx`, update the `navItems` map icon span and the logo emoji:
+
+```tsx
+ {/* Logo */}
+
+```
+
+And in the `navItems.map`:
+
+```tsx
+
+ {icon}
+ {label}
+
+```
+
+Also wrap the emoji in the utility footer links (`🎞️`, `🎬`, `⚙️`) with `aria-hidden="true"` where applicable — but since the Browse links are removed (Task 8), only the Settings link needs it:
+
+```tsx
+
+ ⚙️ Settings
+
+```
+
+- [ ] **Step 4: Add aria-hidden to mobile-bottom-nav.tsx**
+
+In `src/components/mobile-bottom-nav.tsx`, the icon span already exists — add `aria-hidden="true"`:
+
+```tsx
+
+ {icon}
+
+```
+
+- [ ] **Step 5: Run all tests**
+
+```bash
+npm run test:run
+```
+
+Expected: all PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/sidebar.tsx src/components/mobile-bottom-nav.tsx \
+ tests/sidebar.test.tsx tests/mobile-bottom-nav.test.tsx
+git commit -m "fix: add aria-hidden to decorative emoji icons in nav components"
+```
+
+---
+
+### Task 11: Open PR 2
+
+- [ ] **Step 1: Push and open PR**
+
+```bash
+git push -u origin
+```
+
+Open a PR targeting `main` (or PR 1's branch if stacked) with title: `fix: UI/UX review — polish and accessibility (PR 2 of 2)`
+
+Body:
+```
+Addresses the Low priority findings from the UI/UX expert review.
+
+- **Fix 6:** Watch ↗ link restyled from dark stone to amber outline — consistent with palette
+- **Fix 8:** Browse Criterion/IMDB links removed from sidebar; added to Add Movie page where contextually useful
+- **Fix 9:** Mobile bottom nav active tab uses filled amber-600 pill + bold label — unmissable at a glance
+- **Fix 10:** Decorative emoji icons wrapped in aria-hidden spans in sidebar and mobile nav
+```
+
+---
+
+## Quick Reference
+
+| Fix | Files | Task |
+|-----|-------|------|
+| 5 (pill colours) | movie-row.tsx | Task 1 |
+| 1 (row layout) | movie-row.tsx | Task 2 |
+| 2 (submit guard) | rating-dialog.tsx | Task 3 |
+| 3 (filter bar) | filter-bar.tsx, watchlist/page.tsx | Task 4 |
+| 4 (nav settings) | mobile-bottom-nav.tsx, mobile-header.tsx | Task 5 |
+| 6 (watch button) | movie-row.tsx | Task 7 |
+| 8 (sidebar) | sidebar.tsx, add/page.tsx | Task 8 |
+| 9 (active tab) | mobile-bottom-nav.tsx | Task 9 |
+| 10 (aria-hidden) | sidebar.tsx, mobile-bottom-nav.tsx | Task 10 |
+
+Run the full suite at any time: `npm run test:run`
diff --git a/docs/superpowers/specs/2026-04-18-ui-ux-review-design.md b/docs/superpowers/specs/2026-04-18-ui-ux-review-design.md
new file mode 100644
index 0000000..5bba88b
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-18-ui-ux-review-design.md
@@ -0,0 +1,136 @@
+# UI/UX Review — Design Spec
+
+**Date:** 2026-04-18
+**Source:** External UI/UX expert review of the Date Night app
+**Approach:** Two PRs — PR 1 (High/Medium priority usability fixes), PR 2 (Low priority polish + accessibility)
+**Fix 7 (max-w-2xl centering) dropped** — home-lab app on a known display; centering creates an amber island; low value.
+
+---
+
+## PR 1 — Usability Fixes
+
+### Fix 1: MovieRow Actions Column Overload
+**File:** `src/components/movie-row.tsx`
+
+Move status pills, streaming badge, provider logos, and Watch link out of the right-hand actions column and into the movie info section (below title/year). The actions column becomes a single horizontally-arranged row of buttons (Mark Watched / Download Now) plus the ✕ remove button inline at the end. `items-start` → `items-center` on the row container since heights are now uniform.
+
+### Fix 2: Rating Dialog Quote Required
+**File:** `src/components/rating-dialog.tsx`
+
+- Add `*` after the "Critic's Quote" label.
+- Disable the Submit button (`disabled` prop) until both `rating !== undefined` and `quote.trim().length > 0`. Use a computed `canSubmit` boolean.
+
+### Fix 3: Streamable Filter Integrated into FilterBar
+**Files:** `src/components/filter-bar.tsx`, `src/app/watchlist/page.tsx`
+
+Add an optional `extraPills` prop to `FilterBar`:
+```tsx
+interface ExtraPill {
+ label: string
+ active: boolean
+ onToggle: () => void
+}
+interface FilterBarProps {
+ // ...existing props...
+ extraPills?: ExtraPill[]
+}
+```
+Render `extraPills` inline after the status pill buttons in the same `flex-wrap` row. In `watchlist/page.tsx`, remove the standalone streamable toggle `` and pass it as an `extraPill` instead. The `Play` icon import can be kept on the pill label string or passed through the label.
+
+### Fix 4: Navigation Inconsistency — Settings Demoted on Mobile
+**Files:** `src/components/mobile-bottom-nav.tsx`, `src/components/mobile-header.tsx`
+
+- Remove Settings from the `tabs` array in `mobile-bottom-nav.tsx` (4 primary tabs remain: List, Watched, Add, Recs).
+- Add a Settings link to the existing bottom sheet in `mobile-header.tsx` (the `⋯` more menu already contains Browse Criterion, Browse IMDB, Plex Sync, Ask Claude — add Settings after Ask Claude). No header layout changes needed.
+
+### Fix 5: Status Pill Color Hierarchy
+**File:** `src/components/movie-row.tsx`
+
+Replace the binary `seerrPillClass` with a full map:
+
+```ts
+const SEERR_PILL_CLASS: Record
= {
+ not_requested: 'bg-stone-100 text-stone-500 border-stone-200',
+ pending: 'bg-indigo-50 text-indigo-600 border-indigo-200',
+ processing: 'bg-amber-50 text-amber-600 border-amber-200',
+ available: 'bg-green-50 text-green-700 border-green-200',
+ deleted: 'bg-stone-100 text-stone-500 border-stone-200',
+}
+```
+
+---
+
+## PR 2 — Polish & Accessibility
+
+### Fix 6: Watch ↗ Button Palette
+**File:** `src/components/movie-row.tsx`
+
+Change the Watch link from `bg-stone-800 text-white border-stone-600` to `bg-white text-amber-700 border-amber-400 hover:bg-amber-50`. Keeps it clearly a link/action while fitting the warm palette.
+
+### Fix 8: Sidebar Utility Section Trimmed
+**Files:** `src/components/sidebar.tsx`, `src/app/add/page.tsx`
+
+- Remove the Browse Criterion and Browse IMDB `` links from the sidebar utility footer. Sidebar footer becomes: Plex Sync, Streaming Refresh, Ask Claude, Settings — 4 items.
+- Add Browse Criterion and Browse IMDB as helper links in `src/app/add/page.tsx`, below the URL input hint text (`Supports imdb.com/title/... and criterion.com/films/... URLs`). Render them as small amber text links.
+- Note: Browse links are already present in the mobile header's `⋯` more sheet (`mobile-header.tsx`) — leave those as-is. This fix only affects the desktop sidebar and Add page.
+
+### Fix 9: Mobile Bottom Nav Active State
+**File:** `src/components/mobile-bottom-nav.tsx`
+
+Strengthen the active indicator:
+- Icon pill: `bg-amber-600` (filled) instead of `bg-amber-100` (pale)
+- Label text: `font-bold text-amber-600` instead of `font-medium text-amber-600`
+
+```tsx
+
+ {icon}
+
+
+ {label}
+
+```
+
+### Fix 10: Emoji Icons aria-hidden
+**Files:** `src/components/sidebar.tsx`, `src/components/mobile-bottom-nav.tsx`
+
+Wrap every decorative emoji in ``. The text label that follows provides the accessible name. Apply to the `navItems` map in sidebar, the logo emoji in the sidebar header, and the `tabs` map in mobile-bottom-nav.
+
+```tsx
+// Before
+{icon}
+{label}
+
+// After
+{icon}
+{label}
+```
+
+Note: `mobile-header.tsx` already uses `aria-hidden="true"` on its emojis — no changes needed there.
+
+---
+
+## Files Touched
+
+| File | Fixes |
+|---|---|
+| `src/components/movie-row.tsx` | 1, 5, 6 |
+| `src/components/rating-dialog.tsx` | 2 |
+| `src/components/filter-bar.tsx` | 3 |
+| `src/app/watchlist/page.tsx` | 3 |
+| `src/components/mobile-bottom-nav.tsx` | 4, 9, 10 |
+| `src/components/sidebar.tsx` | 8, 10 |
+| `src/app/add/page.tsx` | 8 |
+| `src/components/mobile-header.tsx` | 4 |
+
+---
+
+## Out of Scope
+
+- Fix 7 (max-w-2xl centering): dropped — home-lab app on known display, centering creates floating-island effect with amber on both sides; low value.
+- No new routes, no data model changes, no API changes.
+- All changes are purely presentational / accessibility.
diff --git a/src/app/watchlist/page.tsx b/src/app/watchlist/page.tsx
index bbd2e56..cdf416e 100644
--- a/src/app/watchlist/page.tsx
+++ b/src/app/watchlist/page.tsx
@@ -1,7 +1,6 @@
// src/app/watchlist/page.tsx
'use client'
import { useState, useEffect, useCallback } from 'react'
-import { Play } from 'lucide-react'
import { MovieRow } from '@/components/movie-row'
import { RatingDialog } from '@/components/rating-dialog'
import { FilterBar } from '@/components/filter-bar'
@@ -143,24 +142,13 @@ export default function WatchlistPage() {
buttons={STATUS_BUTTONS}
activeButton={activeFilter}
onButtonChange={setActiveFilter}
+ extraPills={
+ streamingServiceIds.length > 0
+ ? [{ label: '▶ Streamable', active: streamableOnly, onToggle: () => setStreamableOnly((v) => !v) }]
+ : undefined
+ }
/>
- {streamingServiceIds.length > 0 && (
-
-
-
- )}
-
{filteredMovies.map((movie, index) => {
const matchingProviders = getMatchingProviders(movie, streamingServiceIds)
diff --git a/src/components/filter-bar.tsx b/src/components/filter-bar.tsx
index 1be08af..3d977b5 100644
--- a/src/components/filter-bar.tsx
+++ b/src/components/filter-bar.tsx
@@ -6,12 +6,19 @@ interface FilterButton {
value: string
}
+interface ExtraPill {
+ label: string
+ active: boolean
+ onToggle: () => void
+}
+
interface FilterBarProps {
search: string
onSearchChange: (value: string) => void
buttons: FilterButton[]
activeButton: string | null
onButtonChange: (value: string | null) => void
+ extraPills?: ExtraPill[]
}
export function FilterBar({
@@ -20,6 +27,7 @@ export function FilterBar({
buttons,
activeButton,
onButtonChange,
+ extraPills,
}: FilterBarProps) {
return (
@@ -55,6 +63,19 @@ export function FilterBar({
{btn.label}
))}
+ {extraPills?.map((pill) => (
+
+ ))}
)
diff --git a/src/components/mobile-bottom-nav.tsx b/src/components/mobile-bottom-nav.tsx
index c8b7643..1e9f8c5 100644
--- a/src/components/mobile-bottom-nav.tsx
+++ b/src/components/mobile-bottom-nav.tsx
@@ -8,7 +8,6 @@ const tabs = [
{ href: '/watched', label: 'Watched', icon: '✅' },
{ href: '/add', label: 'Add', icon: '➕' },
{ href: '/recommendations', label: 'Recs', icon: '🎯' },
- { href: '/settings', label: 'Settings', icon: '⚙️' },
]
export function MobileBottomNav() {
diff --git a/src/components/mobile-header.tsx b/src/components/mobile-header.tsx
index 4c9832e..0537f6f 100644
--- a/src/components/mobile-header.tsx
+++ b/src/components/mobile-header.tsx
@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
+import Link from 'next/link'
import {
Sheet,
SheetContent,
@@ -56,6 +57,14 @@ export function MobileHeader() {
+ setOpen(false)}
+ >
+ ⚙️
+ Settings
+
diff --git a/src/components/movie-row.tsx b/src/components/movie-row.tsx
index 52b2e4a..9d86399 100644
--- a/src/components/movie-row.tsx
+++ b/src/components/movie-row.tsx
@@ -20,6 +20,14 @@ const SEERR_LABEL: Record = {
deleted: "Deleted",
};
+const SEERR_PILL_CLASS: Record = {
+ not_requested: "bg-stone-100 text-stone-500 border-stone-200",
+ pending: "bg-indigo-50 text-indigo-600 border-indigo-200",
+ processing: "bg-amber-50 text-amber-600 border-amber-200",
+ available: "bg-green-50 text-green-700 border-green-200",
+ deleted: "bg-stone-100 text-stone-500 border-stone-200",
+};
+
interface MovieRowProps {
movie: Movie;
position: number;
@@ -47,10 +55,7 @@ export function MovieRow({
const isStreamable = streamingProviders.length > 0;
const isCheckingStreaming = !isStreamable && movie.streamingLastChecked == null;
- const seerrPillClass =
- movie.seerrStatus === "available"
- ? "bg-amber-50 text-amber-700 border-amber-200"
- : "bg-stone-100 text-stone-500 border-stone-200";
+ const seerrPillClass = SEERR_PILL_CLASS[movie.seerrStatus] ?? "bg-stone-100 text-stone-500 border-stone-200";
const handleConfirmRemove = () => {
setConfirming(false);
@@ -63,22 +68,22 @@ export function MovieRow({
return (
<>
-
+
{/* Position */}
-
+
{position}
{/* Poster */}
-
+
- {/* Info */}
-
-
+ {/* Info — title, year, pills, streaming */}
+
+
{movie.year} · {formatRuntime(movie.runtime)}
@@ -95,12 +100,9 @@ export function MovieRow({
)}
-
- {/* Actions column */}
-
- {/* Row 1: Streaming pill (positive state only) + Seerr status pill */}
-
+ {/* Status pills + streaming info live here */}
+
{isStreamable && (
Streaming
@@ -114,60 +116,38 @@ export function MovieRow({
{SEERR_LABEL[movie.seerrStatus] ?? movie.seerrStatus}
+ {isStreamable && streamingProviders.map((p) => (
+ // eslint-disable-next-line @next/next/no-img-element
+
{
+ (e.target as HTMLImageElement).style.display = "none"
+ }}
+ />
+ ))}
+ {isStreamable && streamingLink && (
+
+ Watch ↗
+
+ )}
+
- {/* Row 2: Provider logos (decorative) + single Where to Watch link */}
- {isStreamable && (
-
- {streamingProviders.map((p) => (
- // eslint-disable-next-line @next/next/no-img-element
-

{
- (e.target as HTMLImageElement).style.display = "none";
- }}
- />
- ))}
- {streamingLink && (
-
- Watch ↗
-
- )}
-
- )}
-
- {/* Row 3: Action buttons */}
-
- {isStreamable ? (
- <>
-
-
- >
- ) : movie.seerrStatus === "available" ? (
+ {/* Actions — single row */}
+
+ {isStreamable ? (
+ <>
- ) : movie.seerrStatus === "not_requested" ||
- movie.seerrStatus === "pending" ? (
- ) : null}
-
+ >
+ ) : movie.seerrStatus === "available" ? (
+
+ ) : movie.seerrStatus === "not_requested" ||
+ movie.seerrStatus === "pending" ? (
+
+ ) : null}
- {/* Row 4: Remove (two-tap confirm) — always last */}
{confirming ? (
-
+ <>
-
+ >
) : (
- {/* Seerr cleanup dialog */}
+ {/* Seerr cleanup dialog — unchanged */}
>
- );
+ )
}
diff --git a/src/components/rating-dialog.tsx b/src/components/rating-dialog.tsx
index 246b538..87e0506 100644
--- a/src/components/rating-dialog.tsx
+++ b/src/components/rating-dialog.tsx
@@ -30,6 +30,7 @@ export function RatingDialog({ movie, open, onClose, onComplete, userNames }: Ra
const [quote, setQuote] = useState('')
const [ratings, setRatings] = useState
([])
const [submitting, setSubmitting] = useState(false)
+ const canSubmit = rating !== undefined && quote.trim().length > 0
const [error, setError] = useState(null)
const handleUserSelect = async (user: User) => {
@@ -106,7 +107,9 @@ export function RatingDialog({ movie, open, onClose, onComplete, userNames }: Ra
-
Critic's Quote
+
+ Critic's Quote *
+