Эх сурвалжийг харах

[Cleanup] Remove dead components and dormant genre wiring

Deletes ExpandedPanel, MovieSearchPanel, HomeRollTeaserCard, RollAnimation,
RollResultCard (all zero references), their tests, and the dormant
use-genre-filter hook (only consumer was ExpandedPanel). Drops the
selectedGenre prop from PosterGrid. Genre Roll's filter path
(lib/dice/genre-filter.ts) is unchanged — it remains the sole filter entry
point per scope decision 3.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User 1 сар өмнө
parent
commit
c7859af637

+ 1 - 8
src/__tests__/a11y/phase4-xss-sweep.test.ts

@@ -18,12 +18,9 @@ const REPO_ROOT = process.cwd();
 
 
 const PHASE4_SURFACE = [
 const PHASE4_SURFACE = [
   "src/components/dice/genre-roll-modal.tsx",
   "src/components/dice/genre-roll-modal.tsx",
-  "src/components/dice/roll-animation.tsx",
   "src/components/dice/roll-announcer.tsx",
   "src/components/dice/roll-announcer.tsx",
   "src/components/dice/roll-bar.tsx",
   "src/components/dice/roll-bar.tsx",
-  "src/components/dice/roll-result-card.tsx",
   "src/components/home/roll-section.tsx",
   "src/components/home/roll-section.tsx",
-  "src/components/home/home-roll-teaser-card.tsx",
   "src/components/movies/movie-list-client.tsx",
   "src/components/movies/movie-list-client.tsx",
 ];
 ];
 
 
