Owner: Phase 4 U11 (final a11y sweep + grep audit + coverage enumeration).
Status: tests green (npm test → 10 files / 71 tests passed).
Date: 2026-04-11.
This document satisfies Compliance finding #7 by explicitly enumerating which Phase 4 paths are covered by automated unit tests and which require manual QA in a real browser + screen reader. It also records the XSS / unsafe-HTML grep sweep results and any follow-up tickets that must ship before the PM agent checks the eight Phase 4 boxes.
npm test)| Path / behaviour | Test file |
|---|---|
useRoll state transitions (idle → rolling → done) |
src/__tests__/hooks/use-roll.test.ts |
useRoll reduced-motion branch — skips animation timer |
src/__tests__/hooks/use-roll.test.ts |
useRoll pool-capture invariant — concurrent cache mutation cannot change an in-flight roll |
src/__tests__/hooks/use-roll.test.ts |
useRoll re-roll semantics — second roll() rerolls the captured pool |
src/__tests__/hooks/use-roll.test.ts |
useRoll Strict Mode cleanup — double-mount does not leak timers |
src/__tests__/hooks/use-roll.test.ts |
RollAnnouncer DOM-per-state (idle / rolling / done) |
src/__tests__/components/dice/roll-announcer.test.tsx |
RollAnnouncer key increment on re-roll (forces SR re-announce) |
src/__tests__/components/dice/roll-announcer.test.tsx |
RollAnnouncer XSS guard — movie title rendered as text, not HTML |
src/__tests__/components/dice/roll-announcer.test.tsx |
RollAnimation DOM-per-state (idle / rolling / done) |
src/__tests__/components/dice/roll-animation.test.tsx |
RollAnimation reduced-motion branch — skips scatter/flip |
src/__tests__/components/dice/roll-animation.test.tsx |
RollAnimation onComplete lifecycle |
src/__tests__/components/dice/roll-animation.test.tsx |
RollAnimation timer cleanup on unmount (no leaked intervals) |
src/__tests__/components/dice/roll-animation.test.tsx |
RollAnimation XSS guard — poster URL and title not injected as HTML |
src/__tests__/components/dice/roll-animation.test.tsx |
useAllUserMovies RLS-enforced fetch path |
src/__tests__/hooks/use-all-user-movies.test.ts |
useAllUserMovies forged-group simulation — RLS rejects unauthorised group_id |
src/__tests__/hooks/use-all-user-movies.test.ts |
useAllUserMovies dedupe across multiple groups |
src/__tests__/hooks/use-all-user-movies.test.ts |
selectRandomMovie edge cases (empty pool, single item, modulo bias) |
src/__tests__/dice/randomizer.test.ts |
useRealtimeMovies dual invalidation — invalidates both ['group-movies', groupId] and ['all-user-movies', userId] |
src/__tests__/hooks/use-realtime-movies.test.ts |
filterByGenresAndEmotions (string-based, legacy) — token match, no-match fallback |
src/__tests__/movies/genre-filter.test.ts |
| Query-key contract stability across hooks | src/__tests__/movies/query-keys.test.ts |
| Real-time cache projection does not drop rows | src/__tests__/movies/realtime-cache.test.ts |
| XSS / unsafe-HTML static grep sweep over Phase 4 surface | src/__tests__/a11y/phase4-xss-sweep.test.ts (NEW in U11) |
filterByGenresAndEmotionsStructured (U8) — the new structured variant
in src/lib/dice/genre-filter.ts is currently used by
MovieListClient in-list rolls. The existing genre-filter test file covers
the legacy string-based tokenizer. U8 shipped its own tests for the
structured function inside genre-filter.test.ts; verify that block exists
before closing Phase 4. If the block is absent, file a sub-ticket against
U8 — do not retro-add here (U11 does not modify existing test files).These paths cannot be meaningfully asserted from jsdom/Vitest. Each must be exercised by a human reviewer before the PM agent checks Phase 4 complete.
prefers-reduced-motion: reduce via DevTools Rendering panel
emulation → confirm both RollAnimation and useRoll skip the
animation branch and jump straight to the final state.<GenreRollModal> focus trap: tab cycle stays inside the modal;
initial focus lands on the search input; closing the modal restores
focus to the RollBar button that opened it.Tab to RollBar → Enter
on Roll the Dice → Tab to re-roll button → Enter → Tab to close.Tab to RollSection →
Enter → re-roll → close. Confirm the page does NOT navigate
(HomeRollTeaserCard renders in place per CLAUDE.md:45).Test on VoiceOver (macOS) OR Orca (Linux). Order must be:
"Rolling…" is announced."Rolled <Movie Title> (<Year>)" is announced.RollAnnouncer key increment is unit
tested, but the actual AT re-announce behaviour is not).devDependencies and U11 is forbidden from adding dependencies)./home with an active roll result visible → zero serious or
critical violations./list/<id> with the Genre Roll modal open → zero serious or
critical violations./list/<id> with a roll result visible → zero serious or
critical violations.Sweep targets (the Phase 4 rendering surface):
src/components/dice/
src/components/home/roll-section.tsx
src/components/home/home-roll-teaser-card.tsx
src/components/movies/movie-list-client.tsx
dangerouslySetInnerHTMLCommand:
grep -rn "dangerouslySetInnerHTML" src/components/dice/ \
src/components/home/roll-section.tsx \
src/components/home/home-roll-teaser-card.tsx \
src/components/movies/movie-list-client.tsx
Result:
src/components/dice/roll-animation.tsx:26: * only. No `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
src/components/dice/roll-result-card.tsx:18: * plain React text children. No `dangerouslySetInnerHTML`, no unescaped
src/components/home/home-roll-teaser-card.tsx:14: * `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
All three hits are inside JSDoc block comments — they document the absence
of the pattern. Zero production JSX usages. Asserted in
src/__tests__/a11y/phase4-xss-sweep.test.ts (has no dangerouslySetInnerHTML
test case, run once per surface file after comment stripping).
title= attribute bindingsCommand:
grep -rn "title=" src/components/dice/ \
src/components/home/roll-section.tsx \
src/components/home/home-roll-teaser-card.tsx \
src/components/movies/movie-list-client.tsx
Result (excluding JSDoc comment lines):
| File | Line | Binding | Resolved value | Safe? |
|---|---|---|---|---|
src/components/dice/roll-bar.tsx |
53 | title={randomTooltip} |
"Loading…" | "Nothing to roll" | "Roll the dice for a random pick" — all hardcoded in disabledReason() or the constant fallback |
YES |
src/components/dice/roll-bar.tsx |
66 | title={genreTooltip} |
"Loading…" | "Nothing to roll" | "Pick genres and moods, then roll" — same provenance |
YES |
src/components/home/roll-section.tsx |
91 | title={tooltip} |
"Loading lists…" | "Nothing to roll" | undefined — hardcoded ternary over isLoading / fullPool.length === 0 |
YES |
src/components/home/roll-section.tsx |
106 | title={tooltip} |
Same variable, same provenance | YES |
src/components/home/home-roll-teaser-card.tsx and
src/components/movies/movie-list-client.tsx have zero title= bindings
outside JSDoc comments.
Conclusion. No title= attribute on the Phase 4 surface carries user-,
movie-, group-, or otherwise-interpolated data. Every bound identifier has
been added to TITLE_BINDING_ALLOWLIST in
src/__tests__/a11y/phase4-xss-sweep.test.ts with an audit note. A new
title={someExpr} binding will fail the sweep until an auditor extends the
allowlist deliberately.
Command:
grep -rn "landing/genre-roll-modal" src/
Result: zero matches.
U7 migrated <GenreRollModal> from src/components/landing/ to
src/components/dice/. A merge-conflict resurrection of the old path would
silently reintroduce an unowned copy, so the sweep asserts that no file
references the legacy path.
Ticket: phase4-followup-home-genre-structured-filter
Severity: functional defect (not a security issue).
Owner: Phase 4 follow-up (must ship before MVP cut).
Do NOT fix in U11 — U11 is non-production.
src/components/home/roll-section.tsx currently calls the legacy
string-based filterByGenresAndEmotions(tokens, fullPool) with a token
string built via:
const tokens = [...payload.genreIds.map(String), ...payload.moodKeys].join(" ");
This is Option A from the Phase 4 plan (U9 landed the quick fix so the
home roll teaser could ship). The legacy tokenizer matches against the
movies.genres column, which stores TMDB genre names (e.g. "Action",
"Comedy"), not numeric IDs as strings. A genre-only selection produces
tokens like "28 12"; these never match any row, so noMatches === true
and RollSection.handleGenreRoll falls through to an unfiltered full-pool
roll. The user sees noMatchesBanner but their genre filter was silently
discarded.
U8 shipped filterByGenresAndEmotionsStructured in
src/lib/dice/genre-filter.ts. That variant accepts a structured payload
and matches genre IDs against both numeric TMDB IDs and genre names,
closing the mismatch. The swap in RollSection is approximately a five-line
change:
// BEFORE
const tokens = [...payload.genreIds.map(String), ...payload.moodKeys].join(" ");
const { movies: filtered, noMatches } = filterByGenresAndEmotions(tokens, fullPool);
// AFTER
const { movies: filtered, noMatches } = filterByGenresAndEmotionsStructured(
{ genreIds: payload.genreIds, moodKeys: payload.moodKeys },
fullPool,
);
Also swap the import at the top of roll-section.tsx. The in-list roll
(MovieListClient) already uses the structured variant, so the fix brings
the home-view roll in line with the list-view roll.
U11 is explicitly a test + docs-only unit with a hard "no production code changes" scope guard. File this as a follow-up ticket before the PM agent closes Phase 4.
The Section 4 E2E walkthrough from PHASE4_PLAN.md (list-view roll → re-roll
→ Genre Roll modal → home-view roll → cross-list roll → back to list → new
roll) is a blocker before the PM agent can check the eight Phase 4
boxes. U11 does NOT attempt to run it — Vitest cannot drive a real browser,
a real screen reader, or the Supabase realtime socket. The reviewer must:
CLAUDE.md § Local MVP Testing (Supabase
Docker stack + npm run dev).useAllUserMovies dedupe).Only then may Phase 4 be marked complete.
npm test -- src/__tests__/a11y/phase4-xss-sweep.test.ts
Expected: 17 passed (17) across three sub-suites
(dangerouslySetInnerHTML, title= bindings, and the stale-import guard).
Full suite: npm test → 10 files / 71 tests passed.