Reviewed 2026-05-21. Scope: uncommitted batch on master (cert filter, layout/footer refactor, carousel button conversion, settings refresh, privacy/legal footer).
src/lib/tmdb/certification.ts:51-77, src/app/api/tmdb/search/route.ts:32-39 — The walker requires sawRecognizedCert && sawPositiveMatch. A title is rejected if no recognized country reports any certification at all — extremely common for older, foreign-language, and indie titles (TMDB lets contributors leave certification empty). /api/tmdb/search post-filters with the same walker, so users searching legitimate PG/PG-13-equivalent movies see them silently vanish with no UX signal — invisible failure mode is worse than a permissive filter.
Fix: Define an explicit policy for "no cert data" titles. Options: (a) allow US-released-but-empty-cert when paired with a non-recent release date; (b) treat known no-rating tokens (NR, "", Not Rated, Unrated) as explicitly allowed; (c) fall back to TMDB adult flag + popularity threshold when cert data is absent. Document the choice in the file header.
Implementation Risk: Loosening could let through R-rated indies with missing US cert. Recommend a one-week log of "rejected for no-cert" counts before deciding the policy.
movies rows now break <ListMoreInfoModal>src/app/api/tmdb/movie/[id]/route.ts:25-28, src/components/dice/list-more-info-modal.tsx — Any movie added to public.movies before cert filtering shipped (or one that flips after a future allowlist tightening) will now fail isMovieAllowedByCert when the modal opens. The route correctly 404s to avoid existence leakage, but the user just sees the modal fetch fail on a movie that's already on their list.
Fix: Add a ?context=in-list (or similar) opt-in to /api/tmdb/movie/[id] that skips the cert gate when the caller is fetching details for a movie already in the user's group (server can verify via session + movies lookup before bypassing). Alternative: backfill — sweep movies, soft-flag any that no longer pass, surface UI message instead of silent 404.
Implementation Risk: Context-bypass leaks existence to authenticated members of the owning list only — acceptable. Backfill is destructive and needs admin sign-off.
min-h-screen breaks login/dashboard chromesrc/app/admin/layout.tsx:5-9, src/app/admin/login/page.tsx:40, src/app/admin/page.tsx:13 — The layout now wraps children in flex min-h-screen flex-col bg-neutral-950 + <TMDBFooter /> below a flex-1 slot. Both child pages still self-render their own min-h-screen container. Net effect: the child fills 100vh, the footer renders below it, the page now requires scroll by default, and the centered login UX is offset. bg-neutral-950 is duplicated (harmless).
Fix: Remove min-h-screen from both admin/login/page.tsx and admin/page.tsx. Drop the now-redundant inner bg-neutral-950. Replace login centering with flex-1 flex items-center justify-center on an inner container so it still centers within the layout's flex-1 main area.
Implementation Risk: None if done correctly — Playwright/manual check the centered-login layout after.
<a href> and drop client statesrc/app/(app)/list/[id]/page.tsx:46-67, 70-91 — Both navigation chrome elements use <a href="/home"> / <a href="/list/{id}/settings"> instead of <Link> from next/link. Each click triggers a full document load: TanStack Query cache evicts, IndexedDB-persisted offline data must rehydrate, AuthBootstrap re-runs, optimistic mutation state is lost. The rest of the app uses <Link>.
Fix: Convert both anchors to <Link href={...}>. Preserve className, aria-label, svg child.
Implementation Risk: None; <Link> accepts arbitrary children including svg.
aria-live="polite" on the carousel teaser <button>src/components/dice/list-roll-carousel.tsx:234 — The teaser button has aria-live="polite", but its accessible name (More info about {title}) is static once the winner is set. Worse, the button sits inside another aria-live="polite" region at line 204 — overlapping live regions can cause double-announce or AT confusion. RollAnnouncer is the canonical announcement source elsewhere.
Fix: Remove aria-live="polite" from the <button> on line 234. Also drop the wrapping aria-live on line 204 — rely on RollAnnouncer for the announcement.
src/components/dice/list-roll-carousel.tsx:255-260 — The whole card is now the click target. The italic-serif "i" badge is aria-hidden (correct) but visually implies a separate hit target. Users may hesitate ("do I click the i?").
Fix: Remove the <span aria-hidden>i</span> or replace with a clearly decorative chevron / "tap for info" microcopy. Recompute spacing of the genre chip row below.
/api/tmdb/search post-filter fan-out is heavysrc/app/api/tmdb/search/route.ts:32-39, src/lib/tmdb/client.ts:57-88 — Each search makes up to 20× /movie/{id}?append_to_response=release_dates calls (concurrency 6). A debounced user typing 8 chars produces 5-7 search requests → up to 140 detail calls cold-cache. s-maxage=300 mitigates repeat queries but per-instance edges and cold deploys will push TMDB's 50 req/sec budget.
Fix: Cache cert verdicts in Postgres keyed by tmdb_id with a 30-day TTL, lazily populated. Alternatively, only post-filter at add-time (search shows raw results, add endpoint rejects disallowed) — different UX trade-off.
Implementation Risk: Cached verdicts go stale if TMDB updates a rating — 30-day TTL is acceptable. Lazy at add-time means user can see ineligible titles in results.
src/app/(public)/privacy/page.tsx:138-140 — <span id="ccpa" className="block -translate-y-4" aria-hidden> placed inside <section id="gdpr">. The transform doesn't actually create a scroll-offset (the anchor target still resolves to the original layout box in all major browsers), and both anchors land at the same line. /privacy#ccpa and /privacy#gdpr are visually identical, undermining the legal-footer claim of differentiated landings.
Fix: Split section 6 into two sub-sections — "Your Rights (CCPA / US California)" with id="ccpa" and "Your Rights (GDPR / EU/EEA)" with id="gdpr", each enumerating regime-specific rights. Or place id="ccpa" and id="gdpr" on separate paragraphs within one section.
Implementation Risk: Wording care: CCPA-specific rights (sale/share opt-out, non-discrimination) and GDPR-specific (lodge a complaint, restriction-of-processing) shouldn't be conflated.
src/app/(public)/privacy/page.tsx:140-175 — Section 6 names CCPA but lists GDPR rights only. Missing CCPA-specific items: (a) categories of personal info collected (Identifiers — UUID; Internet/Network activity — server logs); (b) affirmative "We do not sell or share personal information for cross-context behavioral advertising"; (c) right to non-discrimination; (d) right to limit use of sensitive personal info; (e) right to correct; (f) verification method for rights requests (since there's no email collected, document the in-app account-deletion as the verified channel).
Fix: Add a CCPA-specific block with the above. Worth a legal review if the deployment serves California residents at scale.
src/components/shared/legal-footer.tsx:19-21 — Footer says "essential cookies for authentication." Privacy policy §8 also discloses localStorage usage by Supabase libs and admin iron-session cookies. "Essential cookies" is technically true (localStorage isn't a cookie) but most plain-English / ePrivacy interpretations treat any client-side identifier storage as "cookies or similar tracking technologies."
Fix: Reword to "This site uses essential cookies and local storage for authentication. See our Privacy Policy."
aria-live regions around the roll resultsrc/components/dice/list-roll-carousel.tsx:202-211, src/components/movies/movie-list-client.tsx:161 — RollAnnouncer is the canonical aria-live source. The carousel's outer container also wraps the teaser in aria-live="polite". Two announcement surfaces for the same logical event can double-read.
Fix: Drop aria-live from the carousel teaser container. Verify via NVDA/VoiceOver that RollAnnouncer fires at settle.
src/components/shared/legal-footer.tsx:9-17 — <Link href="/privacy#ccpa"> and #gdpr are honored by <Link>, but with finding #8 active, navigation appears to do nothing. Resolving #8 fixes this.
SearchBar placeholder "ADD A MOVIE" with text-center is unconventionalsrc/components/movies/search-bar.tsx:49-52 — Center-aligned uppercase placeholder + pulse-glow reads more like a button than a text input. Keyboard users are OK (aria-label "Search movies").
Fix: Keep pulse-glow; revert placeholder to left-aligned + a leading "+" or magnifier glyph to disambiguate input-vs-button. (NOTE: user explicitly requested centered bold "ADD A MOVIE" placeholder — escalate before changing.)
Privacy policy and footer mention only CCPA + GDPR. For a low-data anonymous-auth service the omission is defensible, but worth flagging:
<button> carousel conversion is clean — no nested interactive descendants. The inner element became <span aria-hidden>, and <ListMoreInfoModal> is portaled to document.body (list-more-info-modal.tsx:267-269), so no nested-<button> HTML invalidity./api/tmdb/search cache stores the filtered payload (NextResponse.json(filtered, ...)). No cache poisoning of disallowed titles into shared edge cache./api/tmdb/movie/[id] correctly returns identical 404 for "doesn't exist" and "cert-blocked" — no existence leak. Intent documented in code.prefers-reduced-motion honored consistently: globals.css opts out both animate-emerge and animate-pulse-glow; ListRollCarousel short-circuits to settled; useRoll reads the same media query.Sentry.setUser() calls introduced. No new PII logging — console.error in TMDB routes logs error objects only.MovieListClient's RollBar gating on allMovies.length > 0 is correct.RollSection correctly gated on hasGroups && hasMovies — no orphan roll UI on a fresh account.JoinListButton size prop is additive, no regression for default callers.SettingsPanel shake-to-arm consistent with ListMoreInfoModal and DeleteButton. No window.confirm regressions.["tmdb-search", q], useUserGroups, useAllUserMovies) — cache behavior preserved./privacy match implemented auth flow.(public) pages).| Severity | Total |
|---|---|
| Critical | 2 |
| High | 2 |
| Medium | 6 |
| Low | 4 |