@@ -41,11 +38,7 @@ const PHASE4_SURFACE = [
  *   - roll-section.tsx       : tooltip — "Loading lists…" | "Nothing to roll"
  *   - roll-section.tsx       : tooltip — "Loading lists…" | "Nothing to roll"
  *                              | undefined. All hardcoded.
  *                              | undefined. All hardcoded.
  */
  */
-const TITLE_BINDING_ALLOWLIST = new Set<string>([
-  "randomTooltip",
-  "genreTooltip",
-  "tooltip",
-]);
+const TITLE_BINDING_ALLOWLIST = new Set<string>(["randomTooltip", "genreTooltip", "tooltip"]);
 
 
 /**
 /**
  * Strip `/* … *\/` block comments and `// …` line comments before running
  * Strip `/* … *\/` block comments and `// …` line comments before running

+ 0 - 258
src/__tests__/components/dice/roll-animation.test.tsx

@@ -1,258 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { act, render } from "@testing-library/react";
-import { RollAnimation } from "@/components/dice/roll-animation";
-import type { Movie } from "@/types/movie";
-
-function makeMovie(id: string, title = `Movie ${id}`): Movie {
-  return {
-    id,
-    group_id: "g1",
-    tmdb_id: Number(id) || 0,
-    title,
-    year: 2020,
-    poster_path: `/poster-${id}.jpg`,
-    genres: [],
-    trailer_url: null,
-    trailer_url_refreshed_at: null,
-    metadata_refreshed_at: null,
-    added_by: null,
-    watched: false,
-    watched_at: null,
-    added_at: "2026-01-01T00:00:00Z",
-  };
-}
-
-interface MatchMediaListener {
-  (event: { matches: boolean }): void;
-}
-
-interface FakeMediaQueryList {
-  matches: boolean;
-  media: string;
-  addEventListener: (type: "change", listener: MatchMediaListener) => void;
-  removeEventListener: (type: "change", listener: MatchMediaListener) => void;
-}
-
-function installMatchMedia(matches: boolean) {
-  const listeners = new Set<MatchMediaListener>();
-  const mql: FakeMediaQueryList = {
-    matches,
-    media: "(prefers-reduced-motion: reduce)",
-    addEventListener: (_type, listener) => listeners.add(listener),
-    removeEventListener: (_type, listener) => listeners.delete(listener),
-  };
-  vi.stubGlobal(
-    "matchMedia",
-    vi.fn(() => mql),
-  );
-  return mql;
-}
-
-const POOL: Movie[] = [
-  makeMovie("1", "Alpha"),
-  makeMovie("2", "Beta"),
-  makeMovie("3", "Gamma"),
-  makeMovie("4", "Delta"),
-];
-
-describe("<RollAnimation />", () => {
-  beforeEach(() => {
-    vi.useFakeTimers();
-    installMatchMedia(false);
-  });
-
-  afterEach(() => {
-    vi.useRealTimers();
-    vi.unstubAllGlobals();
-    vi.restoreAllMocks();
-  });
-
-  describe("DOM-per-state", () => {
-    it("renders nothing when state is 'idle'", () => {
-      const { container } = render(<RollAnimation pool={POOL} winner={null} state="idle" />);
-      expect(container.firstChild).toBeNull();
-    });
-
-    it("renders the scatter DOM with poster elements when state is 'rolling'", () => {
-      const { getByTestId, queryAllByTestId } = render(
-        <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" />,
-      );
-      expect(getByTestId("roll-animation-scatter")).toBeTruthy();
-      const posters = queryAllByTestId(/^roll-animation-(poster|winner)$/);
-      expect(posters.length).toBeGreaterThan(0);
-      expect(posters.length).toBeLessThanOrEqual(16);
-      // Winner element exists.
-      expect(getByTestId("roll-animation-winner")).toBeTruthy();
-    });
-
-    it("renders the winner prominently when state is 'complete'", () => {
-      const winner = POOL[1];
-      const { getByTestId } = render(
-        <RollAnimation pool={POOL} winner={winner} state="complete" />,
-      );
-      const winnerEl = getByTestId("roll-animation-winner");
-      expect(winnerEl).toBeTruthy();
-      // Prominent winner gets ring + scale styling — verified via class string
-      // (Tailwind classes are not compiled in the test env, so we assert the
-      // class names exist on the element).
-      expect(winnerEl.className).toMatch(/ring-/);
-      // Prominent poster has descriptive alt text.
-      const img = winnerEl.querySelector("img");
-      expect(img).toBeTruthy();
-      expect(img!.getAttribute("alt")).toBe("Beta poster");
-    });
-
-    it("caps the scatter at 16 DOM elements (perf budget)", () => {
-      const bigPool = Array.from({ length: 50 }, (_, i) => makeMovie(String(i + 1)));
-      const { queryAllByTestId } = render(
-        <RollAnimation pool={bigPool} winner={bigPool[0]} state="rolling" />,
-      );
-      const posters = queryAllByTestId(/^roll-animation-(poster|winner)$/);
-      expect(posters.length).toBeLessThanOrEqual(16);
-    });
-  });
-
-  describe("reduced-motion branch", () => {
-    beforeEach(() => {
-      installMatchMedia(true);
-    });
-
-    it("renders the fade-only path (no scatter DOM) when rolling", () => {
-      const { queryByTestId } = render(
-        <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" />,
-      );
-      expect(queryByTestId("roll-animation-reduced-motion")).toBeTruthy();
-      expect(queryByTestId("roll-animation-scatter")).toBeNull();
-    });
-
-    it("renders the fade-only path when state is 'complete'", () => {
-      const { queryByTestId } = render(
-        <RollAnimation pool={POOL} winner={POOL[2]} state="complete" />,
-      );
-      expect(queryByTestId("roll-animation-reduced-motion")).toBeTruthy();
-      expect(queryByTestId("roll-animation-scatter")).toBeNull();
-    });
-
-    it("renders nothing when reduced-motion is on but no winner is provided", () => {
-      const { container } = render(<RollAnimation pool={POOL} winner={null} state="rolling" />);
-      expect(container.firstChild).toBeNull();
-    });
-
-    it("fires onComplete exactly once after the 150 ms fade-in window", () => {
-      const onComplete = vi.fn();
-      render(
-        <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" onComplete={onComplete} />,
-      );
-
-      expect(onComplete).not.toHaveBeenCalled();
-
-      act(() => {
-        vi.advanceTimersByTime(150);
-      });
-
-      expect(onComplete).toHaveBeenCalledTimes(1);
-    });
-  });
-
-  describe("onComplete lifecycle", () => {
-    it("fires exactly once at the end of the rolling timeline", () => {
-      const onComplete = vi.fn();
-      const { rerender } = render(
-        <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" onComplete={onComplete} />,
-      );
-
-      // Mid-animation: not yet fired.
-      act(() => {
-        vi.advanceTimersByTime(2499);
-      });
-      expect(onComplete).not.toHaveBeenCalled();
-
-      // Cross the 2500 ms boundary.
-      act(() => {
-        vi.advanceTimersByTime(1);
-      });
-      expect(onComplete).toHaveBeenCalledTimes(1);
-
-      // Re-render with state="complete" — the parent's typical transition.
-      // onComplete must NOT be called again.
-      rerender(
-        <RollAnimation pool={POOL} winner={POOL[0]} state="complete" onComplete={onComplete} />,
-      );
-      act(() => {
-        vi.advanceTimersByTime(5000);
-      });
-      expect(onComplete).toHaveBeenCalledTimes(1);
-    });
-
-    it("does not fire onComplete when state stays 'idle'", () => {
-      const onComplete = vi.fn();
-      render(<RollAnimation pool={POOL} winner={null} state="idle" onComplete={onComplete} />);
-      act(() => {
-        vi.advanceTimersByTime(10_000);
-      });
-      expect(onComplete).not.toHaveBeenCalled();
-    });
-
-    it("re-arms onComplete on a fresh idle → rolling cycle", () => {
-      const onComplete = vi.fn();
-      const { rerender } = render(
-        <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" onComplete={onComplete} />,
-      );
-
-      act(() => {
-        vi.advanceTimersByTime(2500);
-      });
-      expect(onComplete).toHaveBeenCalledTimes(1);
-
-      // Parent transitions back to idle, then starts a new roll.
-      rerender(<RollAnimation pool={POOL} winner={null} state="idle" onComplete={onComplete} />);
-      rerender(
-        <RollAnimation pool={POOL} winner={POOL[1]} state="rolling" onComplete={onComplete} />,
-      );
-
-      act(() => {
-        vi.advanceTimersByTime(2500);
-      });
-      expect(onComplete).toHaveBeenCalledTimes(2);
-    });
-  });
-
-  describe("timer cleanup", () => {
-    it("cancels all pending timers on unmount (no leaks, no late onComplete)", () => {
-      const onComplete = vi.fn();
-      const { unmount } = render(
-        <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" onComplete={onComplete} />,
-      );
-
-      // Mid-rolling.
-      act(() => {
-        vi.advanceTimersByTime(500);
-      });
-
-      unmount();
-
-      // Advance well past every phase boundary.
-      act(() => {
-        vi.advanceTimersByTime(10_000);
-      });
-
-      expect(vi.getTimerCount()).toBe(0);
-      expect(onComplete).not.toHaveBeenCalled();
-    });
-  });
-
-  describe("XSS safety", () => {
-    it("renders movie titles as text content only (no innerHTML injection)", () => {
-      const evil = makeMovie("9", "<script>alert(1)</script>");
-      // Strip poster_path so the no-image branch renders the title as text.
-      const evilNoPoster: Movie = { ...evil, poster_path: null };
-      const { container } = render(
-        <RollAnimation pool={[evilNoPoster]} winner={evilNoPoster} state="complete" />,
-      );
-      // The literal angle brackets must appear as escaped text — never as
-      // a real <script> element in the DOM.
-      expect(container.querySelector("script")).toBeNull();
-      expect(container.textContent).toContain("<script>alert(1)</script>");
-    });
-  });
-});

+ 0 - 47
src/__tests__/movies/genre-filter.test.ts

@@ -1,47 +0,0 @@
-import { renderHook, act } from "@testing-library/react";
-import { useGenreFilter } from "@/hooks/use-genre-filter";
-
-describe("useGenreFilter", () => {
-  it("initial state has selectedGenre === null", () => {
-    const { result } = renderHook(() => useGenreFilter());
-    expect(result.current.selectedGenre).toBeNull();
-  });
-
-  it("filterByGenre sets selectedGenre", () => {
-    const { result } = renderHook(() => useGenreFilter());
-
-    act(() => {
-      result.current.filterByGenre("Action");
-    });
-
-    expect(result.current.selectedGenre).toBe("Action");
-  });
-
-  it("filterByGenre toggles back to null when called with same genre", () => {
-    const { result } = renderHook(() => useGenreFilter());
-
-    act(() => {
-      result.current.filterByGenre("Action");
-    });
-    expect(result.current.selectedGenre).toBe("Action");
-
-    act(() => {
-      result.current.filterByGenre("Action");
-    });
-    expect(result.current.selectedGenre).toBeNull();
-  });
-
-  it("clearGenreFilter resets to null", () => {
-    const { result } = renderHook(() => useGenreFilter());
-
-    act(() => {
-      result.current.filterByGenre("Comedy");
-    });
-    expect(result.current.selectedGenre).toBe("Comedy");
-
-    act(() => {
-      result.current.clearGenreFilter();
-    });
-    expect(result.current.selectedGenre).toBeNull();
-  });
-});

+ 0 - 249
src/components/dice/roll-animation.tsx

@@ -1,249 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState, useSyncExternalStore } from "react";
-import type { Movie } from "@/types/movie";
-import type { RollState } from "@/hooks/use-roll";
-
-/**
- * <RollAnimation /> — self-contained presentational component for the
- * randomizer's dice roll animation.
- *
- * Phases (only when `state === "rolling"` and reduced-motion is OFF):
- *   - 0–800ms      scatter:   posters fly out into a fan
- *   - 800–2000ms   eliminate: non-winner posters fade/scale away
- *   - 2000–2500ms  settle:    winner snaps to center, scaled up
- *   - 2500ms       fires `onComplete?.()` exactly once
- *
- * Reduced-motion fast path: bypass scatter entirely. Render a 150 ms fade-in
- * on the winner only.
- *
- * Animation strategy: CSS transitions on `transform` / `opacity` only — no
- * `requestAnimationFrame` loops (jsdom no-ops rAF; React 19 Strict Mode
- * double-invokes effects). All timers live in a SINGLE useEffect so the
- * cleanup function is straightforward and Strict-Mode safe.
- *
- * XSS: movie titles are rendered as React text children / `alt` attributes
- * only. No `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
- *
- * Perf: ≤16 DOM elements in the scatter; only `transform` / `opacity`
- * properties animate (GPU-composited).
- */
-
-interface RollAnimationProps {
-  pool: Movie[];
-  winner: Movie | null;
-  state: RollState;
-  onComplete?: () => void;
-}
-
-const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
-
-function subscribeToReducedMotion(callback: () => void) {
-  const mq = window.matchMedia(REDUCED_MOTION_QUERY);
-  mq.addEventListener("change", callback);
-  return () => mq.removeEventListener("change", callback);
-}
-
-function getReducedMotionSnapshot() {
-  return window.matchMedia(REDUCED_MOTION_QUERY).matches;
-}
-
-function getReducedMotionServerSnapshot() {
-  return false;
-}
-
-function usePrefersReducedMotion(): boolean {
-  return useSyncExternalStore(
-    subscribeToReducedMotion,
-    getReducedMotionSnapshot,
-    getReducedMotionServerSnapshot,
-  );
-}
-
-// Phase boundaries (ms from start of "rolling")
-const SCATTER_END_MS = 800;
-const ELIMINATE_END_MS = 2000;
-const SETTLE_END_MS = 2500;
-
-// Hard cap on scatter DOM elements (perf budget).
-const MAX_SCATTER = 16;
-
-// Reduced-motion fade-in duration.
-const REDUCED_MOTION_FADE_MS = 150;
-
-type Phase = "scatter" | "eliminate" | "settle" | "done";
-
-export function RollAnimation({ pool, winner, state, onComplete }: RollAnimationProps) {
-  const prefersReducedMotion = usePrefersReducedMotion();
-  const [phase, setPhase] = useState<Phase>("scatter");
-  // Drives the reduced-motion fade-in (opacity 0 → 1 via Tailwind transition).
-  const [fadedIn, setFadedIn] = useState(false);
-  // Tracks the previous `state` prop so we can reset the cycle's bookkeeping
-  // at render time when the parent transitions out of "rolling". The React
-  // docs recommend setState-during-render for prop-driven resets.
-  const [prevState, setPrevState] = useState<RollState>(state);
-  // Guard so onComplete fires exactly once per "rolling" → "complete" cycle.
-  // Mutated only inside effect / timer callbacks (never at render time).
-  const completedRef = useRef(false);
-
-  if (prevState !== state) {
-    setPrevState(state);
-    if (state !== "rolling") {
-      // Reset visible animation state synchronously so the next "rolling"
-      // cycle starts from a clean slate.
-      setPhase("scatter");
-      setFadedIn(false);
-    }
-  }
-
-  useEffect(() => {
-    if (state !== "rolling") {
-      completedRef.current = false;
-      return;
-    }
-
-    completedRef.current = false;
-
-    // Reduced-motion: skip the scatter timeline entirely. Flip the opacity
-    // class one tick after mount so the CSS transition actually animates,
-    // then fire onComplete after the fade window.
-    if (prefersReducedMotion) {
-      const fadeStart = setTimeout(() => {
-        setPhase("done");
-        setFadedIn(true);
-      }, 0);
-      const fadeDone = setTimeout(() => {
-        if (!completedRef.current) {
-          completedRef.current = true;
-          onComplete?.();
-        }
-      }, REDUCED_MOTION_FADE_MS);
-      return () => {
-        clearTimeout(fadeStart);
-        clearTimeout(fadeDone);
-      };
-    }
-
-    // Full animation timeline. ALL timers are tracked locally and cleared
-    // in the cleanup function — Strict-Mode safe. Initial phase ("scatter")
-    // is handled by the render-time reset above; the timeline only mutates
-    // forward from there.
-    const t1 = setTimeout(() => setPhase("eliminate"), SCATTER_END_MS);
-    const t2 = setTimeout(() => setPhase("settle"), ELIMINATE_END_MS);
-    const t3 = setTimeout(() => {
-      setPhase("done");
-      if (!completedRef.current) {
-        completedRef.current = true;
-        onComplete?.();
-      }
-    }, SETTLE_END_MS);
-
-    return () => {
-      clearTimeout(t1);
-      clearTimeout(t2);
-      clearTimeout(t3);
-    };
-  }, [state, prefersReducedMotion, onComplete]);
-
-  if (state === "idle") return null;
-
-  // Reduced-motion render: winner-only fade-in via Tailwind opacity transition.
-  if (prefersReducedMotion) {
-    if (!winner) return null;
-    // When state arrives already as "complete" (no rolling step), render at
-    // full opacity immediately — there's no animation cycle to wait for.
-    const visible = fadedIn || state === "complete";
-    return (
-      <div className="flex justify-center py-6" data-testid="roll-animation-reduced-motion">
-        <div
-          className={`transition-opacity duration-150 ease-out ${visible ? "opacity-100" : "opacity-0"}`}
-        >
-          <Poster movie={winner} prominent />
-        </div>
-      </div>
-    );
-  }
-
-  // Full animation: scatter pool. Cap to MAX_SCATTER, ensure winner is in.
-  const scatterPool = buildScatterPool(pool, winner, MAX_SCATTER);
-  const showWinnerProminent = state === "complete" || phase === "settle" || phase === "done";
-
-  return (
-    <div
-      className="relative flex flex-wrap justify-center gap-3 py-6 min-h-[10rem]"
-      data-testid="roll-animation-scatter"
-      aria-hidden="true"
-    >
-      {scatterPool.map((movie, i) => {
-        const isWinner = winner !== null && movie.id === winner.id;
-        const eliminated = !isWinner && phase !== "scatter";
-
-        // Spread positions deterministically for visual scatter (transform only).
-        const angle = (i / Math.max(scatterPool.length, 1)) * 360;
-        const radius = phase === "scatter" || phase === "eliminate" ? 60 : 0;
-        const tx = Math.cos((angle * Math.PI) / 180) * radius;
-        const ty = Math.sin((angle * Math.PI) / 180) * radius;
-
-        const transform =
-          isWinner && showWinnerProminent
-            ? "translate(0px, 0px) scale(1.4)"
-            : `translate(${tx.toFixed(1)}px, ${ty.toFixed(1)}px) scale(${eliminated ? 0.4 : 1})`;
-
-        return (
-          <div
-            key={movie.id}
-            data-testid={isWinner ? "roll-animation-winner" : "roll-animation-poster"}
-            className={`
-              w-20 h-28 sm:w-24 sm:h-36 rounded-lg overflow-hidden
-              transition-[transform,opacity] duration-500 ease-out
-              ${eliminated ? "opacity-0" : "opacity-100"}
-              ${isWinner && showWinnerProminent ? "ring-4 ring-yellow-400 shadow-2xl z-10" : ""}
-            `}
-            style={{ transform, willChange: "transform, opacity" }}
-          >
-            <Poster movie={movie} prominent={isWinner && showWinnerProminent} />
-          </div>
-        );
-      })}
-    </div>
-  );
-}
-
-/**
- * Build the scatter pool: ensure the winner is included, cap to `max`,
- * preserve original ordering otherwise. This guarantees the winner has a
- * stable DOM key so its CSS transitions animate smoothly across phases.
- */
-function buildScatterPool(pool: Movie[], winner: Movie | null, max: number): Movie[] {
-  if (pool.length === 0) return winner ? [winner] : [];
-  if (pool.length <= max) {
-    if (winner && !pool.some((m) => m.id === winner.id)) {
-      return [winner, ...pool].slice(0, max);
-    }
-    return pool;
-  }
-  const truncated = pool.slice(0, max);
-  if (winner && !truncated.some((m) => m.id === winner.id)) {
-    truncated[truncated.length - 1] = winner;
-  }
-  return truncated;
-}
-
-function Poster({ movie, prominent }: { movie: Movie; prominent?: boolean }) {
-  if (movie.poster_path) {
-    return (
-      // eslint-disable-next-line @next/next/no-img-element
-      <img
-        src={`https://image.tmdb.org/t/p/w185${movie.poster_path}`}
-        alt={prominent ? `${movie.title} poster` : ""}
-        loading="lazy"
-        className="w-full h-full object-cover"
-      />
-    );
-  }
-  return (
-    <div className="w-full h-full bg-foreground/10 flex items-center justify-center text-xs text-foreground/60 p-1 text-center">
-      {movie.title}
-    </div>
-  );
-}

+ 0 - 105
src/components/dice/roll-result-card.tsx

@@ -1,105 +0,0 @@
-"use client";
-
-import type { Database } from "@/types/database";
-import { getTMDBImageUrl } from "@/types/tmdb";
-
-/**
- * <RollResultCard /> — summary card shown under the roll animation once a
- * winner is chosen. Consumes the DB row shape directly (never the TMDB API
- * shape — those diverge).
- *
- * Interactions:
- *   - click / Enter / Space on the card opens the existing <ExpandedPanel>
- *     via the parent's `onOpen` callback
- *   - the Re-roll button re-runs the roll against the previously captured
- *     snapshot (useRoll handles that internally)
- *
- * XSS: `movie.title`, `movie.year`, and `movie.genres` are rendered as
- * plain React text children. No `dangerouslySetInnerHTML`, no unescaped
- * `title=` attributes with user data.
- */
-type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
-
-interface RollResultCardProps {
-  movie: MovieRow;
-  onReroll: () => void;
-  onOpen: (movie: MovieRow) => void;
-}
-
-export function RollResultCard({ movie, onReroll, onOpen }: RollResultCardProps) {
-  const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
-
-  function handleOpen() {
-    onOpen(movie);
-  }
-
-  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
-    if (e.key === "Enter" || e.key === " ") {
-      e.preventDefault();
-      onOpen(movie);
-    }
-  }
-
-  return (
-    <div
-      className="mx-auto my-4 w-full max-w-md overflow-hidden rounded-xl bg-gray-900/90 shadow-xl ring-1 ring-yellow-400/50"
-      data-testid="roll-result-card"
-    >
-      <div
-        role="button"
-        tabIndex={0}
-        aria-label={`Open details for ${movie.title}`}
-        onClick={handleOpen}
-        onKeyDown={handleKeyDown}
-        className="flex w-full cursor-pointer items-start gap-4 p-4 text-left hover:bg-gray-800/90 focus:outline-none focus:ring-2 focus:ring-yellow-400"
-      >
-        {posterUrl ? (
-          // eslint-disable-next-line @next/next/no-img-element
-          <img
-            src={posterUrl}
-            alt={`${movie.title} poster`}
-            loading="lazy"
-            className="h-36 w-24 flex-shrink-0 rounded-lg object-cover shadow-md"
-          />
-        ) : (
-          <div className="flex h-36 w-24 flex-shrink-0 items-center justify-center rounded-lg bg-gray-700 p-2 text-center text-xs text-gray-400">
-            {movie.title}
-          </div>
-        )}
-
-        <div className="min-w-0 flex-1">
-          <p className="text-xs uppercase tracking-wide text-yellow-400">
-            You rolled
-          </p>
-          <h3 className="mt-1 text-lg font-bold text-white">
-            {movie.title}
-            {movie.year > 0 && (
-              <span className="ml-1 font-normal text-gray-300">
-                ({movie.year})
-              </span>
-            )}
-          </h3>
-          {movie.genres.length > 0 && (
-            <p className="mt-1 text-xs text-gray-400">
-              {movie.genres.slice(0, 4).join(", ")}
-            </p>
-          )}
-          <p className="mt-2 text-xs text-gray-500">Tap to view details</p>
-        </div>
-      </div>
-
-      <div className="flex justify-end border-t border-gray-800 bg-gray-900/60 p-3">
-        <button
-          type="button"
-          onClick={onReroll}
-          aria-label="Re-roll the dice"
-          data-testid="roll-result-reroll"
-          className="inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400"
-        >
-          <span aria-hidden="true">🎲</span>
-          <span>Re-roll</span>
-        </button>
-      </div>
-    </div>
-  );
-}

+ 0 - 112
src/components/home/home-roll-teaser-card.tsx

@@ -1,112 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import type { Movie } from "@/types/movie";
-import { TMDB_GENRE_MAP, getTMDBImageUrl } from "@/types/tmdb";
-
-/**
- * <HomeRollTeaserCard /> — in-place result card for the home-page cross-list
- * roll. Renders IN PLACE per PROJECT_SCOPE.md:222-223; the only navigation
- * is the user-initiated "Open list" link.
- *
- * Uses the DB `Movie` row shape (not the TMDB landing teaser shape). Titles,
- * genres, and group names are rendered as React text children only — no
- * `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
- */
-
-interface HomeRollTeaserCardProps {
-  movie: Movie;
-  groupId: string;
-  groupName: string | null;
-  onReroll: () => void;
-}
-
-function genreLabelsFromIds(ids: string[]): string[] {
-  const labels: string[] = [];
-  for (const raw of ids) {
-    const id = Number.parseInt(raw, 10);
-    if (Number.isNaN(id)) continue;
-    const name = TMDB_GENRE_MAP[id];
-    if (name) labels.push(name);
-  }
-  return labels;
-}
-
-export function HomeRollTeaserCard({
-  movie,
-  groupId,
-  groupName,
-  onReroll,
-}: HomeRollTeaserCardProps) {
-  const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
-  const genreLabels = genreLabelsFromIds(movie.genres ?? []);
-
-  return (
-    <div
-      className="mt-4 rounded-xl border border-foreground/10 bg-foreground/5 p-4 sm:p-6"
-      data-testid="home-roll-teaser-card"
-    >
-      <div className="flex flex-col gap-4 sm:flex-row">
-        {posterUrl ? (
-          // eslint-disable-next-line @next/next/no-img-element
-          <img
-            src={posterUrl}
-            alt={movie.title}
-            loading="lazy"
-            className="w-32 h-48 rounded-lg object-cover self-center sm:self-start"
-          />
-        ) : (
-          <div className="w-32 h-48 rounded-lg bg-foreground/10 flex items-center justify-center text-xs text-foreground/50 self-center sm:self-start">
-            No poster
-          </div>
-        )}
-
-        <div className="flex-1 min-w-0">
-          <h2 className="text-lg sm:text-xl font-semibold text-foreground break-words">
-            {movie.title}
-          </h2>
-          <p className="mt-1 text-sm text-foreground/60">{movie.year}</p>
-
-          {genreLabels.length > 0 && (
-            <div className="mt-2 flex flex-wrap gap-1.5">
-              {genreLabels.map((label) => (
-                <span
-                  key={label}
-                  className="rounded-full bg-foreground/10 px-2 py-0.5 text-xs text-foreground/70"
-                >
-                  {label}
-                </span>
-              ))}
-            </div>
-          )}
-
-          <p
-            className="mt-3 text-xs text-foreground/60 truncate"
-            style={{ maxWidth: "22rem" }}
-          >
-            from {groupName ?? "a list"}
-          </p>
-
-          <div className="mt-4 flex flex-wrap gap-2">
-            <button
-              type="button"
-              onClick={onReroll}
-              aria-label="Re-roll the dice"
-              className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 transition-opacity"
-              style={{ minHeight: 44 }}
-            >
-              🎲 Re-roll
-            </button>
-            <Link
-              href={`/list/${groupId}`}
-              className="rounded-lg border border-foreground/20 px-4 py-2 text-sm font-medium text-foreground hover:bg-foreground/5 transition-colors inline-flex items-center"
-              style={{ minHeight: 44 }}
-            >
-              Open list
-            </Link>
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-}

+ 0 - 151
src/components/movies/expanded-panel.tsx

@@ -1,151 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import type { Database } from "@/types/database";
-import { getTMDBImageUrl } from "@/types/tmdb";
-import { useToggleWatched } from "@/hooks/use-toggle-watched";
-import { useDeleteMovie } from "@/hooks/use-delete-movie";
-import { GenreTag } from "./genre-tag";
-import { WatchedButton } from "./watched-button";
-import { TrailerButton } from "./trailer-button";
-import { DeleteButton } from "./delete-button";
-
-type Movie = Database["public"]["Tables"]["movies"]["Row"];
-
-interface ExpandedPanelProps {
-  movie: Movie;
-  addedByName: string | null;
-  selectedGenre: string | null;
-  onGenreSelect: (genre: string) => void;
-  onClose: () => void;
-}
-
-export function ExpandedPanel({
-  movie,
-  addedByName,
-  selectedGenre,
-  onGenreSelect,
-  onClose,
-}: ExpandedPanelProps) {
-  const panelRef = useRef<HTMLDivElement>(null);
-  const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
-
-  const toggleWatched = useToggleWatched(movie.group_id);
-  const deleteMovie = useDeleteMovie(movie.group_id);
-
-  useEffect(() => {
-    panelRef.current?.focus();
-  }, []);
-
-  useEffect(() => {
-    function handleKeyDown(e: KeyboardEvent) {
-      if (e.key === "Escape") {
-        onClose();
-      }
-    }
-
-    document.addEventListener("keydown", handleKeyDown);
-    return () => document.removeEventListener("keydown", handleKeyDown);
-  }, [onClose]);
-
-  useEffect(() => {
-    function handleClickOutside(e: MouseEvent) {
-      if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
-        onClose();
-      }
-    }
-
-    // Delay to prevent the opening click from immediately closing the panel
-    const timer = setTimeout(() => {
-      document.addEventListener("click", handleClickOutside);
-    }, 0);
-
-    return () => {
-      clearTimeout(timer);
-      document.removeEventListener("click", handleClickOutside);
-    };
-  }, [onClose]);
-
-  return (
-    <div
-      ref={panelRef}
-      role="region"
-      aria-label={`Details for ${movie.title}`}
-      tabIndex={-1}
-      className="col-span-full border-y border-gray-200 bg-gray-50 px-4 py-6 outline-none dark:border-gray-700 dark:bg-gray-900"
-    >
-      <div className="mx-auto flex max-w-2xl flex-col items-center gap-4">
-        <div className="flex w-full justify-end">
-          <button
-            type="button"
-            onClick={onClose}
-            className="min-h-[44px] min-w-[44px] rounded-lg p-2 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"
-            aria-label="Close panel"
-          >
-            <svg
-              xmlns="http://www.w3.org/2000/svg"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              strokeWidth={2}
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              className="h-5 w-5"
-              aria-hidden="true"
-            >
-              <line x1="18" y1="6" x2="6" y2="18" />
-              <line x1="6" y1="6" x2="18" y2="18" />
-            </svg>
-          </button>
-        </div>
-
-        {posterUrl && (
-          <img
-            src={posterUrl}
-            alt={`Movie poster for ${movie.title}`}
-            loading="lazy"
-            className="w-full max-w-xs rounded-lg shadow-md"
-          />
-        )}
-
-        <h2 className="text-center text-xl font-bold text-foreground">
-          {movie.title} {movie.year > 0 && <span className="font-normal">({movie.year})</span>}
-        </h2>
-
-        {addedByName && (
-          <p className="text-sm text-gray-500 dark:text-gray-400">Added by {addedByName}</p>
-        )}
-
-        {movie.genres.length > 0 && (
-          <div className="flex flex-wrap justify-center gap-2" aria-label="Genre filters">
-            {movie.genres.map((genre) => (
-              <GenreTag
-                key={genre}
-                genre={genre}
-                isActive={selectedGenre === genre}
-                onSelect={onGenreSelect}
-              />
-            ))}
-            <span aria-live="polite" className="sr-only">
-              {selectedGenre ? `Filtered by ${selectedGenre}` : "No genre filter active"}
-            </span>
-          </div>
-        )}
-
-        <div className="flex w-full max-w-xs gap-3">
-          <WatchedButton
-            watched={movie.watched}
-            isPending={toggleWatched.isPending}
-            onToggle={() => toggleWatched.mutate({ movieId: movie.id, watched: !movie.watched })}
-          />
-          <TrailerButton trailerUrl={movie.trailer_url} />
-        </div>
-
-        <DeleteButton
-          isPending={deleteMovie.isPending}
-          onDelete={() => deleteMovie.mutate(movie.id, { onSuccess: () => onClose() })}
-        />
-      </div>
-    </div>
-  );
-}

+ 0 - 54
src/components/movies/movie-search-panel.tsx

@@ -1,54 +0,0 @@
-"use client";
-
-import { useState, useCallback } from "react";
-import { SearchBar } from "./search-bar";
-import { SearchResults } from "./search-results";
-import { useMovieSearch } from "@/hooks/use-movie-search";
-import { useAddMovie } from "@/hooks/use-add-movie";
-import type { Database } from "@/types/database";
-
-type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
-
-interface MovieSearchPanelProps {
-  groupId: string;
-  groupMovies: MovieRow[];
-}
-
-export function MovieSearchPanel({ groupId, groupMovies }: MovieSearchPanelProps) {
-  const [searchQuery, setSearchQuery] = useState("");
-  const [addingTmdbId, setAddingTmdbId] = useState<number | null>(null);
-
-  const { data, isLoading } = useMovieSearch(searchQuery);
-  const tmdbResults = data?.results ?? [];
-
-  const addMovie = useAddMovie();
-
-  const handleAdd = useCallback(
-    (tmdbId: number) => {
-      setAddingTmdbId(tmdbId);
-      addMovie.mutate(
-        { tmdb_id: tmdbId, group_id: groupId },
-        { onSettled: () => setAddingTmdbId(null) },
-      );
-    },
-    [addMovie, groupId],
-  );
-
-  return (
-    <div className="space-y-3">
-      <SearchBar onSearch={setSearchQuery} isLoading={isLoading} />
-      {searchQuery.length >= 2 && (
-        <div className="max-h-96 overflow-y-auto rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
-          <SearchResults
-            tmdbResults={tmdbResults}
-            groupMovies={groupMovies}
-            query={searchQuery}
-            isAdding={addMovie.isPending}
-            addingTmdbId={addingTmdbId}
-            onAdd={handleAdd}
-          />
-        </div>
-      )}
-    </div>
-  );
-}

+ 47 - 103
src/components/movies/poster-grid.tsx

@@ -1,16 +1,17 @@
 "use client";
 "use client";
 
 
-import { useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
 import type { Database } from "@/types/database";
 import type { Database } from "@/types/database";
 import { useGroupMovies } from "@/hooks/use-group-movies";
 import { useGroupMovies } from "@/hooks/use-group-movies";
 import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
 import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { useToggleWatched } from "@/hooks/use-toggle-watched";
+import { useDeleteMovie } from "@/hooks/use-delete-movie";
 import { PosterCard } from "./poster-card";
 import { PosterCard } from "./poster-card";
-import { ExpandedPanel } from "./expanded-panel";
+import { ListMoreInfoModal } from "@/components/dice/list-more-info-modal";
 
 
 type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
 type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
 
 
 const EMPTY_USER_NAMES = new Map<string, string>();
 const EMPTY_USER_NAMES = new Map<string, string>();
-const noop = () => {};
 
 
 interface UserInfo {
 interface UserInfo {
   id: string;
   id: string;
@@ -20,27 +21,15 @@ interface UserInfo {
 interface PosterGridProps {
 interface PosterGridProps {
   groupId: string;
   groupId: string;
   users?: UserInfo[];
   users?: UserInfo[];
-  onSelect: (movie: MovieRow) => void;
-  selectedGenre?: string | null;
-  onGenreSelect?: (genre: string) => void;
-  onClearGenre?: () => void;
-  selectedMovieId?: string | null;
-  onClosePanel?: () => void;
   userNames?: Map<string, string>;
   userNames?: Map<string, string>;
 }
 }
 
 
-export function PosterGrid({
-  groupId,
-  users,
-  onSelect,
-  selectedGenre = null,
-  onGenreSelect,
-  onClearGenre,
-  selectedMovieId = null,
-  onClosePanel,
-  userNames = EMPTY_USER_NAMES,
-}: PosterGridProps) {
+export function PosterGrid({ groupId, users, userNames = EMPTY_USER_NAMES }: PosterGridProps) {
   const [watchedOpen, setWatchedOpen] = useState(false);
   const [watchedOpen, setWatchedOpen] = useState(false);
+  const [infoMovieId, setInfoMovieId] = useState<string | null>(null);
+
+  const toggleWatched = useToggleWatched(groupId);
+  const deleteMovie = useDeleteMovie(groupId);
 
 
   const { data, hasNextPage, isFetchingNextPage, fetchNextPage, isLoading, isError } =
   const { data, hasNextPage, isFetchingNextPage, fetchNextPage, isLoading, isError } =
     useGroupMovies(groupId);
     useGroupMovies(groupId);
@@ -63,33 +52,6 @@ export function PosterGrid({
     return { unwatched: uw, watched: w };
     return { unwatched: uw, watched: w };
   }, [allMovies]);
   }, [allMovies]);
 
 
-  const filteredUnwatched = useMemo(
-    () =>
-      selectedGenre
-        ? unwatched.filter((m) => m.genres.includes(selectedGenre))
-        : unwatched,
-    [unwatched, selectedGenre],
-  );
-
-  const filteredWatched = useMemo(
-    () =>
-      selectedGenre
-        ? watched.filter((m) => m.genres.includes(selectedGenre))
-        : watched,
-    [watched, selectedGenre],
-  );
-
-  // Auto-close panel when selected movie is no longer in filtered results
-  useEffect(() => {
-    if (!selectedMovieId) return;
-    const inFiltered =
-      filteredUnwatched.some((m) => m.id === selectedMovieId) ||
-      filteredWatched.some((m) => m.id === selectedMovieId);
-    if (!inFiltered) {
-      onClosePanel?.();
-    }
-  }, [selectedMovieId, filteredUnwatched, filteredWatched, onClosePanel]);
-
   const userMap = useMemo(() => {
   const userMap = useMemo(() => {
     const map = new Map<string, string | null>();
     const map = new Map<string, string | null>();
     if (users) {
     if (users) {
@@ -108,76 +70,48 @@ export function PosterGrid({
     return movie.added_by ? (userNames.get(movie.added_by) ?? null) : null;
     return movie.added_by ? (userNames.get(movie.added_by) ?? null) : null;
   }
   }
 
 
-  const selectedMovie = useMemo(
-    () =>
-      selectedMovieId
-        ? allMovies.find((m) => m.id === selectedMovieId) ?? null
-        : null,
-    [allMovies, selectedMovieId],
+  const infoMovie = useMemo(
+    () => (infoMovieId ? (allMovies.find((m) => m.id === infoMovieId) ?? null) : null),
+    [allMovies, infoMovieId],
   );
   );
 
 
   if (isLoading) {
   if (isLoading) {
     return <p className="py-8 text-center text-gray-400">Loading movies...</p>;
     return <p className="py-8 text-center text-gray-400">Loading movies...</p>;
   }
   }
-
   if (isError) {
   if (isError) {
     return <p className="py-8 text-center text-red-400">Failed to load movies.</p>;
     return <p className="py-8 text-center text-red-400">Failed to load movies.</p>;
   }
   }
-
   if (allMovies.length === 0) {
   if (allMovies.length === 0) {
     return <p className="py-8 text-center text-gray-400">No movies yet. Add one to get started!</p>;
     return <p className="py-8 text-center text-gray-400">No movies yet. Add one to get started!</p>;
   }
   }
 
 
-  function renderMoviesWithPanel(movies: MovieRow[]) {
-    const items: React.ReactNode[] = [];
-    for (const movie of movies) {
-      items.push(
-        <PosterCard
-          key={movie.id}
-          movie={movie}
-          avatarColor={getAvatarColor(movie)}
-          onSelect={onSelect}
-        />,
-      );
-      if (selectedMovie && movie.id === selectedMovieId) {
-        items.push(
-          <ExpandedPanel
-            key={`panel-${movie.id}`}
-            movie={selectedMovie}
-            addedByName={getAddedByName(selectedMovie)}
-            selectedGenre={selectedGenre}
-            onGenreSelect={onGenreSelect ?? noop}
-            onClose={onClosePanel ?? noop}
-          />,
-        );
-      }
-    }
-    return items;
+  function handleInfo(movie: MovieRow) {
+    setInfoMovieId(movie.id);
+  }
+
+  function handleWatchedToggle(movie: MovieRow) {
+    toggleWatched.mutate({ movieId: movie.id, watched: !movie.watched });
+  }
+
+  function handleDelete(movie: MovieRow) {
+    deleteMovie.mutate(movie.id);
+  }
+
+  function renderGrid(movies: MovieRow[]) {
+    return movies.map((movie) => (
+      <PosterCard
+        key={movie.id}
+        movie={movie}
+        avatarColor={getAvatarColor(movie)}
+        onInfo={handleInfo}
+      />
+    ));
   }
   }
 
 
   return (
   return (
     <div>
     <div>
-      {selectedGenre && (
-        <div className="mb-3 flex items-center gap-2">
-          <span className="text-sm text-gray-300">
-            Filtered by <strong>{selectedGenre}</strong>
-          </span>
-          <button
-            type="button"
-            onClick={onClearGenre}
-            className="min-h-[44px] min-w-[44px] rounded-lg px-2 py-1 text-sm text-gray-400 hover:bg-gray-700 hover:text-gray-200"
-            aria-label={`Clear ${selectedGenre} filter`}
-          >
-            Clear
-          </button>
-          <span aria-live="polite" className="sr-only">
-            Showing {filteredUnwatched.length} unwatched and {filteredWatched.length} watched movies filtered by {selectedGenre}
-          </span>
-        </div>
-      )}
-
       <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
       <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
-        {renderMoviesWithPanel(filteredUnwatched)}
+        {renderGrid(unwatched)}
       </div>
       </div>
 
 
       <div ref={sentinelRef} className="h-1" />
       <div ref={sentinelRef} className="h-1" />
@@ -186,7 +120,7 @@ export function PosterGrid({
         <p className="py-4 text-center text-sm text-gray-400">Loading more...</p>
         <p className="py-4 text-center text-sm text-gray-400">Loading more...</p>
       )}
       )}
 
 
-      {filteredWatched.length > 0 && (
+      {watched.length > 0 && (
         <div className="mt-6">
         <div className="mt-6">
           <button
           <button
             type="button"
             type="button"
@@ -201,7 +135,7 @@ export function PosterGrid({
             >
             >
             </span>
             </span>
-            Watched ({filteredWatched.length})
+            Watched ({watched.length})
           </button>
           </button>
 
 
           {watchedOpen && (
           {watchedOpen && (
@@ -209,11 +143,21 @@ export function PosterGrid({
               className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4"
               className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4"
               aria-live="polite"
               aria-live="polite"
             >
             >
-              {renderMoviesWithPanel(filteredWatched)}
+              {renderGrid(watched)}
             </div>
             </div>
           )}
           )}
         </div>
         </div>
       )}
       )}
+
+      {infoMovie && (
+        <ListMoreInfoModal
+          movie={infoMovie}
+          addedByName={getAddedByName(infoMovie)}
+          onClose={() => setInfoMovieId(null)}
+          onWatchedToggle={handleWatchedToggle}
+          onDelete={handleDelete}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 0 - 17
src/hooks/use-genre-filter.ts

@@ -1,17 +0,0 @@
-"use client";
-
-import { useCallback, useState } from "react";
-
-export function useGenreFilter() {
-  const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
-
-  const filterByGenre = useCallback((genre: string) => {
-    setSelectedGenre((prev) => (prev === genre ? null : genre));
-  }, []);
-
-  const clearGenreFilter = useCallback(() => {
-    setSelectedGenre(null);
-  }, []);
-
-  return { selectedGenre, filterByGenre, clearGenreFilter };
-}