feat(ui): give the app one visual identity as Movie Loop

Gold is the primary in both themes, Bricolage Grotesque carries the
wordmark, titles and the score, and Movie A / Movie B wear the blue and
magenta of three-strip Technicolor on their posters instead of colouring
whole rows. The loop closes on screen - connectors light, a gold line
draws back to Movie A, the row glows, then the result arrives - in place
of confetti, on every play mode, scaled to the chain's length. Every
page shares one header and hairline lists; copy is sentence case with
one verb per flow, and the Home hero shows a real finished loop.

Also clamps link counts at zero: an opponent who had not moved yet
showed "-1 links".
This commit is contained in:
TehRiehlDeal committed 2026-09-12 22:42:55 -07:00
1 parent da32e49b0b
commit 8d0b7f39ee
122 files changed
+3129 -2089

No files matched your search

+61
View File
@@ -9,6 +9,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.3.0] - 2026-09-12
A look-and-feel pass. The app now has one visual identity - Movie Loop - with
the closed loop as its only bold element.
### Added
- A display typeface (Bricolage Grotesque) for the wordmark, page titles, the
puzzle statement and the score, alongside Geist for everything else, and a
declared type scale with a 12px floor.
- A logo mark - an open ring with the missing link in the gap - beside the
wordmark, on the sign-in and 404 pages, in the completion dialog, and as an
SVG favicon.
- The loop closes on screen: connectors light up top to bottom, a gold line
draws from the last row back to Movie A, the closing row glows, and the
result arrives after - in solo, versus, async and game-night play alike. The
sequence scales with the chain's length. Under reduced motion the finished
loop simply appears and the result opens at once.
- The Home page shows a real finished loop (Speed to Apocalypse Now and back)
in place of the how-to-play card; the ? in the header keeps the full rules.
- Posters and photos fall back to placeholder art if TMDB no longer serves
them, instead of showing a broken image.
- `PageHeader`, `MovieMarker` and `LogoMark` components; difficulty badges now
carry ordinal pips.
### Changed
- Gold is the primary colour in both themes; it no longer flips to indigo in
light mode. The dark background is a true indigo-slate rather than near-black.
- Movie A and Movie B are identified by the poster's ring and an A/B chip, in
the blue and magenta of three-strip Technicolor, instead of colouring whole
rows. Green is reserved for online/ready states and no longer means "correct".
- The timer, difficulty badges, trophies, win/lose states and score lines use
the palette tokens instead of stock red/green/yellow.
- The game screen states the puzzle in a sentence ("Toy Story to Cast Away and
back.") with the timer, link count and next objective on one line; the two
movie cards that repeated the top of the chain are gone. Posters in the chain
are larger.
- Home, Daily, Leaderboard, Profile, Achievements, Endless, Custom, Login, Game
review, Versus, Game night, Friends and Admin share one left-aligned page
header, hairline lists instead of card grids, and sentence-case copy. Profile
shows the streak large and the rest of the record as a ledger, with account
settings last.
- Copy: "Quick Play" is "Play now", "Ranks" is "Leaderboard", "Passed Movie B"
is a changing objective ("Next: reach …", "Next: back to …", "Loop closed"),
"Connected!" is "Linked", "Congratulations!" is "Loop closed.", and empty
states say what to do next.
- The daily page shows the "not generated yet" state as an ordinary notice
rather than red error text.
### Fixed
- An opponent who has not moved yet showed "-1 links"; an empty chain now
counts as zero.
### Removed
- The confetti overlay; the loop-closing sequence replaces it.
- Stock Tailwind palette colours from game components, uppercase table headers
and labels, and the `text-7xl`/`text-8xl` sizes.
## [2.2.3] - 2026-09-12 ## [2.2.3] - 2026-09-12
The first production deploy of the push opt-in. The first production deploy of the push opt-in.
+18 -6
View File
@@ -76,23 +76,35 @@ Coverage thresholds live in `vite.config.ts`; every source file is above them in
src/ src/
├── api/ one module per backend resource, all through api/client.ts (axios) ├── api/ one module per backend resource, all through api/client.ts (axios)
├── components/ ├── components/
│ ├── game/ ChainDisplay, SearchAutocomplete (ARIA combobox), GameHeader, completion modals… │ ├── game/ ChainDisplay + LoopReturn (the serpentine chain), MovieMarker, SearchAutocomplete (ARIA combobox), GameHeader, completion modal…
│ ├── game-night/, versus/ │ ├── game-night/, versus/
│ ├── layout/ PageLayout, nav, NotificationBell │ ├── layout/ PageLayout, PageHeader, LogoMark, nav, NotificationBell
│ ├── profile/ PushNotificationsSection │ ├── profile/ PushNotificationsSection
│ └── ui/ shadcn primitives (Base UI) │ └── ui/ shadcn primitives (Base UI)
├── hooks/ use-game-screen (the shared game-page shell), use-chain-validation, use-timer, use-hints, use-push-subscription ├── hooks/ use-game-screen (the shared game-page shell), use-chain-validation, use-timer, use-hints, use-push-subscription, use-image-fallback
├── lib/ config, error, format, movie-pair(s), push, sounds, create-socket, storage ├── lib/ config, error, format, movie-pair(s), example-loop (the Home hero), motion, push, sounds, create-socket, storage
├── pages/ one file per route ├── pages/ one file per route
├── stores/ Zustand: auth, game, versus, game-night, chat, friends, notifications ├── stores/ Zustand: auth, game, versus, game-night, chat, friends, notifications
├── types/ ├── types/
└── App.tsx routes (lazy) + Suspense └── App.tsx routes (lazy) + Suspense
public/ public/
├── sw.js service worker: push display + notification click ├── sw.js service worker: push display + notification click
├── manifest.json, icon-192.png, icon-512.png, apple-touch-icon.png, favicon-32.png ├── manifest.json, favicon.svg (the mark), icon-192.png, icon-512.png, apple-touch-icon.png, favicon-32.png
└── sounds/ └── sounds/
``` ```
### Design
The look is one system, defined in `src/index.css` and nowhere else.
- **Two typefaces, two jobs.** Bricolage Grotesque (`font-display`) carries the personality: the wordmark, page titles, the puzzle statement, the score. Geist (`font-sans`) does everything that has to be read quickly. Both are self-hosted through `@fontsource-variable`. The type scale is declared as Tailwind `--text-*` tokens; 12px (`text-xs`) is the floor and `text-6xl` the ceiling - the larger steps are unset on purpose.
- **Gold is the primary in both themes.** Light is the matinee, dark is house lights down, and buttons, focus rings and the mark keep their colour when the theme toggles. `text-primary-text` is the gold to use *as text*: a bronze on light that clears 4.5:1, the primary itself on dark.
- **Three-strip Technicolor for the game.** The blue record is Movie A (`movie-a`), the magenta record is Movie B (`movie-b`), and the yellow record is the loop: connectors turn gold when it closes. Green (`success`) is only for non-game states - online, ready - and never means "correct". Identity lives on the poster (`MovieMarker` chip + ring), never on a whole row.
- **Structure encodes state.** Difficulty is monochrome with ordinal pips, page titles come from one `PageHeader`, sections are separated by a rule above them, and lists are hairline rows rather than cards.
- **One orchestrated moment.** When a loop closes, the connectors light top to bottom, `LoopReturn` draws the line back to Movie A, the closing row glows, and only then does the score arrive. Everything else moves only in answer to the player.
To add a colour, add a token to both theme blocks and a `--color-*` alias in `@theme inline`; do not reach for a stock Tailwind palette colour in a component.
### How a game works, client side ### How a game works, client side
`game-store` owns the session: it creates one on the server, sends each link to `POST /games/:id/validate-link`, and asks the server to score on completion. The store never computes a score. When the backend cannot be reached, anonymous play falls back to checking TMDB credits client-side through the API proxy — but such a game has no session and is not scored or ranked. `game-store` owns the session: it creates one on the server, sends each link to `POST /games/:id/validate-link`, and asks the server to score on completion. The store never computes a score. When the backend cannot be reached, anonymous play falls back to checking TMDB credits client-side through the API proxy — but such a game has no session and is not scored or ranked.
@@ -109,7 +121,7 @@ Opt-in from **Profile → Notifications**. The service worker is registered at s
### Accessibility ### Accessibility
`eslint-plugin-jsx-a11y` runs at error level. Every form control has a label, every icon-only button has a name, dialogs are real dialogs (Base UI), validation results and countdowns are announced through live regions, there is a skip link, and `prefers-reduced-motion` collapses every animation. `eslint-plugin-jsx-a11y` runs at error level. Every form control has a label, every icon-only button has a name, dialogs are real dialogs (Base UI), validation results, the game objective and countdowns are announced through live regions, there is a skip link, and `prefers-reduced-motion` collapses every animation - including the loop-closing sequence, in which case the score modal opens at once instead of waiting for it.
## Production image ## Production image
+2 -1
View File
@@ -2,12 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>You Know Who Else Was In That Movie?</title> <title>You Know Who Else Was In That Movie?</title>
<meta name="description" content="A trivia-puzzle game inspired by Six Degrees of Kevin Bacon. Build a chain of actor-movie connections forming a complete loop between two movies." /> <meta name="description" content="A trivia-puzzle game inspired by Six Degrees of Kevin Bacon. Build a chain of actor-movie connections forming a complete loop between two movies." />
<meta name="theme-color" content="#1e1b30" /> <meta name="theme-color" content="#111524" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<!-- Open Graph --> <!-- Open Graph -->
+12 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "movie-loop-frontend", "name": "movie-loop-frontend",
"version": "2.2.3", "version": "2.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "movie-loop-frontend", "name": "movie-loop-frontend",
"version": "2.2.3", "version": "2.3.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -1684,6 +1685,15 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fontsource-variable/bricolage-grotesque": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
"integrity": "sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/geist": { "node_modules/@fontsource-variable/geist": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "movie-loop-frontend", "name": "movie-loop-frontend",
"private": true, "private": true,
"version": "2.2.3", "version": "2.3.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"author": "Kevin Riehl", "author": "Kevin Riehl",
@@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#da950b" stroke-width="2.5" stroke-linecap="round"><path d="M20.5 12a8.5 8.5 0 1 1-3.63-6.96"/><circle cx="19.54" cy="8.08" r="1.75" fill="#da950b" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 250 B

+2 -2
View File
@@ -3,8 +3,8 @@
"short_name": "Movie Loop", "short_name": "Movie Loop",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#1e1b30", "background_color": "#111524",
"theme_color": "#1e1b30", "theme_color": "#111524",
"icons": [ "icons": [
{ {
"src": "/icon-192.png", "src": "/icon-192.png",
+6 -4
View File
@@ -72,7 +72,7 @@ describe('routing', () => {
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument(); expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
expect( expect(
await screen.findByRole('heading', { name: 'Daily Challenges' }), await screen.findByRole('heading', { name: "Today's challenge" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull(); expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull();
}); });
@@ -83,7 +83,7 @@ describe('routing', () => {
render(<App />); render(<App />);
expect( expect(
screen.getByRole('heading', { name: 'You Know Who Else Was In That Movie?' }), screen.getByRole('heading', { name: 'You know who else was in that movie?' }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull(); expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull();
}); });
@@ -93,8 +93,10 @@ describe('routing', () => {
render(<App />); render(<App />);
expect(await screen.findByText('404')).toBeInTheDocument(); // The 404 page is a lazy chunk; under coverage instrumentation it can
expect(screen.getByText('Page not found.')).toBeInTheDocument(); // take longer than findBy's default second to arrive.
expect(await screen.findByText(/Page not found/, {}, { timeout: 4000 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'This screen is dark.' })).toBeInTheDocument();
}); });
}); });
@@ -1,7 +1,14 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router'; import { MemoryRouter, Route, Routes } from 'react-router';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
// The completion modal waits for the loop-closing animation; there is none
// to wait for in jsdom, and these tests are about the content, not the delay.
vi.mock('@/lib/motion', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/motion')>()),
prefersReducedMotion: () => true,
}));
import { useGameStore } from '@/stores/game-store'; import { useGameStore } from '@/stores/game-store';
import { useGameNightStore } from '@/stores/game-night-store'; import { useGameNightStore } from '@/stores/game-night-store';
import GameNightCompletionModal from './GameNightCompletionModal'; import GameNightCompletionModal from './GameNightCompletionModal';
@@ -72,18 +79,18 @@ describe('GameNightCompletionModal', () => {
expect(screen.queryByRole('dialog')).toBeNull(); expect(screen.queryByRole('dialog')).toBeNull();
}); });
it('opens with "Chain Complete!" once my chain is done, before the room finishes', () => { it('opens with "Chain complete" once my chain is done, before the room finishes', () => {
useGameStore.setState({ status: 'completed', score: score(1200) }); useGameStore.setState({ status: 'completed', score: score(1200) });
renderModal(); renderModal();
expect(screen.getByRole('heading', { name: 'Chain Complete!' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Chain complete' })).toBeInTheDocument();
expect(screen.getByText('1200')).toBeInTheDocument(); expect(screen.getByText('1,200')).toBeInTheDocument();
expect(screen.getByText('Your Score')).toBeInTheDocument(); expect(screen.getByText('Your score')).toBeInTheDocument();
}); });
it('shows no score panel when the store has none yet', () => { it('shows no score panel when the store has none yet', () => {
useGameStore.setState({ status: 'completed', score: null }); useGameStore.setState({ status: 'completed', score: null });
renderModal(); renderModal();
expect(screen.queryByText('Your Score')).toBeNull(); expect(screen.queryByText('Your score')).toBeNull();
}); });
it('lists the other active players with singular wording for exactly one waiting', () => { it('lists the other active players with singular wording for exactly one waiting', () => {
@@ -94,7 +101,7 @@ describe('GameNightCompletionModal', () => {
}); });
renderModal(); renderModal();
expect(screen.getByText('Waiting for 1 player...')).toBeInTheDocument(); expect(screen.getByText('Waiting for 1 player…')).toBeInTheDocument();
expect(screen.getByText('Rival')).toBeInTheDocument(); expect(screen.getByText('Rival')).toBeInTheDocument();
}); });
@@ -106,7 +113,7 @@ describe('GameNightCompletionModal', () => {
}); });
renderModal(); renderModal();
expect(screen.getByText('Waiting for 2 players...')).toBeInTheDocument(); expect(screen.getByText('Waiting for 2 players…')).toBeInTheDocument();
}); });
it('excludes spectators from the waiting count and the roster', () => { it('excludes spectators from the waiting count and the roster', () => {
@@ -157,12 +164,12 @@ describe('GameNightCompletionModal', () => {
expect(screen.getByText('lobby-list route')).toBeInTheDocument(); expect(screen.getByText('lobby-list route')).toBeInTheDocument();
}); });
it('shows "Game Over!" and a results button once the room has finished', async () => { it('shows "Game over" and a results button once the room has finished', async () => {
useGameNightStore.setState({ lobbyState: 'finished', roomId: 'room1' }); useGameNightStore.setState({ lobbyState: 'finished', roomId: 'room1' });
renderModal('/game-night/room1'); renderModal('/game-night/room1');
expect(screen.getByRole('heading', { name: 'Game Over!' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Game over' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'View Results' })); await userEvent.click(screen.getByRole('button', { name: 'View results' }));
expect(screen.getByText('results route')).toBeInTheDocument(); expect(screen.getByText('results route')).toBeInTheDocument();
}); });
@@ -170,7 +177,7 @@ describe('GameNightCompletionModal', () => {
useGameNightStore.setState({ lobbyState: 'finished', roomId: null }); useGameNightStore.setState({ lobbyState: 'finished', roomId: null });
renderModal('/game-night/room1'); renderModal('/game-night/room1');
await userEvent.click(screen.getByRole('button', { name: 'View Results' })); await userEvent.click(screen.getByRole('button', { name: 'View results' }));
expect(screen.queryByText('results route')).toBeNull(); expect(screen.queryByText('results route')).toBeNull();
}); });
@@ -1,5 +1,7 @@
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useGameStore } from '@/stores/game-store'; import { useGameStore } from '@/stores/game-store';
import { useLoopReveal } from '@/hooks/use-loop-reveal';
import { getLinkCount } from '@/types';
import { useGameNightStore } from '@/stores/game-night-store'; import { useGameNightStore } from '@/stores/game-night-store';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -13,6 +15,9 @@ import { CheckCircle2, Flag, Loader2 } from 'lucide-react';
export default function GameNightCompletionModal() { export default function GameNightCompletionModal() {
const status = useGameStore((s) => s.status); const status = useGameStore((s) => s.status);
const score = useGameStore((s) => s.score); const score = useGameStore((s) => s.score);
const chain = useGameStore((s) => s.chain);
// The loop gets the screen to itself before the result covers it.
const revealed = useLoopReveal(status, getLinkCount(chain));
const navigate = useNavigate(); const navigate = useNavigate();
const lobbyState = useGameNightStore((s) => s.lobbyState); const lobbyState = useGameNightStore((s) => s.lobbyState);
@@ -23,7 +28,7 @@ export default function GameNightCompletionModal() {
const leaveRoom = useGameNightStore((s) => s.leaveRoom); const leaveRoom = useGameNightStore((s) => s.leaveRoom);
const reset = useGameNightStore((s) => s.reset); const reset = useGameNightStore((s) => s.reset);
const isOpen = status === 'completed' || lobbyState === 'finished'; const isOpen = (status === 'completed' || lobbyState === 'finished') && revealed;
const activePlayers = players.filter((p) => !p.isSpectator); const activePlayers = players.filter((p) => !p.isSpectator);
const waitingCount = activePlayers.filter( const waitingCount = activePlayers.filter(
@@ -49,15 +54,15 @@ export default function GameNightCompletionModal() {
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{lobbyState === 'finished' ? 'Game Over!' : 'Chain Complete!'} {lobbyState === 'finished' ? 'Game over' : 'Chain complete'}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{score && ( {score && (
<div className="rounded-lg border border-border bg-card p-4 text-center"> <div className="rounded-lg bg-card p-5 ring-1 ring-foreground/10 text-center">
<p className="text-3xl font-bold">{score.totalScore}</p> <p className="font-display text-3xl font-bold tabular-nums">{score.totalScore.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Your Score</p> <p className="text-sm text-muted-foreground">Your score</p>
</div> </div>
)} )}
@@ -67,24 +72,24 @@ export default function GameNightCompletionModal() {
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm"> <span className="text-sm">
Waiting for {waitingCount} player Waiting for {waitingCount} player
{waitingCount !== 1 ? 's' : ''}... {waitingCount !== 1 ? 's' : ''}…
</span> </span>
</div> </div>
<div className="space-y-1"> <div className="divide-y divide-border">
{activePlayers.map((player) => { {activePlayers.map((player) => {
const isFinished = finishedPlayers.has(player.userId); const isFinished = finishedPlayers.has(player.userId);
const isForfeited = forfeitedPlayers.has(player.userId); const isForfeited = forfeitedPlayers.has(player.userId);
return ( return (
<div <div
key={player.id} key={player.id}
className="flex items-center justify-between text-sm px-2 py-1" className="flex items-center justify-between text-sm py-3"
> >
<span>{player.user.username}</span> <span>{player.user.username}</span>
{isForfeited ? ( {isForfeited ? (
<Flag className="h-3.5 w-3.5 text-destructive" /> <Flag className="h-3.5 w-3.5 text-destructive" />
) : isFinished ? ( ) : isFinished ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" /> <CheckCircle2 className="h-3.5 w-3.5 text-success" />
) : ( ) : (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" /> <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
)} )}
@@ -106,7 +111,7 @@ export default function GameNightCompletionModal() {
{lobbyState === 'finished' && ( {lobbyState === 'finished' && (
<Button className="w-full" onClick={handleViewResults}> <Button className="w-full" onClick={handleViewResults}>
View Results View results
</Button> </Button>
)} )}
</div> </div>
@@ -81,10 +81,10 @@ describe('PlayerCard', () => {
expect(screen.getByRole('button', { name: 'Unready' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Unready' })).toBeInTheDocument();
}); });
it('highlights the current user with a distinct border class', () => { it('highlights the current user with a distinct ring class', () => {
const { container: mine } = render(<PlayerCard player={player()} isCurrentUser />); const { container: mine } = render(<PlayerCard player={player()} isCurrentUser />);
const { container: theirs } = render(<PlayerCard player={player()} isCurrentUser={false} />); const { container: theirs } = render(<PlayerCard player={player()} isCurrentUser={false} />);
expect(mine.firstElementChild?.className).toContain('border-primary'); expect(mine.firstElementChild?.className).toContain('ring-primary');
expect(theirs.firstElementChild?.className).toContain('border-border'); expect(theirs.firstElementChild?.className).toContain('ring-foreground/10');
}); });
}); });
+4 -4
View File
@@ -16,15 +16,15 @@ export default function PlayerCard({
}: PlayerCardProps) { }: PlayerCardProps) {
return ( return (
<div <div
className={`rounded-lg border bg-card p-4 space-y-2 ${ className={`rounded-lg bg-card p-5 space-y-2 ring-1 ${
isCurrentUser ? 'border-primary' : 'border-border' isCurrentUser ? 'ring-2 ring-primary' : 'ring-foreground/10'
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{player.user.username}</p> <p className="font-medium">{player.user.username}</p>
{player.isHost && ( {player.isHost && (
<Crown className="h-4 w-4 text-yellow-500" /> <Crown className="h-4 w-4 text-primary" />
)} )}
</div> </div>
{player.isSpectator ? ( {player.isSpectator ? (
@@ -33,7 +33,7 @@ export default function PlayerCard({
Spectator Spectator
</Badge> </Badge>
) : player.isReady ? ( ) : player.isReady ? (
<CheckCircle2 className="h-5 w-5 text-green-500" /> <CheckCircle2 className="h-5 w-5 text-success" />
) : ( ) : (
<Circle className="h-5 w-5 text-muted-foreground" /> <Circle className="h-5 w-5 text-muted-foreground" />
)} )}
@@ -15,7 +15,7 @@ export default function PlayersProgress() {
<h3 className="text-sm font-semibold">Players</h3> <h3 className="text-sm font-semibold">Players</h3>
{/* Polite, so a player finishing or forfeiting is announced without {/* Polite, so a player finishing or forfeiting is announced without
interrupting what the user is doing. */} interrupting what the user is doing. */}
<div className="space-y-2" aria-live="polite"> <div className="divide-y divide-border" aria-live="polite">
{activePlayers.map((player) => { {activePlayers.map((player) => {
const chainLength = playerProgress.get(player.userId) ?? 0; const chainLength = playerProgress.get(player.userId) ?? 0;
const isFinished = finishedPlayers.has(player.userId); const isFinished = finishedPlayers.has(player.userId);
@@ -24,13 +24,13 @@ export default function PlayersProgress() {
return ( return (
<div <div
key={player.id} key={player.id}
className="flex items-center justify-between rounded-md border border-border bg-card px-3 py-2" className="flex items-center justify-between py-3"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isForfeited ? ( {isForfeited ? (
<Flag className="h-4 w-4 text-destructive" aria-hidden="true" /> <Flag className="h-4 w-4 text-destructive" aria-hidden="true" />
) : isFinished ? ( ) : isFinished ? (
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" /> <CheckCircle2 className="h-4 w-4 text-success" aria-hidden="true" />
) : player.disconnected ? ( ) : player.disconnected ? (
<WifiOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> <WifiOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
) : ( ) : (
+15 -18
View File
@@ -1,4 +1,6 @@
import { cn } from '@/lib/utils';
import { profileUrl } from '@/lib/tmdb'; import { profileUrl } from '@/lib/tmdb';
import { useImageFallback } from '@/hooks/use-image-fallback';
import type { ActorChainLink } from '@/types'; import type { ActorChainLink } from '@/types';
interface ActorCardProps { interface ActorCardProps {
@@ -7,29 +9,24 @@ interface ActorCardProps {
} }
export default function ActorCard({ actor, compact }: ActorCardProps) { export default function ActorCard({ actor, compact }: ActorCardProps) {
const imgSrc = profileUrl(actor.profilePath, 'w185'); const img = useImageFallback(profileUrl(actor.profilePath, 'w185'), '/placeholder-person.svg');
if (compact) {
return (
<div className="flex items-center gap-2 min-w-0">
<img
src={imgSrc || '/placeholder-person.svg'}
alt={actor.name}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
<p className="text-sm font-medium leading-tight break-words">{actor.name}</p>
</div>
);
}
return ( return (
<div className="flex items-center gap-3 rounded-lg border border-border p-2"> <div
className={cn(
'flex min-w-0 items-center',
compact ? 'gap-2.5' : 'gap-3 rounded-lg bg-card p-2.5 ring-1 ring-foreground/10',
)}
>
<img <img
src={imgSrc || '/placeholder-person.svg'} src={img.src}
onError={img.onError}
alt={actor.name} alt={actor.name}
className="h-12 w-12 shrink-0 rounded-full object-cover" className={cn('shrink-0 rounded-full object-cover', compact ? 'h-12 w-12' : 'h-14 w-14')}
/> />
<p className="truncate text-sm font-medium">{actor.name}</p> <p className={cn('font-medium leading-tight', compact ? 'text-sm break-words' : 'truncate text-base')}>
{actor.name}
</p>
</div> </div>
); );
} }
@@ -1,62 +0,0 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CelebrationOverlay from './CelebrationOverlay';
/**
* Purely decorative confetti. Vestibular-disorder guidance says not to
* animate at all under prefers-reduced-motion, so the overlay must render
* nothing in that case rather than a still frame.
*/
function stubMatchMedia(matches: boolean) {
const mql = {
matches,
media: '(prefers-reduced-motion: reduce)',
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(mql));
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('CelebrationOverlay', () => {
it('renders confetti when the browser has no matchMedia support at all', () => {
// jsdom does not implement matchMedia by default; the component must
// treat that as "no preference" rather than throwing.
const { container } = render(<CelebrationOverlay />);
expect(container.firstElementChild).not.toBeNull();
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
expect(container.firstElementChild?.children).toHaveLength(20);
});
it('renders confetti when the user has not asked to reduce motion', () => {
stubMatchMedia(false);
const { container } = render(<CelebrationOverlay />);
expect(container.firstElementChild?.children).toHaveLength(20);
});
it('renders nothing under prefers-reduced-motion', () => {
stubMatchMedia(true);
const { container } = render(<CelebrationOverlay />);
expect(container).toBeEmptyDOMElement();
});
it('disappears on its own after three seconds', () => {
stubMatchMedia(false);
const { container } = render(<CelebrationOverlay />);
expect(container).not.toBeEmptyDOMElement();
act(() => {
vi.advanceTimersByTime(3000);
});
expect(container).toBeEmptyDOMElement();
});
});
@@ -1,88 +0,0 @@
import { useEffect, useState } from 'react';
const CONFETTI_COLORS = [
'#f44336', '#e91e63', '#9c27b0', '#673ab7',
'#3f51b5', '#2196f3', '#03a9f4', '#00bcd4',
'#4caf50', '#8bc34a', '#ffeb3b', '#ff9800',
];
const CONFETTI_COUNT = 20;
interface ConfettiPiece {
color: string;
left: number;
delay: number;
duration: number;
size: number;
round: boolean;
}
/** Always in range (index < CONFETTI_COLORS.length); the fallback only satisfies the type. */
function randomConfettiColor(): string {
return CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)] ?? '#f44336';
}
function makeConfetti(): ConfettiPiece[] {
return Array.from({ length: CONFETTI_COUNT }, () => ({
color: randomConfettiColor(),
left: Math.random() * 100,
delay: Math.random() * 1,
duration: 2 + Math.random() * 1.5,
size: 6 + Math.random() * 6,
round: Math.random() > 0.5,
}));
}
/**
* Twenty pieces of confetti falling for three seconds. Vestibular-disorder
* guidance is not to animate at all when the user has asked the OS not to, so
* the overlay renders nothing under `prefers-reduced-motion: reduce` rather
* than a still frame of coloured squares. It is purely decorative - the
* completion modal carries the actual news - so nothing is lost.
*/
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
export default function CelebrationOverlay() {
const [visible, setVisible] = useState(true);
const [reduceMotion] = useState(prefersReducedMotion);
// Rolled once per mount. Randomising in the render body re-rolled every piece
// on each re-render, so confetti visibly jumped mid-flight.
const [pieces] = useState(makeConfetti);
useEffect(() => {
const timer = setTimeout(() => setVisible(false), 3000);
return () => clearTimeout(timer);
}, []);
if (!visible || reduceMotion) return null;
return (
<div
className="pointer-events-none fixed inset-0 z-50 overflow-hidden"
aria-hidden="true"
>
{pieces.map((piece, i) => (
<div
key={i}
className="absolute animate-confetti"
style={{
left: `${piece.left}%`,
top: '-10px',
width: `${piece.size}px`,
height: `${piece.size}px`,
backgroundColor: piece.color,
borderRadius: piece.round ? '50%' : '2px',
animationDelay: `${piece.delay}s`,
animationDuration: `${piece.duration}s`,
}}
/>
))}
</div>
);
}
+14 -11
View File
@@ -43,7 +43,7 @@ describe('ChainDisplay in static mode (a finished chain passed as props)', () =>
expect(screen.getByRole('img', { name: 'Movie B' })).toBeInTheDocument(); expect(screen.getByRole('img', { name: 'Movie B' })).toBeInTheDocument();
expect(screen.getByText('Actor 1')).toBeInTheDocument(); expect(screen.getByText('Actor 1')).toBeInTheDocument();
expect(screen.getByText('Actor 2')).toBeInTheDocument(); expect(screen.getByText('Actor 2')).toBeInTheDocument();
expect(screen.getByText('Loop Complete!')).toBeInTheDocument(); expect(screen.getByTestId('loop-return')).toBeInTheDocument();
}); });
it('marks the movie B slot with a B badge and the closing movie A slot with an A badge', () => { it('marks the movie B slot with a B badge and the closing movie A slot with an A badge', () => {
@@ -56,15 +56,17 @@ describe('ChainDisplay in static mode (a finished chain passed as props)', () =>
]; ];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />); render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
// Both badges render as "Movie {A|B}"-labelled single-letter spans. // Both markers render as "Movie {A|B}"-labelled single-letter chips. The
// anchor card at the top of the chain wears an A as well as the closing row.
expect(screen.getAllByText('B')).toHaveLength(1); expect(screen.getAllByText('B')).toHaveLength(1);
expect(screen.getAllByText('A')).toHaveLength(1); expect(screen.getAllByText('A')).toHaveLength(2);
expect(screen.getByTestId('loop-return')).toHaveAttribute('data-state', 'closed');
}); });
it('shows no pending slot or validating spinner for a static chain', () => { it('shows no pending slot or validating spinner for a static chain', () => {
const chain: ChainLink[] = [MOVIE_A, actorLink(1)]; const chain: ChainLink[] = [MOVIE_A, actorLink(1)];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />); render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
expect(screen.queryByText('Validating...')).toBeNull(); expect(screen.queryByText('Checking…')).toBeNull();
expect(screen.queryByText('?')).toBeNull(); expect(screen.queryByText('?')).toBeNull();
}); });
}); });
@@ -86,7 +88,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
}); });
render(<ChainDisplay />); render(<ChainDisplay />);
expect(screen.getByText('?')).toBeInTheDocument(); expect(screen.getByText('?')).toBeInTheDocument();
expect(screen.queryByText('Validating...')).toBeNull(); expect(screen.queryByText('Checking…')).toBeNull();
}); });
it('shows a validating spinner in the pending slot while a movie is being checked', () => { it('shows a validating spinner in the pending slot while a movie is being checked', () => {
@@ -100,7 +102,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
render(<ChainDisplay />); render(<ChainDisplay />);
// Two "Validating..." labels: the pending-movie slot and (since the chain // Two "Validating..." labels: the pending-movie slot and (since the chain
// length here is even, not odd) not the standalone validating row. // length here is even, not odd) not the standalone validating row.
expect(screen.getByText('Validating...')).toBeInTheDocument(); expect(screen.getByText('Checking…')).toBeInTheDocument();
}); });
it('shows the standalone validating row while searching for the next actor', () => { it('shows the standalone validating row while searching for the next actor', () => {
@@ -112,7 +114,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
isValidating: true, isValidating: true,
}); });
render(<ChainDisplay />); render(<ChainDisplay />);
expect(screen.getByText('Validating...')).toBeInTheDocument(); expect(screen.getByText('Checking…')).toBeInTheDocument();
}); });
it('shows the standalone validating row on an odd-length chain beyond the first pair', () => { it('shows the standalone validating row on an odd-length chain beyond the first pair', () => {
@@ -124,7 +126,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
isValidating: true, isValidating: true,
}); });
render(<ChainDisplay />); render(<ChainDisplay />);
expect(screen.getByText('Validating...')).toBeInTheDocument(); expect(screen.getByText('Checking…')).toBeInTheDocument();
}); });
it('does not show the standalone validating row once the chain is complete', () => { it('does not show the standalone validating row once the chain is complete', () => {
@@ -136,8 +138,8 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
isValidating: false, isValidating: false,
}); });
render(<ChainDisplay />); render(<ChainDisplay />);
expect(screen.queryByText('Validating...')).toBeNull(); expect(screen.queryByText('Checking…')).toBeNull();
expect(screen.getByText('Loop Complete!')).toBeInTheDocument(); expect(screen.getByTestId('loop-return')).toHaveAttribute('data-state', 'closed');
}); });
it('badges the movie B link once the chain reaches it', () => { it('badges the movie B link once the chain reaches it', () => {
@@ -162,6 +164,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
isValidating: false, isValidating: false,
}); });
render(<ChainDisplay />); render(<ChainDisplay />);
expect(screen.queryByText('A')).toBeNull(); // Only the anchor card's own marker.
expect(screen.getAllByText('A')).toHaveLength(1);
}); });
}); });
+69 -58
View File
@@ -1,8 +1,11 @@
import { useRef } from 'react';
import { useGameStore } from '@/stores/game-store'; import { useGameStore } from '@/stores/game-store';
import MovieCard from './MovieCard'; import MovieCard from './MovieCard';
import ActorCard from './ActorCard'; import ActorCard from './ActorCard';
import SerpentineConnector from './SerpentineConnector'; import SerpentineConnector from './SerpentineConnector';
import LoopReturn from './LoopReturn';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CONNECTOR_STAGGER_MS, loopReturnDelayMs } from '@/lib/motion';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import type { import type {
ActorChainLink, ActorChainLink,
@@ -73,6 +76,9 @@ interface ChainRowsProps {
live: boolean; live: boolean;
} }
const CONNECTOR_OPEN = 'border-connector';
const CONNECTOR_CLOSED = 'border-connector-closed';
function ChainRows({ function ChainRows({
chain, chain,
movieAId, movieAId,
@@ -81,6 +87,8 @@ function ChainRows({
isValidating, isValidating,
live, live,
}: ChainRowsProps) { }: ChainRowsProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
// chain[0] = MovieA (standalone starting point) // chain[0] = MovieA (standalone starting point)
// chain[1..2] = pair 0 (Actor, Movie) // chain[1..2] = pair 0 (Actor, Movie)
// chain[3..4] = pair 1 (Actor, Movie) // chain[3..4] = pair 1 (Actor, Movie)
@@ -93,6 +101,15 @@ function ChainRows({
} }
const startMovie = chain[0] as MovieChainLink | undefined; const startMovie = chain[0] as MovieChainLink | undefined;
// A loop is closed only when the last pair really lands back on Movie A. A
// still of an abandoned session (an even-length chain, or one that ended
// elsewhere) must not be drawn as one.
const lastPair = pairs[pairs.length - 1];
const closed =
status === 'completed' &&
lastPair !== undefined &&
lastPair.movie !== undefined &&
lastPair.movie.id === movieAId;
// Geometry for w-[85%] offset cards with L-shaped connectors: // Geometry for w-[85%] offset cards with L-shaped connectors:
// Odd pairs (0,2,4) are right-aligned → card spans 15%-100% // Odd pairs (0,2,4) are right-aligned → card spans 15%-100%
@@ -101,12 +118,20 @@ function ChainRows({
// Vertical line at 7.5% (left) or 92.5% (right) runs through the gap // Vertical line at 7.5% (left) or 92.5% (right) runs through the gap
// AND continues into the card row down to its vertical center, where // AND continues into the card row down to its vertical center, where
// a horizontal arm connects to the card edge (15% or 85%). // a horizontal arm connects to the card edge (15% or 85%).
//
// The wrapper's horizontal padding is the return channel: once the loop
// closes, LoopReturn draws a line up the side the last row sits against,
// from that row back to Movie A. The last right-aligned row touches the
// right channel, the last left-aligned row the left one.
const lastRowIsRightAligned = (pairs.length - 1) % 2 === 0;
return ( return (
<div> <div ref={wrapperRef} className="relative px-3">
{/* Starting movie (Movie A) — full width */} {/* Starting movie (Movie A) — full width */}
{startMovie && ( {startMovie && (
<MovieCard movie={startMovie} isTarget="A" /> <div data-loop-anchor="">
<MovieCard movie={startMovie} isTarget="A" />
</div>
)} )}
{/* Chain pairs */} {/* Chain pairs */}
@@ -133,34 +158,38 @@ function ChainRows({
// For the horizontal arm: left% and right% in CSS // For the horizontal arm: left% and right% in CSS
const armLeft = Math.min(lineX, cardEdge); const armLeft = Math.min(lineX, cardEdge);
const armRight = 100 - Math.max(lineX, cardEdge); const armRight = 100 - Math.max(lineX, cardEdge);
const connectorClass = cn(
'transition-colors duration-300',
closed ? CONNECTOR_CLOSED : CONNECTOR_OPEN,
);
const connectorDelay = { transitionDelay: `${pairIndex * CONNECTOR_STAGGER_MS}ms` };
return ( return (
<div key={pairIndex}> <div key={pairIndex}>
{/* Vertical line through the gap */} {/* Vertical line through the gap */}
<SerpentineConnector lineX={lineX} /> <SerpentineConnector lineX={lineX} closed={closed} index={pairIndex} />
{/* Pair row — 85% width, alternating alignment */} {/* Pair row — 85% width, alternating alignment */}
<div className={cn('relative flex', isOdd ? 'justify-end' : 'justify-start')}> <div className={cn('relative flex', isOdd ? 'justify-end' : 'justify-start')}>
{/* Vertical continuation from top of row to vertical center */} {/* Vertical continuation from top of row to vertical center */}
<div <div
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30" className={cn('absolute top-0 h-1/2 w-0 border-l-2', connectorClass)}
style={{ left: `${lineX}%` }} style={{ left: `${lineX}%`, ...connectorDelay }}
/> />
{/* Horizontal arm at vertical center to card edge */} {/* Horizontal arm at vertical center to card edge */}
<div <div
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30" className={cn('absolute top-1/2 h-0 border-t-2', connectorClass)}
style={{ left: `${armLeft}%`, right: `${armRight}%` }} style={{ left: `${armLeft}%`, right: `${armRight}%`, ...connectorDelay }}
/> />
<div <div
data-loop-end={isLastPair && closed ? '' : undefined}
className={cn( className={cn(
'flex w-[85%] items-center overflow-hidden rounded-lg border p-2.5', 'flex w-[85%] items-center overflow-hidden rounded-md bg-card p-2.5 ring-1',
movieTarget === 'B' && 'border-green-500 bg-green-500/5', isLastPair && closed ? 'ring-primary' : 'ring-foreground/10',
movieTarget === 'A' && 'border-primary bg-primary/5',
!movieTarget && 'border-border bg-card',
live && [ live && [
isLastPair && !isComplete && 'animate-slide-in', isLastPair && !isComplete && 'animate-slide-in',
isLastPair && isComplete && status !== 'completed' && 'animate-slide-in', isLastPair && isComplete && status !== 'completed' && 'animate-slide-in',
isLastPair && status === 'completed' && 'animate-glow-pulse', isLastPair && closed && 'animate-glow-pulse',
], ],
)} )}
> >
@@ -173,10 +202,7 @@ function ChainRows({
<Dot muted={live && !isComplete} /> <Dot muted={live && !isComplete} />
<div className="flex min-w-0 flex-1 items-center justify-center"> <div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie ? ( {pair.movie ? (
<div className="flex items-center gap-1.5"> <MovieCard movie={pair.movie} isTarget={movieTarget} compact />
<MovieCard movie={pair.movie} compact />
{movieTarget && <TargetBadge target={movieTarget} />}
</div>
) : ( ) : (
live && <PendingSlot loading={isValidating && status === 'playing'} /> live && <PendingSlot loading={isValidating && status === 'playing'} />
)} )}
@@ -187,10 +213,7 @@ function ChainRows({
{/* Even pairs: [Movie | dot | Actor] (reversed) */} {/* Even pairs: [Movie | dot | Actor] (reversed) */}
<div className="flex min-w-0 flex-1 items-center justify-center"> <div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie ? ( {pair.movie ? (
<div className="flex items-center gap-1.5"> <MovieCard movie={pair.movie} isTarget={movieTarget} compact />
{movieTarget && <TargetBadge target={movieTarget} />}
<MovieCard movie={pair.movie} compact />
</div>
) : ( ) : (
live && <PendingSlot loading={isValidating && status === 'playing'} /> live && <PendingSlot loading={isValidating && status === 'playing'} />
)} )}
@@ -221,32 +244,32 @@ function ChainRows({
> >
{/* Vertical continuation + horizontal arm for validating row */} {/* Vertical continuation + horizontal arm for validating row */}
<div <div
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30" className={cn('absolute top-0 h-1/2 w-0 border-l-2', CONNECTOR_OPEN)}
style={{ left: `${pairs.length % 2 === 0 ? 7.5 : 92.5}%` }} style={{ left: `${pairs.length % 2 === 0 ? 7.5 : 92.5}%` }}
/> />
<div <div
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30" className={cn('absolute top-1/2 h-0 border-t-2', CONNECTOR_OPEN)}
style={{ style={{
left: `${pairs.length % 2 === 0 ? 7.5 : 85}%`, left: `${pairs.length % 2 === 0 ? 7.5 : 85}%`,
right: `${pairs.length % 2 === 0 ? 85 : 7.5}%`, right: `${pairs.length % 2 === 0 ? 85 : 7.5}%`,
}} }}
/> />
<div className="flex w-[85%] items-center gap-3 rounded-lg border border-dashed border-border p-2.5"> <div className="flex w-[85%] items-center gap-3 rounded-md border border-dashed border-connector p-2.5">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-hidden="true" />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">Checking…</span>
Validating...
</span>
</div> </div>
</div> </div>
</div> </div>
)} )}
{status === 'completed' && ( {closed && (
<div className="flex justify-center py-3"> <LoopReturn
<div className="rounded-full bg-green-500/10 px-4 py-1.5 text-sm font-medium text-green-600"> containerRef={wrapperRef}
Loop Complete! side={lastRowIsRightAligned ? 'right' : 'left'}
</div> animate={live}
</div> delayMs={loopReturnDelayMs(pairs.length)}
measureKey={chain.length}
/>
)} )}
</div> </div>
); );
@@ -257,38 +280,26 @@ function Dot({ muted }: { muted?: boolean }) {
<div <div
className={cn( className={cn(
'mx-2 h-1.5 w-1.5 shrink-0 rounded-full', 'mx-2 h-1.5 w-1.5 shrink-0 rounded-full',
muted ? 'bg-muted-foreground/20' : 'bg-muted-foreground/40', muted ? 'bg-connector/50' : 'bg-connector',
)} )}
/> />
); );
} }
/** The poster-shaped slot the next movie will fill. */
function PendingSlot({ loading }: { loading?: boolean }) { function PendingSlot({ loading }: { loading?: boolean }) {
return ( return (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2.5 text-muted-foreground">
{loading ? ( <div className="flex h-18 w-12 shrink-0 items-center justify-center rounded-sm border border-dashed border-connector">
<Loader2 className="h-4 w-4 animate-spin" /> {loading ? (
) : ( <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<div className="h-10 w-10 shrink-0 rounded border border-dashed border-muted-foreground/30" /> ) : (
)} <span className="font-display text-lg font-semibold" aria-hidden="true">
<span className="text-xs"> ?
{loading ? 'Validating...' : '?'} </span>
</span> )}
</div>
<span className="text-xs">{loading ? 'Checking…' : 'Next movie'}</span>
</div> </div>
); );
} }
function TargetBadge({ target }: { target: 'A' | 'B' }) {
return (
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
target === 'A'
? 'bg-blue-500/10 text-blue-600'
: 'bg-green-500/10 text-green-600',
)}
>
{target}
</span>
);
}
@@ -6,7 +6,7 @@ import type { ChainLink, MovieChainLink, ScoreBreakdown } from '@/types';
/** /**
* GameCompletionModal is a store-driven view over the completed game: the * GameCompletionModal is a store-driven view over the completed game: the
* "calculating score" spinner, a scoring error, or the final score + share * "calculating score" spinner, a scoring error, or the final score + share
* buttons, with Play Again / View Chain controls. Sound and navigation are * buttons, with Play again / View Chain controls. Sound and navigation are
* mocked; everything else is driven by seeding the real game store per WP-19 * mocked; everything else is driven by seeding the real game store per WP-19
* conventions. * conventions.
*/ */
@@ -16,6 +16,14 @@ const navigateMock = vi.fn();
vi.mock('@/lib/sounds', () => ({ vi.mock('@/lib/sounds', () => ({
playSound: (...args: unknown[]) => playSound(...args), playSound: (...args: unknown[]) => playSound(...args),
})); }));
// The score waits for the loop-closing animation unless the OS asks for
// reduced motion. These tests are about the modal's content, so they run
// without the wait; the delay itself has its own test below.
const prefersReducedMotion = vi.fn(() => true);
vi.mock('@/lib/motion', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/motion')>()),
prefersReducedMotion: () => prefersReducedMotion(),
}));
vi.mock('react-router', async (importOriginal) => { vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>(); const actual = await importOriginal<typeof import('react-router')>();
return { ...actual, useNavigate: () => navigateMock }; return { ...actual, useNavigate: () => navigateMock };
@@ -24,6 +32,7 @@ vi.mock('react-router', async (importOriginal) => {
const { useGameStore } = await import('@/stores/game-store'); const { useGameStore } = await import('@/stores/game-store');
const { MemoryRouter } = await import('react-router'); const { MemoryRouter } = await import('react-router');
const { default: GameCompletionModal } = await import('./GameCompletionModal'); const { default: GameCompletionModal } = await import('./GameCompletionModal');
const { loopCloseDurationMs } = await import('@/lib/motion');
const MOVIE_A: MovieChainLink = { const MOVIE_A: MovieChainLink = {
type: 'movie', type: 'movie',
@@ -92,8 +101,8 @@ describe('GameCompletionModal', () => {
validationError: null, validationError: null,
}); });
renderModal(); renderModal();
expect(screen.getByRole('dialog', { name: 'Congratulations!' })).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: 'Loop closed.' })).toBeInTheDocument();
expect(screen.getByText('Calculating score...')).toBeInTheDocument(); expect(screen.getByText('Calculating score…')).toBeInTheDocument();
}); });
it('shows a scoring error instead of a spinner when scoring failed', () => { it('shows a scoring error instead of a spinner when scoring failed', () => {
@@ -107,9 +116,9 @@ describe('GameCompletionModal', () => {
}); });
renderModal(); renderModal();
expect(screen.getByText('Failed to calculate score.')).toBeInTheDocument(); expect(screen.getByText('Failed to calculate score.')).toBeInTheDocument();
expect(screen.queryByText('Calculating score...')).toBeNull(); expect(screen.queryByText('Calculating score…')).toBeNull();
// No score means no share buttons. // No score means no share buttons.
expect(screen.queryByRole('button', { name: /Copy Result/ })).toBeNull(); expect(screen.queryByRole('button', { name: /Copy result/ })).toBeNull();
}); });
it('shows the score, elapsed time and share controls once scored', () => { it('shows the score, elapsed time and share controls once scored', () => {
@@ -123,11 +132,11 @@ describe('GameCompletionModal', () => {
}); });
renderModal(); renderModal();
expect(screen.getByText('2400')).toBeInTheDocument(); expect(screen.getByText('2,400')).toBeInTheDocument();
expect(screen.getByText('Completed in 2:05')).toBeInTheDocument(); expect(screen.getByText('2:05, no hints')).toBeInTheDocument();
expect(screen.queryByText(/hints? used/)).toBeNull(); expect(screen.queryByText(/\d hints?$/)).toBeNull();
expect( expect(
screen.getByRole('button', { name: /Copy Result/ }), screen.getByRole('button', { name: /Copy result/ }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -141,7 +150,7 @@ describe('GameCompletionModal', () => {
hintsUsed: 1, hintsUsed: 1,
}); });
const { rerender } = renderModal(); const { rerender } = renderModal();
expect(screen.getByText('1 hint used')).toBeInTheDocument(); expect(screen.getByText('2:05, 1 hint')).toBeInTheDocument();
useGameStore.setState({ hintsUsed: 2 }); useGameStore.setState({ hintsUsed: 2 });
rerender( rerender(
@@ -149,7 +158,7 @@ describe('GameCompletionModal', () => {
<GameCompletionModal /> <GameCompletionModal />
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.getByText('2 hints used')).toBeInTheDocument(); expect(screen.getByText('2:05, 2 hints')).toBeInTheDocument();
}); });
it('plays the completion sound exactly once per completion, not on every re-render', () => { it('plays the completion sound exactly once per completion, not on every re-render', () => {
@@ -221,7 +230,7 @@ describe('GameCompletionModal', () => {
renderModal(); renderModal();
act(() => { act(() => {
screen.getByRole('button', { name: 'View Chain' }).click(); screen.getByRole('button', { name: 'See the loop' }).click();
}); });
expect(screen.queryByRole('dialog')).toBeNull(); expect(screen.queryByRole('dialog')).toBeNull();
@@ -247,7 +256,7 @@ describe('GameCompletionModal', () => {
expect(useGameStore.getState().status).toBe('completed'); expect(useGameStore.getState().status).toBe('completed');
}); });
it('resets the game and returns home on Play Again', () => { it('resets the game and returns home on Play again', () => {
useGameStore.setState({ useGameStore.setState({
status: 'completed', status: 'completed',
movieA: MOVIE_A, movieA: MOVIE_A,
@@ -258,7 +267,7 @@ describe('GameCompletionModal', () => {
renderModal(); renderModal();
act(() => { act(() => {
screen.getByRole('button', { name: 'Play Again' }).click(); screen.getByRole('button', { name: 'Play again' }).click();
}); });
expect(useGameStore.getState().status).toBe('idle'); expect(useGameStore.getState().status).toBe('idle');
@@ -275,7 +284,7 @@ describe('GameCompletionModal', () => {
score: SCORE, score: SCORE,
}); });
renderModal(); renderModal();
await user.click(screen.getByRole('button', { name: 'View Chain' })); await user.click(screen.getByRole('button', { name: 'See the loop' }));
expect(screen.queryByRole('dialog')).toBeNull(); expect(screen.queryByRole('dialog')).toBeNull();
act(() => { act(() => {
@@ -289,3 +298,46 @@ describe('GameCompletionModal', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
}); });
}); });
describe('GameCompletionModal reveal delay', () => {
it('waits for the loop to draw closed before showing the score, unless motion is reduced', () => {
prefersReducedMotion.mockReturnValue(false);
vi.useFakeTimers();
try {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
expect(screen.queryByRole('dialog')).toBeNull();
act(() => {
vi.advanceTimersByTime(loopCloseDurationMs(2) - 1);
});
expect(screen.queryByRole('dialog')).toBeNull();
act(() => {
vi.advanceTimersByTime(1);
});
expect(screen.getByRole('dialog', { name: 'Loop closed.' })).toBeInTheDocument();
} finally {
vi.useRealTimers();
prefersReducedMotion.mockReturnValue(true);
}
});
it('describes the loop in the dialog', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
expect(screen.getByText('Movie A to Movie B and back in 2 links.')).toBeInTheDocument();
});
});
+62 -62
View File
@@ -7,14 +7,16 @@ import {
DialogDescription, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import LogoMark from '@/components/layout/LogoMark';
import { useGameStore } from '@/stores/game-store'; import { useGameStore } from '@/stores/game-store';
import { getLinkCount } from '@/types';
import ScoreDisplay from './ScoreDisplay'; import ScoreDisplay from './ScoreDisplay';
import ShareableResult from './ShareableResult'; import ShareableResult from './ShareableResult';
import type { GameMode } from './ShareableResult'; import type { GameMode } from './ShareableResult';
import CelebrationOverlay from './CelebrationOverlay';
import { playSound } from '@/lib/sounds'; import { playSound } from '@/lib/sounds';
import { storage } from '@/lib/storage'; import { storage } from '@/lib/storage';
import { formatTime } from '@/lib/format'; import { formatTime } from '@/lib/format';
import { useLoopReveal } from '@/hooks/use-loop-reveal';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
@@ -34,14 +36,17 @@ export default function GameCompletionModal() {
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const [prevStatus, setPrevStatus] = useState(status); const [prevStatus, setPrevStatus] = useState(status);
const celebrated = useRef(false); const celebrated = useRef(false);
const links = getLinkCount(chain);
// The loop gets the screen to itself before the score covers it.
const revealed = useLoopReveal(status, links);
// Derived from status rather than mirrored into state by an effect, with // Derived from status rather than mirrored into state by an effect, with
// dismissal reset as soon as the game leaves the completed state. // dismissal reset as soon as the game leaves the completed state.
if (status !== prevStatus) { if (status !== prevStatus) {
setPrevStatus(status); setPrevStatus(status);
if (status !== 'completed') setDismissed(false); setDismissed(false);
} }
const showModal = status === 'completed' && !dismissed; const showModal = status === 'completed' && revealed && !dismissed;
const gameMode: GameMode = isDailyChallenge && dailyChallengeDate const gameMode: GameMode = isDailyChallenge && dailyChallengeDate
? { type: 'daily', date: dailyChallengeDate } ? { type: 'daily', date: dailyChallengeDate }
@@ -75,68 +80,63 @@ export default function GameCompletionModal() {
}; };
return ( return (
<> <Dialog open={showModal} onOpenChange={(open) => !open && setDismissed(true)}>
{status === 'completed' && <CelebrationOverlay />} <DialogContent className="sm:max-w-md" showCloseButton={false}>
<Dialog open={showModal} onOpenChange={(open) => !open && setDismissed(true)}> <DialogHeader className="items-center text-center">
<DialogContent <DialogTitle className="flex items-center justify-center gap-2 font-display text-2xl font-semibold">
className="sm:max-w-md" <LogoMark size={24} className="text-primary" />
showCloseButton={false} Loop closed.
> </DialogTitle>
<DialogHeader> {movieA && movieB && (
<DialogTitle className="text-center text-2xl">
Congratulations!
</DialogTitle>
<DialogDescription className="text-center"> <DialogDescription className="text-center">
You completed the movie loop! {movieA.title} to {movieB.title} and back in {links} link{links === 1 ? '' : 's'}.
</DialogDescription> </DialogDescription>
</DialogHeader> )}
<div className="py-4"> </DialogHeader>
{!score && !validationError && ( <div className="py-2">
<div className="flex flex-col items-center gap-2 py-4"> {!score && !validationError && (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <div className="flex flex-col items-center gap-2 py-4">
<span className="text-sm text-muted-foreground">Calculating score...</span> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
</div> <span className="text-sm text-muted-foreground">Calculating score…</span>
)}
{validationError && (
<div className="text-center text-sm text-destructive py-4">
{validationError}
</div>
)}
{score && (
<>
<ScoreDisplay score={score} />
<div className="mt-3 flex justify-center gap-4 text-sm text-muted-foreground">
<span>Completed in {formatTime(score.elapsedSeconds)}</span>
{hintsUsed > 0 && (
<span>{hintsUsed} hint{hintsUsed > 1 ? 's' : ''} used</span>
)}
</div>
</>
)}
</div>
<div className="flex flex-col items-center gap-3">
{score && movieA && movieB && (
<ShareableResult
score={score}
chain={chain}
movieATitle={movieA.title}
movieBTitle={movieB.title}
movieAReleaseDate={movieA.releaseDate}
movieBReleaseDate={movieB.releaseDate}
mode={gameMode}
/>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={handleViewChain}>
View Chain
</Button>
<Button onClick={handlePlayAgain} size="lg">
Play Again
</Button>
</div> </div>
)}
{validationError && (
<div className="py-4 text-center text-sm text-destructive">
{validationError}
</div>
)}
{score && (
<>
<ScoreDisplay score={score} />
<p className="mt-4 text-center text-sm text-muted-foreground">
{formatTime(score.elapsedSeconds)},{' '}
{hintsUsed > 0 ? `${hintsUsed} hint${hintsUsed > 1 ? 's' : ''}` : 'no hints'}
</p>
</>
)}
</div>
<div className="flex flex-col items-center gap-4">
<div className="flex gap-3">
<Button onClick={handlePlayAgain} size="lg">
Play again
</Button>
<Button variant="outline" size="lg" onClick={handleViewChain}>
See the loop
</Button>
</div> </div>
</DialogContent> {score && movieA && movieB && (
</Dialog> <ShareableResult
</> score={score}
chain={chain}
movieATitle={movieA.title}
movieBTitle={movieB.title}
movieAReleaseDate={movieA.releaseDate}
movieBReleaseDate={movieB.releaseDate}
mode={gameMode}
/>
)}
</div>
</DialogContent>
</Dialog>
); );
} }
+22 -18
View File
@@ -39,43 +39,47 @@ describe('GameHeader', () => {
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it('shows both target movies and the chain link count', () => { it('states the puzzle and the chain link count', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA, actor, movieB, actor] }); useGameStore.setState({ movieA, movieB, chain: [movieA, actor, movieB, actor] });
render(<GameHeader />); render(<GameHeader />);
expect(screen.getByRole('img', { name: 'Movie A' })).toBeInTheDocument(); expect(screen.getByText('Movie A')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Movie B' })).toBeInTheDocument(); expect(screen.getByText('Movie B')).toBeInTheDocument();
// getLinkCount([movieA, actor, movieB, actor]) = floor((4-1)/2) = 1 // getLinkCount([movieA, actor, movieB, actor]) = floor((4-1)/2) = 1
expect(screen.getByText('Chain: 1 links')).toBeInTheDocument(); expect(screen.getByText('1 link')).toBeInTheDocument();
}); });
it('shows the custom game badge only for a custom game', () => { it('pluralises the link count', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA] });
render(<GameHeader />);
expect(screen.getByText('0 links')).toBeInTheDocument();
});
it('shows the custom badge only for a custom game', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], isCustomGame: true }); useGameStore.setState({ movieA, movieB, chain: [movieA], isCustomGame: true });
const { rerender } = render(<GameHeader />); const { rerender } = render(<GameHeader />);
expect(screen.getByText('Custom Game')).toBeInTheDocument(); expect(screen.getByText('Custom')).toBeInTheDocument();
useGameStore.setState({ isCustomGame: false }); useGameStore.setState({ isCustomGame: false });
rerender(<GameHeader />); rerender(<GameHeader />);
expect(screen.queryByText('Custom Game')).toBeNull(); expect(screen.queryByText('Custom')).toBeNull();
}); });
it('announces that Movie B has not yet been reached', () => { it('says the next objective is to reach Movie B until it has been reached', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: false }); useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: false });
render(<GameHeader />); render(<GameHeader />);
expect(screen.getByRole('status')).toHaveTextContent('Next: reach Movie B');
// The visible label reads "Passed Movie B" in both states; the sr-only
// text is what actually distinguishes them for a screen reader.
const status = screen.getByRole('status');
expect(status).toHaveTextContent('Passed Movie B');
expect(screen.getByText('Movie B not yet reached')).toBeInTheDocument();
expect(screen.queryByText('Movie B reached')).toBeNull();
}); });
it('announces that Movie B has been reached', () => { it('switches the objective to closing the loop once Movie B is reached', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: true }); useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: true });
render(<GameHeader />); render(<GameHeader />);
expect(screen.getByRole('status')).toHaveTextContent('Next: back to Movie A');
});
expect(screen.getByText('Movie B reached')).toBeInTheDocument(); it('reports the loop closed once the game is complete', () => {
expect(screen.queryByText('Movie B not yet reached')).toBeNull(); useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: true, status: 'completed' });
render(<GameHeader />);
expect(screen.getByRole('status')).toHaveTextContent('Loop closed');
}); });
}); });
+37 -36
View File
@@ -1,56 +1,57 @@
import { useGameStore } from '@/stores/game-store'; import { useGameStore } from '@/stores/game-store';
import { getLinkCount } from '@/types'; import { getLinkCount } from '@/types';
import MovieCard from './MovieCard';
import Timer from './Timer'; import Timer from './Timer';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { CheckCircle2, Circle } from 'lucide-react';
/**
* The puzzle statement and one line of state. The two movies used to be
* repeated here as full cards above a chain that begins with Movie A anyway;
* now the header says the puzzle in a sentence and the chain shows it.
*/
export default function GameHeader() { export default function GameHeader() {
const movieA = useGameStore((s) => s.movieA); const movieA = useGameStore((s) => s.movieA);
const movieB = useGameStore((s) => s.movieB); const movieB = useGameStore((s) => s.movieB);
const chain = useGameStore((s) => s.chain); const chain = useGameStore((s) => s.chain);
const status = useGameStore((s) => s.status);
const passedMovieB = useGameStore((s) => s.passedMovieB); const passedMovieB = useGameStore((s) => s.passedMovieB);
const isCustomGame = useGameStore((s) => s.isCustomGame); const isCustomGame = useGameStore((s) => s.isCustomGame);
if (!movieA || !movieB) return null; if (!movieA || !movieB) return null;
const links = getLinkCount(chain);
const objective =
status === 'completed'
? 'Loop closed'
: passedMovieB
? `Next: back to ${movieA.title}`
: `Next: reach ${movieB.title}`;
return ( return (
<div className="space-y-3"> <div className="space-y-2 px-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <p className="font-display text-xl font-semibold leading-tight">
<MovieCard movie={movieA} isTarget="A" /> <span className="text-movie-a">{movieA.title}</span>
<MovieCard movie={movieB} isTarget="B" /> <span className="text-muted-foreground"> to </span>
</div> <span className="text-movie-b">{movieB.title}</span>
<Separator /> <span className="text-muted-foreground"> and back.</span>
<div className="flex flex-wrap items-center gap-3"> </p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm">
<Timer />
<span className="text-muted-foreground">
{links} link{links === 1 ? '' : 's'}
</span>
{/* The objective changes verb as the game moves on, so the visible
text and the announced text are the same thing. */}
<span
role="status"
className={passedMovieB ? 'font-medium text-primary-text' : 'text-muted-foreground'}
>
{objective}
</span>
{isCustomGame && ( {isCustomGame && (
<Badge variant="secondary">Custom Game</Badge> <Badge variant="outline" className="ml-auto">
Custom
</Badge>
)} )}
<Badge variant="outline">Chain: {getLinkCount(chain)} links</Badge>
<div role="status" className="flex items-center gap-1.5 text-sm">
{passedMovieB ? (
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{/* The visible label reads "Passed Movie B" in both states; only the
icon and colour said which. A screen reader therefore heard the
wrong thing before B was reached. */}
<span
aria-hidden="true"
className={
passedMovieB ? 'text-green-600' : 'text-muted-foreground'
}
>
Passed Movie B
</span>
<span className="sr-only">
{passedMovieB ? 'Movie B reached' : 'Movie B not yet reached'}
</span>
</div>
<div className="ml-auto">
<Timer />
</div>
</div> </div>
</div> </div>
); );
+3 -3
View File
@@ -102,7 +102,7 @@ function baseScreen(overrides: Partial<ReadyGameScreen> = {}): ReadyGameScreen {
validateAndAddActor: vi.fn(), validateAndAddActor: vi.fn(),
validateAndAddMovie: vi.fn(), validateAndAddMovie: vi.fn(),
searchLabel: 'Who was in "Movie A"?', searchLabel: 'Who was in "Movie A"?',
searchPlaceholder: 'Search for an actor...', searchPlaceholder: 'Search for an actor…',
showLoopHint: false, showLoopHint: false,
movieA, movieA,
movieB, movieB,
@@ -116,7 +116,7 @@ describe('GameSearchPanel', () => {
expect(screen.getByText('Who was in "Movie A"?')).toBeInTheDocument(); expect(screen.getByText('Who was in "Movie A"?')).toBeInTheDocument();
expect(screen.getByText('mode:actor')).toBeInTheDocument(); expect(screen.getByText('mode:actor')).toBeInTheDocument();
expect( expect(
screen.getByText('placeholder:Search for an actor...'), screen.getByText('placeholder:Search for an actor…'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText('disabled:false')).toBeInTheDocument(); expect(screen.getByText('disabled:false')).toBeInTheDocument();
}); });
@@ -207,7 +207,7 @@ describe('GameSearchPanel', () => {
rerender(<GameSearchPanel screen={baseScreen({ showLoopHint: true })} />); rerender(<GameSearchPanel screen={baseScreen({ showLoopHint: true })} />);
expect( expect(
screen.getByText('Select "Movie A" to close the loop!'), screen.getByText('Pick Movie A to close the loop.'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
+31 -36
View File
@@ -3,7 +3,6 @@ import SearchAutocomplete from './SearchAutocomplete';
import ValidationFeedback from './ValidationFeedback'; import ValidationFeedback from './ValidationFeedback';
import HintButton from './HintButton'; import HintButton from './HintButton';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Undo2 } from 'lucide-react'; import { Undo2 } from 'lucide-react';
import type { ReadyGameScreen } from '@/hooks/use-game-screen'; import type { ReadyGameScreen } from '@/hooks/use-game-screen';
@@ -33,43 +32,39 @@ export default function GameSearchPanel({ screen, children }: GameSearchPanelPro
} = screen; } = screen;
return ( return (
<div className="space-y-3"> <div className="space-y-3 border-t border-border px-3 pt-5">
<Separator /> <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
<label className="text-lg font-medium">{searchLabel}</label>
<div className="space-y-2"> <div className="flex items-center gap-2">
<div className="flex items-center justify-between"> <HintButton />
<label className="text-sm font-medium">{searchLabel}</label> <Button
<div className="flex items-center gap-2"> variant="ghost"
<HintButton /> size="sm"
<Button onClick={undoLastLink}
variant="ghost" disabled={chain.length <= 1 || isValidating}
size="sm" >
onClick={undoLastLink} <Undo2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
disabled={chain.length <= 1 || isValidating} Undo
> </Button>
<Undo2 className="mr-1 h-3.5 w-3.5" /> {children}
Undo
</Button>
{children}
</div>
</div> </div>
<SearchAutocomplete
mode={currentSearchMode}
onSelectActor={validateAndAddActor}
onSelectMovie={validateAndAddMovie}
disabled={isValidating}
placeholder={searchPlaceholder}
/>
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm text-green-600">
Select &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div> </div>
<SearchAutocomplete
mode={currentSearchMode}
onSelectActor={validateAndAddActor}
onSelectMovie={validateAndAddMovie}
disabled={isValidating}
placeholder={searchPlaceholder}
/>
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm font-medium text-primary-text">
Pick {movieA.title} to close the loop.
</p>
)}
</div> </div>
); );
} }
+1 -1
View File
@@ -89,7 +89,7 @@ describe('HintButton', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
// The server charges 200 per hint (score.service.ts); the label used to // The server charges 200 per hint (score.service.ts); the label used to
// say 150, the duplicate penalty's figure. Seen live in WP-19's smoke run. // say 150, the duplicate penalty's figure. Seen live in WP-19's smoke run.
expect(screen.getByText('-200 pts')).toBeInTheDocument(); expect(screen.getByText('−200')).toBeInTheDocument();
}); });
it('is disabled when a hint cannot be taken (no session)', () => { it('is disabled when a hint cannot be taken (no session)', () => {
+3 -3
View File
@@ -41,7 +41,7 @@ export default function HintButton() {
<Lightbulb className="mr-1 h-3.5 w-3.5" /> <Lightbulb className="mr-1 h-3.5 w-3.5" />
)} )}
Hint ({remaining} left) Hint ({remaining} left)
<span className="ml-1 text-xs text-red-500">-200 pts</span> <span className="ml-1 text-xs text-muted-foreground">−200</span>
</Button> </Button>
{hints.length > 0 && ( {hints.length > 0 && (
@@ -50,7 +50,7 @@ export default function HintButton() {
<button <button
key={member.id} key={member.id}
type="button" type="button"
className="flex items-center gap-2 rounded-lg border border-border px-2 py-1.5 text-left transition-colors hover:bg-accent" className="flex items-center gap-2 rounded-md bg-card px-2 py-1.5 text-left ring-1 ring-foreground/10 transition-colors hover:bg-accent"
onClick={() => handleSelectHint(member)} onClick={() => handleSelectHint(member)}
> >
<img <img
@@ -61,7 +61,7 @@ export default function HintButton() {
alt={member.name} alt={member.name}
className="h-8 w-8 shrink-0 rounded-full object-cover" className="h-8 w-8 shrink-0 rounded-full object-cover"
/> />
<span className="text-xs font-medium">{member.name}</span> <span className="text-sm font-medium">{member.name}</span>
</button> </button>
))} ))}
</div> </div>
+113
View File
@@ -0,0 +1,113 @@
import { useRef } from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import LoopReturn from './LoopReturn';
/**
* The line that closes the loop. It measures the live layout of two marker
* elements inside its container via a passive effect; jsdom reports zeros for
* getBoundingClientRect, so these tests can only assert that it renders (or
* doesn't) and how it's classed - never where the numbers land.
*/
interface HarnessProps {
delayMs?: number;
side: 'left' | 'right';
animate: boolean;
measureKey: number;
showAnchor?: boolean;
showEnd?: boolean;
}
function Harness({ side, animate, measureKey, delayMs, showAnchor = true, showEnd = true }: HarnessProps) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref}>
{showAnchor && <div data-loop-anchor="" />}
{showEnd && <div data-loop-end="" />}
<LoopReturn containerRef={ref} side={side} animate={animate} delayMs={delayMs} measureKey={measureKey} />
</div>
);
}
describe('LoopReturn', () => {
it('renders closed when both markers are present', () => {
render(<Harness side="left" animate={false} measureKey={0} />);
const el = screen.getByTestId('loop-return');
expect(el).toHaveAttribute('data-state', 'closed');
});
it('renders nothing when the anchor marker is missing', () => {
render(<Harness side="left" animate={false} measureKey={0} showAnchor={false} />);
expect(screen.queryByTestId('loop-return')).toBeNull();
});
it('renders nothing when the end marker is missing', () => {
render(<Harness side="left" animate={false} measureKey={0} showEnd={false} />);
expect(screen.queryByTestId('loop-return')).toBeNull();
});
it('positions itself in the left channel', () => {
render(<Harness side="left" animate={false} measureKey={0} />);
expect(screen.getByTestId('loop-return').className).toContain('left-[5px]');
});
it('positions itself in the right channel', () => {
render(<Harness side="right" animate={false} measureKey={0} />);
expect(screen.getByTestId('loop-return').className).toContain('right-[5px]');
});
it('draws the line in when animate is set', () => {
render(<Harness side="left" animate delayMs={240} measureKey={0} />);
const line = screen.getByTestId('loop-return').children[0] as HTMLElement;
expect(line.className).toContain('animate-loop-draw');
expect(line.style.animationDelay).toBe('240ms');
});
it('shows the line fully drawn, with no delay, when animate is false', () => {
render(<Harness side="left" animate={false} measureKey={0} />);
const line = screen.getByTestId('loop-return').children[0] as HTMLElement;
expect(line.className).not.toContain('animate-loop-draw');
expect(line.style.animationDelay).toBe('');
});
it('re-measures when measureKey changes, disappearing once a marker is removed', () => {
const { rerender } = render(<Harness side="left" animate={false} measureKey={0} />);
expect(screen.getByTestId('loop-return')).toBeInTheDocument();
// Same measureKey: no re-measure, so the (now stale) geometry is kept.
rerender(<Harness side="left" animate={false} measureKey={0} showEnd={false} />);
expect(screen.getByTestId('loop-return')).toBeInTheDocument();
// Bumping measureKey forces the effect to re-run and notice the missing end.
rerender(<Harness side="left" animate={false} measureKey={1} showEnd={false} />);
expect(screen.queryByTestId('loop-return')).toBeNull();
});
describe('with ResizeObserver available', () => {
class FakeResizeObserver {
static instances: FakeResizeObserver[] = [];
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
constructor() {
FakeResizeObserver.instances.push(this);
}
}
it('observes the container and disconnects on unmount', () => {
FakeResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
const { unmount } = render(<Harness side="left" animate={false} measureKey={0} />);
expect(FakeResizeObserver.instances).toHaveLength(1);
expect(FakeResizeObserver.instances[0]?.observe).toHaveBeenCalledTimes(1);
unmount();
expect(FakeResizeObserver.instances[0]?.disconnect).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
});
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useState, type RefObject } from 'react';
import { cn } from '@/lib/utils';
interface LoopReturnProps {
/** The chain wrapper; the anchor and end rows are found inside it by data attribute. */
containerRef: RefObject<HTMLElement | null>;
/** Which outer channel the line runs in - the side the last row sits against. */
side: 'left' | 'right';
/** Draw the line in rather than showing it fully drawn. */
animate: boolean;
/** How long to wait before drawing, so the connector segments light first. */
delayMs?: number;
/** Anything that changes the rows' positions; re-measures when it changes. */
measureKey: number;
}
/**
* The line that closes the loop: from the last row, up the outer channel of
* the chain, back to Movie A. It is the one thing in the app that is allowed
* to be bold - the chain's other connectors just turn gold, this one draws
* itself. In a finished-game still it is simply present.
*
* Positions come from the live layout (getBoundingClientRect, relative to
* the wrapper) so it survives image loads and window resizes; jsdom reports
* zeros, so tests can only assert that it rendered, not where.
*/
export default function LoopReturn({
containerRef,
side,
animate,
delayMs = 0,
measureKey,
}: LoopReturnProps) {
const [geometry, setGeometry] = useState<{ top: number; height: number } | null>(null);
// A passive effect, not a layout effect: React attaches refs child-first, so
// at layout time the wrapper's ref is still null.
useEffect(() => {
const wrapper = containerRef.current;
if (!wrapper) return;
const measure = () => {
const anchor = wrapper.querySelector<HTMLElement>('[data-loop-anchor]');
const end = wrapper.querySelector<HTMLElement>('[data-loop-end]');
if (!anchor || !end) {
setGeometry(null);
return;
}
const box = wrapper.getBoundingClientRect();
const a = anchor.getBoundingClientRect();
const z = end.getBoundingClientRect();
const top = a.top - box.top + a.height / 2;
const bottom = z.top - box.top + z.height / 2;
setGeometry({ top, height: Math.max(0, bottom - top) });
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(wrapper);
return () => observer.disconnect();
}, [containerRef, measureKey]);
if (!geometry) return null;
return (
<div
aria-hidden="true"
data-testid="loop-return"
data-state="closed"
className={cn(
'pointer-events-none absolute w-[7px]',
side === 'left' ? 'left-[5px]' : 'right-[5px]',
)}
style={{ top: geometry.top, height: geometry.height }}
>
{/* The vertical, drawn upward from the last row. */}
<div
className={cn(
'absolute inset-y-0 w-0.5 origin-bottom bg-connector-closed',
side === 'left' ? 'left-0' : 'right-0',
animate && 'animate-loop-draw',
)}
style={animate ? { animationDelay: `${delayMs}ms` } : undefined}
/>
{/* The two arms: into the last row, and into Movie A. */}
<div className="absolute top-0 h-0.5 w-full bg-connector-closed" />
<div className="absolute bottom-0 h-0.5 w-full bg-connector-closed" />
</div>
);
}
+21 -4
View File
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { MovieChainLink } from '@/types'; import type { MovieChainLink } from '@/types';
import MovieCard from './MovieCard'; import MovieCard from './MovieCard';
@@ -46,10 +46,27 @@ describe('MovieCard', () => {
expect(screen.queryByText(/^Movie [AB]$/)).toBeNull(); expect(screen.queryByText(/^Movie [AB]$/)).toBeNull();
}); });
it('renders the compact layout without a target badge', () => { it('renders the compact layout with the same marker and poster ring', () => {
render(<MovieCard movie={movie({ title: 'Speed' })} isTarget="A" compact />); render(<MovieCard movie={movie({ title: 'Speed' })} isTarget="A" compact />);
expect(screen.getByRole('img', { name: 'Speed' })).toBeInTheDocument(); const img = screen.getByRole('img', { name: 'Speed' });
expect(img.className).toContain('ring-movie-a');
expect(screen.getByText('Speed')).toBeInTheDocument(); expect(screen.getByText('Speed')).toBeInTheDocument();
expect(screen.queryByText('Movie A')).toBeNull(); expect(screen.getByText('Movie A')).toBeInTheDocument();
});
it('puts the identity on the poster: B ring for Movie B, gold ring when highlighted', () => {
const { rerender } = render(<MovieCard movie={movie()} isTarget="B" />);
expect(screen.getByRole('img', { name: 'The Matrix' }).className).toContain('ring-movie-b');
rerender(<MovieCard movie={movie()} highlight />);
expect(screen.getByRole('img', { name: 'The Matrix' }).className).toContain('ring-primary');
});
it('falls back to the placeholder when the TMDB poster fails to load', () => {
render(<MovieCard movie={movie({ posterPath: '/gone.jpg' })} />);
const img = screen.getByRole('img', { name: 'The Matrix' });
expect(img).toHaveAttribute('src', 'https://image.tmdb.org/t/p/w154/gone.jpg');
fireEvent.error(img);
expect(img).toHaveAttribute('src', '/placeholder-movie.svg');
}); });
}); });
+32 -32
View File
@@ -1,72 +1,72 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { posterUrl, releaseYear } from '@/lib/tmdb'; import { posterUrl, releaseYear } from '@/lib/tmdb';
import { useImageFallback } from '@/hooks/use-image-fallback';
import MovieMarker from './MovieMarker';
import type { MovieChainLink } from '@/types'; import type { MovieChainLink } from '@/types';
interface MovieCardProps { interface MovieCardProps {
movie: MovieChainLink; movie: MovieChainLink;
highlight?: boolean; highlight?: boolean;
isTarget?: 'A' | 'B'; isTarget?: 'A' | 'B';
/** The in-row size used inside chain links; the default is the anchor card. */
compact?: boolean; compact?: boolean;
} }
/**
* Identity lives on the poster: Movie A wears the blue ring, Movie B the
* magenta one, and the chip beside the title says which. The card around it
* stays neutral so a row of the chain never turns a solid colour.
*/
export default function MovieCard({ export default function MovieCard({
movie, movie,
highlight, highlight,
isTarget, isTarget,
compact, compact,
}: MovieCardProps) { }: MovieCardProps) {
const imgSrc = posterUrl(movie.posterPath, 'w154'); const img = useImageFallback(posterUrl(movie.posterPath, 'w154'), '/placeholder-movie.svg');
const ring = cn(
isTarget === 'A' && 'ring-2 ring-movie-a ring-offset-1 ring-offset-card',
isTarget === 'B' && 'ring-2 ring-movie-b ring-offset-1 ring-offset-card',
highlight && !isTarget && 'ring-2 ring-primary ring-offset-1 ring-offset-card',
);
if (compact) { if (compact) {
return ( return (
<div className="flex items-center gap-2 min-w-0"> <div className="flex min-w-0 items-center gap-2.5">
<img <img
src={imgSrc || '/placeholder-movie.svg'} src={img.src}
onError={img.onError}
alt={movie.title} alt={movie.title}
className="h-12 w-8 shrink-0 rounded object-cover" className={cn('h-18 w-12 shrink-0 rounded-sm object-cover', ring)}
/> />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium leading-tight break-words">{movie.title}</p> <p className="flex items-center gap-1.5 text-sm font-medium leading-tight">
<p className="text-[11px] text-muted-foreground"> <span className="min-w-0 break-words">{movie.title}</span>
{releaseYear(movie.releaseDate)} {isTarget && <MovieMarker target={isTarget} />}
</p> </p>
<p className="text-xs text-muted-foreground">{releaseYear(movie.releaseDate)}</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div <div className="flex items-center gap-3 rounded-lg bg-card p-2.5 ring-1 ring-foreground/10">
className={cn(
'flex items-center gap-3 rounded-lg border p-2 transition-colors',
highlight && 'border-primary bg-primary/5',
isTarget === 'A' && 'border-blue-500 bg-blue-500/5',
isTarget === 'B' && 'border-green-500 bg-green-500/5',
!highlight && !isTarget && 'border-border',
)}
>
<img <img
src={imgSrc || '/placeholder-movie.svg'} src={img.src}
onError={img.onError}
alt={movie.title} alt={movie.title}
className="h-16 w-11 shrink-0 rounded object-cover" className={cn('h-21 w-14 shrink-0 rounded-sm object-cover', ring)}
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{movie.title}</p> <p className="flex items-center gap-2 font-display text-lg font-semibold leading-tight">
<p className="text-xs text-muted-foreground"> <span className="truncate">{movie.title}</span>
{isTarget && <MovieMarker target={isTarget} size="md" />}
</p>
<p className="mt-0.5 text-sm text-muted-foreground">
{releaseYear(movie.releaseDate)} {releaseYear(movie.releaseDate)}
</p> </p>
{isTarget && (
<span
className={cn(
'mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold',
isTarget === 'A'
? 'bg-blue-500/10 text-blue-600'
: 'bg-green-500/10 text-green-600',
)}
>
Movie {isTarget}
</span>
)}
</div> </div>
</div> </div>
); );
+48
View File
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import MovieMarker from './MovieMarker';
/**
* The A / B chip. The letter is visible; "Movie A"/"Movie B" is what screen
* readers hear via an sr-only span.
*/
describe('MovieMarker', () => {
it('shows the visible letter and an sr-only "Movie A" label for A', () => {
render(<MovieMarker target="A" />);
expect(screen.getByText('A', { selector: 'span[aria-hidden="true"]' })).toBeInTheDocument();
expect(screen.getByText('Movie A')).toBeInTheDocument();
});
it('shows the visible letter and an sr-only "Movie B" label for B', () => {
render(<MovieMarker target="B" />);
expect(screen.getByText('B', { selector: 'span[aria-hidden="true"]' })).toBeInTheDocument();
expect(screen.getByText('Movie B')).toBeInTheDocument();
});
it('colours A with the movie-a token', () => {
render(<MovieMarker target="A" />);
expect(screen.getByText('Movie A').parentElement?.className).toContain('text-movie-a');
});
it('colours B with the movie-b token', () => {
render(<MovieMarker target="B" />);
expect(screen.getByText('Movie B').parentElement?.className).toContain('text-movie-b');
});
it('defaults to the small size', () => {
render(<MovieMarker target="A" />);
expect(screen.getByText('Movie A').parentElement?.className).toContain('h-5');
});
it('applies the medium size when asked', () => {
render(<MovieMarker target="A" size="md" />);
const el = screen.getByText('Movie A').parentElement;
expect(el?.className).toContain('h-6');
expect(el?.className).not.toContain('h-5');
});
it('merges a custom className', () => {
render(<MovieMarker target="A" className="ml-2" />);
expect(screen.getByText('Movie A').parentElement?.className).toContain('ml-2');
});
});
+28
View File
@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils';
interface MovieMarkerProps {
target: 'A' | 'B';
size?: 'sm' | 'md';
className?: string;
}
/**
* The A / B chip. Identity is carried by the Technicolor tokens (blue record
* for A, magenta for B); the letter is what sighted players read and
* "Movie A" is what screen readers hear.
*/
export default function MovieMarker({ target, size = 'sm', className }: MovieMarkerProps) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-sm font-display font-bold leading-none',
size === 'sm' ? 'h-5 w-5 text-xs' : 'h-6 w-6 text-sm',
target === 'A' ? 'bg-movie-a/15 text-movie-a' : 'bg-movie-b/15 text-movie-b',
className,
)}
>
<span aria-hidden="true">{target}</span>
<span className="sr-only">Movie {target}</span>
</span>
);
}
+3 -3
View File
@@ -20,10 +20,10 @@ describe('ScoreDisplay', () => {
it('shows the total score and each bonus line', () => { it('shows the total score and each bonus line', () => {
render(<ScoreDisplay score={SCORE} />); render(<ScoreDisplay score={SCORE} />);
expect(screen.getByText('2400')).toBeInTheDocument(); expect(screen.getByText('2,400')).toBeInTheDocument();
expect(screen.getByText('points')).toBeInTheDocument(); expect(screen.getByText('points')).toBeInTheDocument();
expect(screen.getByText('Base score')).toBeInTheDocument(); expect(screen.getByText('Base score')).toBeInTheDocument();
expect(screen.getByText('1000')).toBeInTheDocument(); expect(screen.getByText('1,000')).toBeInTheDocument();
expect(screen.getByText('+800')).toBeInTheDocument(); expect(screen.getByText('+800')).toBeInTheDocument();
expect(screen.getByText('+500')).toBeInTheDocument(); expect(screen.getByText('+500')).toBeInTheDocument();
expect(screen.getByText('+100')).toBeInTheDocument(); expect(screen.getByText('+100')).toBeInTheDocument();
@@ -51,7 +51,7 @@ describe('ScoreDisplay', () => {
); );
expect(screen.getByText('Duplicate penalty')).toBeInTheDocument(); expect(screen.getByText('Duplicate penalty')).toBeInTheDocument();
expect(screen.getByText('Hint penalty')).toBeInTheDocument(); expect(screen.getByText('Hint penalty')).toBeInTheDocument();
expect(screen.getAllByText('-150')).toHaveLength(2); expect(screen.getAllByText('−150')).toHaveLength(2);
}); });
it('does not prefix a zero or negative bonus value with a plus sign', () => { it('does not prefix a zero or negative bonus value with a plus sign', () => {
+21 -12
View File
@@ -5,13 +5,22 @@ interface ScoreDisplayProps {
score: ScoreBreakdown; score: ScoreBreakdown;
} }
/**
* The score reveal. The total earns the display face and the one centred
* moment in the app; the breakdown beneath it is a plain ledger, bonuses in
* gold and penalties in the error colour.
*/
export default function ScoreDisplay({ score }: ScoreDisplayProps) { export default function ScoreDisplay({ score }: ScoreDisplayProps) {
return ( return (
<div className="space-y-3 text-center"> <div className="space-y-4">
<p className="text-4xl font-bold">{score.totalScore}</p> <div className="text-center">
<p className="text-sm text-muted-foreground">points</p> <p className="font-display text-5xl font-extrabold tabular-nums leading-none tracking-tight">
{score.totalScore.toLocaleString()}
</p>
<p className="mt-1 text-sm text-muted-foreground">points</p>
</div>
<div className="mx-auto max-w-xs space-y-1.5 text-sm"> <dl className="mx-auto max-w-xs space-y-1.5 text-sm tabular-nums">
<ScoreLine label="Base score" value={score.baseScore} /> <ScoreLine label="Base score" value={score.baseScore} />
<ScoreLine <ScoreLine
label={`Chain length (${score.linkCount} link${score.linkCount !== 1 ? 's' : ''}${score.par ? `, par ${score.par}` : ''})`} label={`Chain length (${score.linkCount} link${score.linkCount !== 1 ? 's' : ''}${score.par ? `, par ${score.par}` : ''})`}
@@ -38,7 +47,7 @@ export default function ScoreDisplay({ score }: ScoreDisplayProps) {
penalty penalty
/> />
)} )}
</div> </dl>
</div> </div>
); );
} }
@@ -55,18 +64,18 @@ function ScoreLine({
penalty?: boolean; penalty?: boolean;
}) { }) {
return ( return (
<div className="flex justify-between"> <div className="flex justify-between gap-4">
<span className="text-muted-foreground">{label}</span> <dt className="text-muted-foreground">{label}</dt>
<span <dd
className={cn( className={cn(
'font-medium', 'font-medium',
bonus && value > 0 && 'text-green-600', bonus && value > 0 && 'text-primary-text',
penalty && 'text-red-500', penalty && 'text-destructive',
)} )}
> >
{value > 0 && bonus ? '+' : ''} {value > 0 && bonus ? '+' : ''}
{value} {value < 0 ? `−${Math.abs(value).toLocaleString()}` : value.toLocaleString()}
</span> </dd>
</div> </div>
); );
} }
@@ -174,14 +174,14 @@ describe('SearchAutocomplete', () => {
expect(screen.getByText('The Matrix')).toBeInTheDocument(); expect(screen.getByText('The Matrix')).toBeInTheDocument();
}, 10000); }, 10000);
it('shows "No results found" when a search comes back empty', async () => { it('shows a no-match message when a search comes back empty', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />, <SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
); );
await user.type(screen.getByRole('combobox'), 'zz'); await user.type(screen.getByRole('combobox'), 'zz');
await screen.findByText('No results found'); await screen.findByText('No matches. Try another spelling.');
}, 10000); }, 10000);
it('shows an error option when the search request fails', async () => { it('shows an error option when the search request fails', async () => {
@@ -206,10 +206,10 @@ describe('SearchAutocomplete', () => {
); );
await user.type(screen.getByRole('combobox'), 'ke'); await user.type(screen.getByRole('combobox'), 'ke');
await screen.findByLabelText('Searching...'); await screen.findByLabelText('Searching…');
resolveSearch({ page: 1, results: [], total_pages: 1, total_results: 0 }); resolveSearch({ page: 1, results: [], total_pages: 1, total_results: 0 });
await waitFor(() => expect(screen.queryByLabelText('Searching...')).toBeNull()); await waitFor(() => expect(screen.queryByLabelText('Searching…')).toBeNull());
}, 10000); }, 10000);
it('ignores a stale response that resolves after a newer search has started', async () => { it('ignores a stale response that resolves after a newer search has started', async () => {
+6 -5
View File
@@ -191,7 +191,8 @@ export default function SearchAutocomplete({
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder || (mode === 'actor' ? 'Search for an actor...' : 'Search for a movie...')} placeholder={placeholder || (mode === 'actor' ? 'Search for an actor…' : 'Search for a movie…')}
className="h-11 text-base md:text-base"
disabled={disabled} disabled={disabled}
onFocus={() => { onFocus={() => {
if (results.length > 0) { if (results.length > 0) {
@@ -212,7 +213,7 @@ export default function SearchAutocomplete({
aria-label={mode === 'actor' ? 'Search for an actor' : 'Search for a movie'} aria-label={mode === 'actor' ? 'Search for an actor' : 'Search for a movie'}
/> />
{isLoading && ( {isLoading && (
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" aria-label="Searching..." /> <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" aria-label="Searching…" />
)} )}
</div> </div>
@@ -221,7 +222,7 @@ export default function SearchAutocomplete({
id={listId} id={listId}
role="listbox" role="listbox"
aria-label={mode === 'actor' ? 'Actor results' : 'Movie results'} aria-label={mode === 'actor' ? 'Actor results' : 'Movie results'}
className="absolute z-50 mt-1 max-h-[min(16rem,50vh)] w-full overflow-auto rounded-md border border-border bg-popover shadow-md" className="absolute z-50 mt-1 max-h-[min(16rem,50vh)] w-full overflow-auto rounded-md bg-popover shadow-md ring-1 ring-foreground/10"
> >
{mode === 'actor' && {mode === 'actor' &&
personResults.map((person, idx) => ( personResults.map((person, idx) => (
@@ -275,7 +276,7 @@ export default function SearchAutocomplete({
<img <img
src={posterUrl(movie.poster_path, 'w92') || '/placeholder-movie.svg'} src={posterUrl(movie.poster_path, 'w92') || '/placeholder-movie.svg'}
alt="" alt=""
className="h-14 w-10 shrink-0 rounded object-cover" className="h-15 w-10 shrink-0 rounded-sm object-cover"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{movie.title}</p> <p className="truncate text-sm font-medium">{movie.title}</p>
@@ -300,7 +301,7 @@ export default function SearchAutocomplete({
results.length === 0 && ( results.length === 0 && (
<li role="option" aria-selected={false}> <li role="option" aria-selected={false}>
<p className="px-3 py-4 text-center text-sm text-muted-foreground"> <p className="px-3 py-4 text-center text-sm text-muted-foreground">
No results found No matches. Try another spelling.
</p> </p>
</li> </li>
)} )}
@@ -27,4 +27,12 @@ describe('SerpentineConnector', () => {
left: '50%', left: '50%',
}); });
}); });
it('turns gold once the loop is closed, staggered by its row index', () => {
const { container } = render(<SerpentineConnector lineX={7.5} closed index={2} />);
const line = container.firstElementChild?.firstElementChild as HTMLElement;
expect(line.className).toContain('border-connector-closed');
expect(line.dataset.closed).toBe('true');
expect(line).toHaveStyle({ transitionDelay: '120ms' });
});
}); });
+21 -3
View File
@@ -1,6 +1,13 @@
import { cn } from '@/lib/utils';
import { CONNECTOR_STAGGER_MS } from '@/lib/motion';
interface SerpentineConnectorProps { interface SerpentineConnectorProps {
/** X% where the vertical line runs */ /** X% where the vertical line runs */
lineX: number; lineX: number;
/** The loop is closed: this segment turns gold. */
closed?: boolean;
/** Row index, so the segments light up top to bottom rather than all at once. */
index?: number;
} }
/** /**
@@ -8,12 +15,23 @@ interface SerpentineConnectorProps {
* The horizontal arm is rendered by the card row itself so it can align * The horizontal arm is rendered by the card row itself so it can align
* with the card's vertical center. * with the card's vertical center.
*/ */
export default function SerpentineConnector({ lineX }: SerpentineConnectorProps) { export default function SerpentineConnector({
lineX,
closed = false,
index = 0,
}: SerpentineConnectorProps) {
return ( return (
<div className="relative h-8"> <div className="relative h-8">
<div <div
className="absolute top-0 bottom-0 w-0 border-l-2 border-muted-foreground/30" data-closed={closed || undefined}
style={{ left: `${lineX}%` }} className={cn(
'absolute top-0 bottom-0 w-0 border-l-2 transition-colors duration-300',
closed ? 'border-connector-closed' : 'border-connector',
)}
style={{
left: `${lineX}%`,
transitionDelay: `${index * CONNECTOR_STAGGER_MS}ms`,
}}
/> />
</div> </div>
); );
+13 -13
View File
@@ -65,7 +65,7 @@ describe('ShareableResult', () => {
render(<ShareableResult {...baseProps()} />); render(<ShareableResult {...baseProps()} />);
await clickAndSettle( await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }), screen.getByRole('button', { name: /Copy result|Copied/ }),
); );
expect(writeText).toHaveBeenCalledTimes(1); expect(writeText).toHaveBeenCalledTimes(1);
@@ -80,17 +80,17 @@ describe('ShareableResult', () => {
render(<ShareableResult {...baseProps()} />); render(<ShareableResult {...baseProps()} />);
await clickAndSettle( await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }), screen.getByRole('button', { name: /Copy result|Copied/ }),
); );
expect( expect(
screen.getByRole('button', { name: /Copied!/ }), screen.getByRole('button', { name: /^Copied$/ }),
).toBeInTheDocument(); ).toBeInTheDocument();
await act(async () => { await act(async () => {
vi.advanceTimersByTime(2000); vi.advanceTimersByTime(2000);
}); });
expect( expect(
screen.getByRole('button', { name: /Copy Result/ }), screen.getByRole('button', { name: /Copy result/ }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -99,9 +99,9 @@ describe('ShareableResult', () => {
<ShareableResult {...baseProps()} mode={{ type: 'daily', date: '2024-01-01' }} />, <ShareableResult {...baseProps()} mode={{ type: 'daily', date: '2024-01-01' }} />,
); );
await clickAndSettle( await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }), screen.getByRole('button', { name: /Copy result|Copied/ }),
); );
expect(writeText.mock.calls[0]?.[0]).toContain('Daily Challenge'); expect(writeText.mock.calls[0]?.[0]).toContain('Daily challenge');
expect(writeText.mock.calls[0]?.[0]).toContain('/daily'); expect(writeText.mock.calls[0]?.[0]).toContain('/daily');
rerender( rerender(
@@ -111,16 +111,16 @@ describe('ShareableResult', () => {
/>, />,
); );
await clickAndSettle( await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }), screen.getByRole('button', { name: /Copy result|Copied/ }),
); );
expect(writeText.mock.calls[1]?.[0]).toContain('Async Versus'); expect(writeText.mock.calls[1]?.[0]).toContain('Versus');
expect(writeText.mock.calls[1]?.[0]).toContain('/versus/m1'); expect(writeText.mock.calls[1]?.[0]).toContain('/versus/m1');
}); });
it('links to plain versus play when no match id is given', async () => { it('links to plain versus play when no match id is given', async () => {
render(<ShareableResult {...baseProps()} mode={{ type: 'versus' }} />); render(<ShareableResult {...baseProps()} mode={{ type: 'versus' }} />);
await clickAndSettle( await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }), screen.getByRole('button', { name: /Copy result|Copied/ }),
); );
expect(writeText.mock.calls[0]?.[0]).toMatch(/\/versus$/); expect(writeText.mock.calls[0]?.[0]).toMatch(/\/versus$/);
}); });
@@ -135,7 +135,7 @@ describe('ShareableResult', () => {
/>, />,
); );
await clickAndSettle( await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }), screen.getByRole('button', { name: /Copy result|Copied/ }),
); );
const text = writeText.mock.calls[0]?.[0] as string; const text = writeText.mock.calls[0]?.[0] as string;
expect(text).toContain('Movie A <-> Movie B'); expect(text).toContain('Movie A <-> Movie B');
@@ -147,11 +147,11 @@ describe('ShareableResult', () => {
render(<ShareableResult {...baseProps()} />); render(<ShareableResult {...baseProps()} />);
await clickAndSettle( await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }), screen.getByRole('button', { name: /Copy result|Copied/ }),
); );
expect( expect(
screen.getByRole('button', { name: /Copy Result/ }), screen.getByRole('button', { name: /Copy result/ }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -160,7 +160,7 @@ describe('ShareableResult', () => {
const { unmount } = render(<ShareableResult {...baseProps()} />); const { unmount } = render(<ShareableResult {...baseProps()} />);
await clickAndSettle( await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }), screen.getByRole('button', { name: /Copy result|Copied/ }),
); );
unmount(); unmount();
+8 -8
View File
@@ -29,9 +29,9 @@ function getModeLabel(mode?: GameMode): string {
if (!mode) return ''; if (!mode) return '';
switch (mode.type) { switch (mode.type) {
case 'daily': case 'daily':
return 'Daily Challenge'; return 'Daily challenge';
case 'versus': case 'versus':
return 'Async Versus'; return 'Versus';
default: default:
return ''; return '';
} }
@@ -82,7 +82,7 @@ function generateShareText(props: ShareableResultProps): string {
.map((link) => (link.type === 'movie' ? '\u{1F3AC}' : '\u{1F9D1}')) .map((link) => (link.type === 'movie' ? '\u{1F3AC}' : '\u{1F9D1}'))
.join(' \u2192 '), .join(' \u2192 '),
'', '',
`Think you can do better? ${url}`, `Movie Loop. Think you can do better? ${url}`,
]; ];
return lines.join('\n'); return lines.join('\n');
@@ -131,19 +131,19 @@ export default function ShareableResult(props: ShareableResultProps) {
<Button variant="outline" size="sm" onClick={handleCopy}> <Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? ( {copied ? (
<> <>
<Check className="mr-1.5 h-3.5 w-3.5" /> <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Copied! Copied
</> </>
) : ( ) : (
<> <>
<Copy className="mr-1.5 h-3.5 w-3.5" /> <Copy className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Copy Result Copy result
</> </>
)} )}
</Button> </Button>
{typeof navigator.share === 'function' && ( {typeof navigator.share === 'function' && (
<Button variant="outline" size="sm" onClick={handleShare}> <Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="mr-1.5 h-3.5 w-3.5" /> <Share2 className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Share Share
</Button> </Button>
)} )}
+3 -3
View File
@@ -14,15 +14,15 @@ export default function SoundToggle() {
return ( return (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon-sm"
onClick={handleToggle} onClick={handleToggle}
className="h-8 w-8" className="text-muted-foreground hover:text-foreground"
aria-label={enabled ? 'Mute sounds' : 'Enable sounds'} aria-label={enabled ? 'Mute sounds' : 'Enable sounds'}
> >
{enabled ? ( {enabled ? (
<Volume2 className="h-4 w-4" /> <Volume2 className="h-4 w-4" />
) : ( ) : (
<VolumeX className="h-4 w-4 text-muted-foreground" /> <VolumeX className="h-4 w-4" />
)} )}
</Button> </Button>
); );
+9 -7
View File
@@ -3,11 +3,13 @@ import { useTimer, type BonusTier } from '@/hooks/use-timer';
import { Timer as TimerIcon } from 'lucide-react'; import { Timer as TimerIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// The bonus tier is not a traffic light: still in the full-bonus window reads
// gold, and running out of bonus is quiet, not red - it is not an error.
const tierColors: Record<BonusTier, string> = { const tierColors: Record<BonusTier, string> = {
high: 'text-green-600', high: 'text-primary-text',
medium: 'text-yellow-600', medium: 'text-foreground',
low: 'text-orange-500', low: 'text-muted-foreground',
none: 'text-red-500', none: 'text-muted-foreground',
}; };
const tierLabels: Record<BonusTier, string> = { const tierLabels: Record<BonusTier, string> = {
@@ -27,9 +29,9 @@ export default function Timer() {
return ( return (
<div className={cn('flex items-center gap-1.5 text-sm', tierColors[bonusTier])}> <div className={cn('flex items-center gap-1.5 text-sm', tierColors[bonusTier])}>
<TimerIcon className="h-4 w-4" /> <TimerIcon className="h-4 w-4" aria-hidden="true" />
<span className="font-mono font-medium">{formattedTime}</span> <span className="font-display text-base font-semibold tabular-nums">{formattedTime}</span>
<span className="text-xs opacity-75">({tierLabels[bonusTier]})</span> <span className="text-xs">({tierLabels[bonusTier]})</span>
</div> </div>
); );
} }
@@ -33,7 +33,7 @@ describe('ValidationFeedback', () => {
useGameStore.setState({ isValidating: true }); useGameStore.setState({ isValidating: true });
render(<ValidationFeedback />); render(<ValidationFeedback />);
expect(screen.getByRole('status')).toHaveTextContent( expect(screen.getByRole('status')).toHaveTextContent(
'Validating connection...', 'Checking the link…',
); );
}); });
@@ -49,7 +49,7 @@ describe('ValidationFeedback', () => {
useGameStore.setState({ isValidating: true, validationError: 'stale' }); useGameStore.setState({ isValidating: true, validationError: 'stale' });
render(<ValidationFeedback />); render(<ValidationFeedback />);
expect(screen.getByRole('status')).toHaveTextContent( expect(screen.getByRole('status')).toHaveTextContent(
'Validating connection...', 'Checking the link…',
); );
expect(screen.queryByRole('alert')).toBeNull(); expect(screen.queryByRole('alert')).toBeNull();
}); });
@@ -57,28 +57,28 @@ describe('ValidationFeedback', () => {
it('flashes a success check when the chain grows, then clears it', () => { it('flashes a success check when the chain grows, then clears it', () => {
useGameStore.setState({ chain: [movieA] }); useGameStore.setState({ chain: [movieA] });
render(<ValidationFeedback />); render(<ValidationFeedback />);
expect(screen.queryByText('Connected!')).toBeNull(); expect(screen.queryByText('Linked')).toBeNull();
act(() => { act(() => {
useGameStore.setState({ chain: [movieA, movieA] }); useGameStore.setState({ chain: [movieA, movieA] });
}); });
expect(screen.getByRole('status')).toHaveTextContent('Connected!'); expect(screen.getByRole('status')).toHaveTextContent('Linked');
act(() => { act(() => {
vi.advanceTimersByTime(600); vi.advanceTimersByTime(600);
}); });
expect(screen.queryByText('Connected!')).toBeNull(); expect(screen.queryByText('Linked')).toBeNull();
}); });
it('does not re-flash when the chain length is unchanged', () => { it('does not re-flash when the chain length is unchanged', () => {
useGameStore.setState({ chain: [movieA, movieA] }); useGameStore.setState({ chain: [movieA, movieA] });
const { rerender } = render(<ValidationFeedback />); const { rerender } = render(<ValidationFeedback />);
expect(screen.getByText('Connected!')).toBeInTheDocument(); expect(screen.getByText('Linked')).toBeInTheDocument();
act(() => { act(() => {
vi.advanceTimersByTime(600); vi.advanceTimersByTime(600);
}); });
rerender(<ValidationFeedback />); rerender(<ValidationFeedback />);
expect(screen.queryByText('Connected!')).toBeNull(); expect(screen.queryByText('Linked')).toBeNull();
}); });
}); });
+4 -4
View File
@@ -9,7 +9,7 @@ export default function ValidationFeedback() {
const [showCheck, setShowCheck] = useState(false); const [showCheck, setShowCheck] = useState(false);
const [flashedLength, setFlashedLength] = useState(0); const [flashedLength, setFlashedLength] = useState(0);
// Flash a green check when the chain grows (successful validation). Adjusting // Flash a gold check when the chain grows (successful validation). Adjusting
// state during render is React's documented pattern for reacting to a changed // state during render is React's documented pattern for reacting to a changed
// value; doing it in an effect costs an extra committed render each link. // value; doing it in an effect costs an extra committed render each link.
if (chain.length > 1 && chain.length !== flashedLength) { if (chain.length > 1 && chain.length !== flashedLength) {
@@ -30,7 +30,7 @@ export default function ValidationFeedback() {
className="flex items-center gap-2 text-sm text-muted-foreground" className="flex items-center gap-2 text-sm text-muted-foreground"
> >
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Validating connection... Checking the link…
</div> </div>
); );
} }
@@ -52,10 +52,10 @@ export default function ValidationFeedback() {
return ( return (
<div <div
role="status" role="status"
className="flex animate-check-flash items-center gap-2 text-sm text-green-600 motion-reduce:animate-none" className="flex animate-check-flash items-center gap-2 text-sm font-medium text-primary-text motion-reduce:animate-none"
> >
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
Connected! Linked
</div> </div>
); );
} }
@@ -14,10 +14,10 @@ describe('HowToPlayDialog', () => {
await userEvent.click(screen.getByRole('button', { name: 'How to play' })); await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect( expect(
screen.getByRole('dialog', { name: 'How to Play' }), screen.getByRole('dialog', { name: 'How to play' }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText(/Build a chain of actor-movie connections/), screen.getByText(/Build a chain of actor-movie connections and bring it back/),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -25,12 +25,12 @@ describe('HowToPlayDialog', () => {
render(<HowToPlayDialog />); render(<HowToPlayDialog />);
await userEvent.click(screen.getByRole('button', { name: 'How to play' })); await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect(screen.getByRole('heading', { name: 'The Goal' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'The goal' })).toBeInTheDocument();
expect( expect(
screen.getByRole('heading', { name: 'How Connections Work' }), screen.getByRole('heading', { name: 'How connections work' }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Scoring' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Scoring' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Game Modes' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Ways to play' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Tips' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Tips' })).toBeInTheDocument();
}); });
+39 -30
View File
@@ -6,6 +6,7 @@ import {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import MovieMarker from '@/components/game/MovieMarker';
import { HelpCircle } from 'lucide-react'; import { HelpCircle } from 'lucide-react';
export default function HowToPlayDialog() { export default function HowToPlayDialog() {
@@ -16,75 +17,83 @@ export default function HowToPlayDialog() {
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="text-muted-foreground transition-colors hover:text-foreground" className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="How to play" aria-label="How to play"
> >
<HelpCircle className="h-4 w-4" /> <HelpCircle className="h-4 w-4" aria-hidden="true" />
</button> </button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg"> <DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>How to Play</DialogTitle> <DialogTitle className="font-display text-xl font-semibold">How to play</DialogTitle>
<DialogDescription> <DialogDescription>
Build a chain of actor-movie connections to complete the loop! Build a chain of actor-movie connections and bring it back to where it started.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2 text-sm text-foreground"> <div className="space-y-5 py-2 text-sm text-foreground">
<section> <section>
<h3 className="mb-1 font-semibold">The Goal</h3> <h3 className="mb-1 font-semibold">The goal</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
You're given two movies (<span className="font-medium text-blue-500">Movie A</span>{' '} You're given two movies,{' '}
and <span className="font-medium text-green-500">Movie B</span>). Build a chain of <MovieMarker target="A" className="align-middle" />{' '}
connections starting from Movie A, passing through Movie B, and looping back to and{' '}
Movie A. <MovieMarker target="B" className="align-middle" />
. Build a chain that starts at Movie A, passes through Movie B, and loops back
to Movie A.
</p> </p>
</section> </section>
<section> <section>
<h3 className="mb-1 font-semibold">How Connections Work</h3> <h3 className="mb-1 font-semibold">How connections work</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Each connection alternates between actors and movies. Pick an actor who appeared in Each link alternates between an actor and a movie. Pick an actor who appeared in
the current movie, then pick another movie that actor appeared in, and so on. the current movie, then pick another movie that actor appeared in, and so on.
</p> </p>
<div className="mt-2 rounded-md bg-muted/50 p-3 text-xs text-muted-foreground"> <p className="mt-2 rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
Movie A → Actor → Movie → Actor → Movie B → Actor → Movie A Movie A → actor → movie → actor → Movie B → actor → Movie A
</div> </p>
</section> </section>
<section> <section>
<h3 className="mb-1 font-semibold">Scoring</h3> <h3 className="mb-1 font-semibold">Scoring</h3>
<ul className="list-inside list-disc space-y-1 text-muted-foreground"> <ul className="list-inside list-disc space-y-1 text-muted-foreground">
<li> <li>
<span className="font-medium text-foreground">Shorter chains</span> score higher — fewer links means a better path <span className="font-medium text-foreground">Shorter chains</span> score
higher; fewer links means a better path.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Faster times</span> earn a time bonus that decays over 10 minutes <span className="font-medium text-foreground">Faster times</span> earn a time
bonus that decays over ten minutes.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Obscure picks</span> (less popular actors/movies) earn bonus points <span className="font-medium text-foreground">Obscure picks</span> (less
popular actors and movies) earn bonus points.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Reusing</span> an actor or movie you've already picked costs points <span className="font-medium text-foreground">Reusing</span> an actor or movie
you've already picked costs points.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Using hints</span> reduces your score</li> <span className="font-medium text-foreground">Hints</span> cost points too.
</li>
</ul> </ul>
</section> </section>
<section> <section>
<h3 className="mb-1 font-semibold">Game Modes</h3> <h3 className="mb-1 font-semibold">Ways to play</h3>
<ul className="list-inside list-disc space-y-1 text-muted-foreground"> <ul className="list-inside list-disc space-y-1 text-muted-foreground">
<li> <li>
<span className="font-medium text-foreground">Free Play</span> — pick any two <span className="font-medium text-foreground">Play now</span> hands you a
movies and build your chain random pair. Custom lets you pick both movies yourself.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Daily Challenge</span> — everyone <span className="font-medium text-foreground">Today's challenge</span> gives
gets the same movie pair each day everyone the same pairs each day.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Async Versus</span> — create or <span className="font-medium text-foreground">Versus</span> and{' '}
join a challenge and compare scores <span className="font-medium text-foreground">Game night</span> put the same
pair in front of other players, live or whenever they get to it.
</li> </li>
</ul> </ul>
</section> </section>
@@ -92,9 +101,9 @@ export default function HowToPlayDialog() {
<section> <section>
<h3 className="mb-1 font-semibold">Tips</h3> <h3 className="mb-1 font-semibold">Tips</h3>
<ul className="list-inside list-disc space-y-1 text-muted-foreground"> <ul className="list-inside list-disc space-y-1 text-muted-foreground">
<li>Think about actors who appear in many genres</li> <li>Think of actors who work across many genres.</li>
<li>You can undo your last pick if you hit a dead end</li> <li>Undo your last pick if you hit a dead end.</li>
<li>All connections are verified against real movie data from TMDB</li> <li>Every link is checked against real movie data from TMDB.</li>
</ul> </ul>
</section> </section>
</div> </div>
+45
View File
@@ -0,0 +1,45 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import LogoMark from './LogoMark';
/**
* The wordmark's ring-and-link glyph. Purely decorative (aria-hidden), drawn
* in currentColor so the only observable contract is size, class merging and
* that it stays hidden from assistive tech.
*/
describe('LogoMark', () => {
it('renders an svg hidden from assistive tech', () => {
const { container } = render(<LogoMark />);
const svg = container.querySelector('svg');
expect(svg).not.toBeNull();
expect(svg).toHaveAttribute('aria-hidden', 'true');
expect(svg).toHaveAttribute('focusable', 'false');
});
it('defaults to a 24px square', () => {
const { container } = render(<LogoMark />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('width', '24');
expect(svg).toHaveAttribute('height', '24');
});
it('honours a custom size', () => {
const { container } = render(<LogoMark size={48} />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('width', '48');
expect(svg).toHaveAttribute('height', '48');
});
it('merges a custom className onto the svg', () => {
const { container } = render(<LogoMark className="text-primary" />);
const svg = container.querySelector('svg');
expect(svg?.getAttribute('class')).toContain('text-primary');
expect(svg?.getAttribute('class')).toContain('shrink-0');
});
it('draws in currentColor so the caller controls the colour', () => {
const { container } = render(<LogoMark />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('stroke', 'currentColor');
});
});
+33
View File
@@ -0,0 +1,33 @@
import { cn } from '@/lib/utils';
interface LogoMarkProps {
/** Rendered width and height in px. */
size?: number;
className?: string;
}
/**
* The mark: an open ring with the missing link sitting in the gap. It is the
* game in one shape - a loop with one link still to be supplied. Drawn in
* currentColor so the caller picks the colour - gold, wherever it appears. The same geometry is authored once more as
* public/favicon.svg.
*/
export default function LogoMark({ size = 24, className }: LogoMarkProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
aria-hidden="true"
focusable="false"
className={cn('shrink-0', className)}
>
<path d="M20.5 12a8.5 8.5 0 1 1-3.63-6.96" />
<circle cx="19.54" cy="8.08" r="1.75" fill="currentColor" stroke="none" />
</svg>
);
}
@@ -315,7 +315,7 @@ describe('NotificationBell', () => {
it('shows an empty state with no notifications', async () => { it('shows an empty state with no notifications', async () => {
renderBell(); renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' })); await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('No notifications')).toBeInTheDocument(); expect(screen.getByText('No notifications yet.')).toBeInTheDocument();
}); });
it('closes the panel when a click lands outside it', async () => { it('closes the panel when a click lands outside it', async () => {
+5 -5
View File
@@ -94,7 +94,7 @@ export default function NotificationBell() {
ref={triggerRef} ref={triggerRef}
type="button" type="button"
onClick={handleToggle} onClick={handleToggle}
className="relative flex items-center justify-center rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground" className="relative flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={triggerLabel} aria-label={triggerLabel}
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={open} aria-expanded={open}
@@ -104,7 +104,7 @@ export default function NotificationBell() {
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
aria-hidden="true" aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white" className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-xs font-bold leading-none text-white"
> >
{unreadCount > 99 ? '99+' : unreadCount} {unreadCount > 99 ? '99+' : unreadCount}
</span> </span>
@@ -116,7 +116,7 @@ export default function NotificationBell() {
id={panelId} id={panelId}
role="dialog" role="dialog"
aria-label="Notifications" aria-label="Notifications"
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border border-border bg-card shadow-lg" className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg bg-popover shadow-lg ring-1 ring-foreground/10"
> >
<div className="flex items-center justify-between border-b border-border px-3 py-2"> <div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-sm font-semibold">Notifications</span> <span className="text-sm font-semibold">Notifications</span>
@@ -146,7 +146,7 @@ export default function NotificationBell() {
<div className="max-h-80 overflow-y-auto"> <div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? ( {notifications.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground"> <p className="px-3 py-6 text-center text-sm text-muted-foreground">
No notifications No notifications yet.
</p> </p>
) : ( ) : (
<ul className="m-0 list-none p-0"> <ul className="m-0 list-none p-0">
@@ -185,7 +185,7 @@ export default function NotificationBell() {
<span className="mt-0.5 block text-xs text-muted-foreground line-clamp-2"> <span className="mt-0.5 block text-xs text-muted-foreground line-clamp-2">
{n.message} {n.message}
</span> </span>
<span className="mt-1 block text-[10px] text-muted-foreground"> <span className="mt-1 block text-xs text-muted-foreground">
{timeAgo(n.createdAt)} {timeAgo(n.createdAt)}
</span> </span>
</button> </button>
+84
View File
@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it } from 'vitest';
import PageHeader from './PageHeader';
/**
* The one page-title treatment. Rendered inside a MemoryRouter since an
* optional `back` link uses react-router's `Link` directly.
*/
describe('PageHeader', () => {
it('renders the title as the level-1 heading', () => {
render(
<MemoryRouter>
<PageHeader title="Daily Challenge" />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { level: 1, name: 'Daily Challenge' })).toBeInTheDocument();
});
it('renders the description when given', () => {
render(
<MemoryRouter>
<PageHeader title="Daily Challenge" description="One puzzle a day." />
</MemoryRouter>,
);
expect(screen.getByText('One puzzle a day.')).toBeInTheDocument();
});
it('omits the description when not given', () => {
render(
<MemoryRouter>
<PageHeader title="Daily Challenge" />
</MemoryRouter>,
);
expect(screen.queryByText('One puzzle a day.')).toBeNull();
});
it('renders page-level actions', () => {
render(
<MemoryRouter>
<PageHeader title="Daily Challenge" actions={<button type="button">Share</button>} />
</MemoryRouter>,
);
expect(screen.getByRole('button', { name: 'Share' })).toBeInTheDocument();
});
it('omits the actions slot when not given', () => {
render(
<MemoryRouter>
<PageHeader title="Daily Challenge" />
</MemoryRouter>,
);
expect(screen.queryByRole('button')).toBeNull();
});
it('renders a back link with the given label and href', () => {
render(
<MemoryRouter>
<PageHeader title="Daily Challenge" back={{ to: '/', label: 'Home' }} />
</MemoryRouter>,
);
const back = screen.getByRole('link', { name: /Home/ });
expect(back).toHaveAttribute('href', '/');
});
it('omits the back link when not given', () => {
render(
<MemoryRouter>
<PageHeader title="Daily Challenge" />
</MemoryRouter>,
);
expect(screen.queryByRole('link')).toBeNull();
});
it('merges a custom className', () => {
const { container } = render(
<MemoryRouter>
<PageHeader title="Daily Challenge" className="mt-4" />
</MemoryRouter>,
);
expect(container.firstElementChild?.className).toContain('mt-4');
expect(container.firstElementChild?.className).toContain('mb-6');
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { ArrowLeft } from 'lucide-react';
import { cn } from '@/lib/utils';
interface PageHeaderProps {
title: ReactNode;
/** One line, kept under 65ch. */
description?: ReactNode;
/** Buttons or links that belong to the whole page, right-aligned. */
actions?: ReactNode;
back?: { to: string; label: string };
className?: string;
}
/**
* The one page-title treatment: display face, left-aligned, no icon, no
* eyebrow, no rule. A description sits under it in muted ink; page-level
* actions sit to the right and wrap under on narrow screens.
*/
export default function PageHeader({
title,
description,
actions,
back,
className,
}: PageHeaderProps) {
return (
<div className={cn('mb-6 space-y-2', className)}>
{back && (
<Link
to={back.to}
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
{back.label}
</Link>
)}
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
<h1 className="font-display text-2xl font-semibold tracking-tight">{title}</h1>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
{description && (
<p className="max-w-prose text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
+6 -6
View File
@@ -29,10 +29,10 @@ beforeEach(() => {
}); });
describe('PageLayout - signed out', () => { describe('PageLayout - signed out', () => {
it('renders the page content and a Sign In link, with no account controls', () => { it('renders the page content and a Sign in link, with no account controls', () => {
renderLayout(); renderLayout();
expect(screen.getByText('page content')).toBeInTheDocument(); expect(screen.getByText('page content')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Sign In' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Sign in' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Profile|Me/ })).toBeNull(); expect(screen.queryByRole('link', { name: /Profile|Me/ })).toBeNull();
expect(screen.queryByRole('button', { name: /Notifications/ })).toBeNull(); expect(screen.queryByRole('button', { name: /Notifications/ })).toBeNull();
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull(); expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
@@ -81,7 +81,7 @@ describe('PageLayout - signed out', () => {
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
// No admin or sign-out controls without a signed-in user. // No admin or sign-out controls without a signed-in user.
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull(); expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
expect(screen.queryByRole('button', { name: 'Sign Out' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Sign out' })).toBeNull();
}); });
it('closes the mobile menu when a nav link is clicked', async () => { it('closes the mobile menu when a nav link is clicked', async () => {
@@ -170,7 +170,7 @@ describe('PageLayout - signed in', () => {
expect(adminLink?.className).toContain('text-foreground'); expect(adminLink?.className).toContain('text-foreground');
}); });
it('shows the Admin link and a Sign Out button in the mobile menu too', async () => { it('shows the Admin link and a Sign out button in the mobile menu too', async () => {
renderLayout(); renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' })); await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole('dialog');
@@ -179,7 +179,7 @@ describe('PageLayout - signed in', () => {
.find((el) => dialog.contains(el)); .find((el) => dialog.contains(el));
expect(adminLinkInDialog).toBeDefined(); expect(adminLinkInDialog).toBeDefined();
expect( expect(
screen.getAllByRole('button', { name: 'Sign Out' }).some((b) => dialog.contains(b)), screen.getAllByRole('button', { name: 'Sign out' }).some((b) => dialog.contains(b)),
).toBe(true); ).toBe(true);
}); });
@@ -202,7 +202,7 @@ describe('PageLayout - signed in', () => {
await userEvent.click(screen.getByRole('button', { name: 'Open menu' })); await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole('dialog');
const mobileSignOut = screen const mobileSignOut = screen
.getAllByRole('button', { name: 'Sign Out' }) .getAllByRole('button', { name: 'Sign out' })
.find((b) => dialog.contains(b)); .find((b) => dialog.contains(b));
expect(mobileSignOut).toBeDefined(); expect(mobileSignOut).toBeDefined();
if (mobileSignOut) await userEvent.click(mobileSignOut); if (mobileSignOut) await userEvent.click(mobileSignOut);
+69 -42
View File
@@ -13,17 +13,23 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { User, Menu, LogOut, Shield } from 'lucide-react'; import { Menu, LogOut, Shield } from 'lucide-react';
import { useFriendsStore } from '@/stores/friends-store'; import { useFriendsStore } from '@/stores/friends-store';
import LogoMark from '@/components/layout/LogoMark';
const NAV_LINKS = [ const NAV_LINKS = [
{ path: '/', label: 'Home' }, { path: '/', label: 'Home' },
{ path: '/daily', label: 'Daily' }, { path: '/daily', label: 'Daily' },
{ path: '/versus', label: 'Versus' }, { path: '/versus', label: 'Versus' },
{ path: '/leaderboard', label: 'Ranks' }, { path: '/leaderboard', label: 'Leaderboard' },
{ path: '/friends', label: 'Friends' }, { path: '/friends', label: 'Friends' },
] as const; ] as const;
const NAV_LINK_CLASS =
"relative flex h-14 items-center text-sm font-medium transition-colors hover:text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-t-full after:content-['']";
const NAV_ACTIVE = 'text-foreground after:bg-primary';
const NAV_INACTIVE = 'text-muted-foreground after:bg-transparent';
export default function PageLayout({ export default function PageLayout({
children, children,
noPadding = false, noPadding = false,
@@ -39,7 +45,7 @@ export default function PageLayout({
return ( return (
<div className={cn( <div className={cn(
"flex flex-col bg-gradient-to-b from-background to-background/95", "flex flex-col bg-background",
noPadding ? "h-[100dvh] overflow-hidden" : "min-h-screen", noPadding ? "h-[100dvh] overflow-hidden" : "min-h-screen",
)}> )}>
{/* Visually hidden until focused, so keyboard and screen-reader users can {/* Visually hidden until focused, so keyboard and screen-reader users can
@@ -58,61 +64,75 @@ export default function PageLayout({
style={noPadding ? { top: 'var(--vv-offset, 0px)' } : undefined} style={noPadding ? { top: 'var(--vv-offset, 0px)' } : undefined}
> >
<div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-4"> <div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-4">
<Link to="/" className="text-lg font-bold tracking-tight text-primary"> <Link to="/" className="flex items-center gap-2 text-foreground">
Movie Loop <LogoMark size={20} className="text-primary" />
<span className="font-display text-lg font-bold tracking-tight font-stretch-semi-condensed">
Movie Loop
</span>
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 sm:gap-5">
{/* Desktop nav */} {/* Desktop nav. The active page carries a gold rule under it; the
<nav className="hidden gap-4 md:flex" aria-label="Main navigation"> text colour alone was too quiet to find at a glance. */}
{NAV_LINKS.map(({ path, label }) => ( <nav className="hidden gap-5 md:flex" aria-label="Main navigation">
<Link {NAV_LINKS.map(({ path, label }) => {
key={path} const active =
to={path} location.pathname === path ||
className={cn( (path === '/friends' && location.pathname.startsWith('/friends'));
'relative text-sm font-medium transition-colors hover:text-foreground', return (
location.pathname === path || (path === '/friends' && location.pathname.startsWith('/friends')) <Link
? 'text-foreground' key={path}
: 'text-muted-foreground', to={path}
)} aria-current={active ? 'page' : undefined}
> className={cn(NAV_LINK_CLASS, active ? NAV_ACTIVE : NAV_INACTIVE)}
{label} >
{label === 'Friends' && pendingCount > 0 && ( {label}
<span className="absolute -right-2.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground"> {label === 'Friends' && pendingCount > 0 && (
{pendingCount} <span className="absolute -right-3 top-3 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-xs font-bold leading-none text-primary-foreground">
</span> {pendingCount}
)} </span>
</Link> )}
))} </Link>
);
})}
{user?.isAdmin && ( {user?.isAdmin && (
<Link <Link
to="/admin" to="/admin"
aria-current={location.pathname === '/admin' ? 'page' : undefined}
className={cn( className={cn(
'flex items-center gap-1 text-sm font-medium transition-colors hover:text-foreground', NAV_LINK_CLASS,
location.pathname === '/admin' 'gap-1',
? 'text-foreground' location.pathname === '/admin' ? NAV_ACTIVE : NAV_INACTIVE,
: 'text-muted-foreground',
)} )}
> >
<Shield className="h-3.5 w-3.5" /> <Shield className="h-3.5 w-3.5" aria-hidden="true" />
Admin Admin
</Link> </Link>
)} )}
</nav> </nav>
<HowToPlayDialog /> {/* Utility cluster: every control at the same quiet weight. */}
<SoundToggle /> <div className="flex items-center gap-0.5">
<ThemeToggle /> <HowToPlayDialog />
{user && <NotificationBell />} <SoundToggle />
<ThemeToggle />
{user && <NotificationBell />}
</div>
{user ? ( {user ? (
<Link <Link
to="/profile" to="/profile"
aria-current={location.pathname === '/profile' ? 'page' : undefined}
className={cn( className={cn(
'flex items-center gap-1 text-sm font-medium transition-colors hover:text-foreground', 'flex items-center gap-2 text-sm font-medium transition-colors hover:text-foreground',
location.pathname === '/profile' location.pathname === '/profile'
? 'text-foreground' ? 'text-foreground'
: 'text-muted-foreground', : 'text-muted-foreground',
)} )}
> >
<User className="h-4 w-4" /> <span
aria-hidden="true"
className="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-display text-xs font-bold uppercase text-foreground"
>
{user.username.charAt(0)}
</span>
<span className="hidden sm:inline">{user.username}</span> <span className="hidden sm:inline">{user.username}</span>
</Link> </Link>
) : ( ) : (
@@ -120,7 +140,7 @@ export default function PageLayout({
to="/login" to="/login"
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
> >
Sign In Sign in
</Link> </Link>
)} )}
{/* Desktop sign out */} {/* Desktop sign out */}
@@ -128,9 +148,10 @@ export default function PageLayout({
<button <button
type="button" type="button"
onClick={logout} onClick={logout}
className="hidden items-center gap-1 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground md:flex" aria-label="Sign out"
className="hidden h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:flex"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" aria-hidden="true" />
</button> </button>
)} )}
{/* Mobile hamburger */} {/* Mobile hamburger */}
@@ -156,6 +177,12 @@ export default function PageLayout({
key={path} key={path}
to={path} to={path}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
aria-current={
location.pathname === path ||
(path === '/friends' && location.pathname.startsWith('/friends'))
? 'page'
: undefined
}
className={cn( className={cn(
'flex items-center justify-between rounded-md px-3 py-2.5 text-base font-medium transition-colors hover:bg-muted', 'flex items-center justify-between rounded-md px-3 py-2.5 text-base font-medium transition-colors hover:bg-muted',
location.pathname === path || (path === '/friends' && location.pathname.startsWith('/friends')) location.pathname === path || (path === '/friends' && location.pathname.startsWith('/friends'))
@@ -196,7 +223,7 @@ export default function PageLayout({
className="mt-4 flex items-center gap-2 rounded-md px-3 py-2.5 text-base font-medium text-muted-foreground transition-colors hover:bg-muted" className="mt-4 flex items-center gap-2 rounded-md px-3 py-2.5 text-base font-medium text-muted-foreground transition-colors hover:bg-muted"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Sign Out Sign out
</button> </button>
)} )}
</nav> </nav>
+2 -1
View File
@@ -19,7 +19,8 @@ export default function ThemeToggle() {
return ( return (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
> >
+14 -2
View File
@@ -1,5 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { DIFFICULTY_STYLES, type Difficulty } from './difficulty-styles'; import { DIFFICULTY_PIPS, DIFFICULTY_STYLES, type Difficulty } from './difficulty-styles';
export function DifficultyBadge({ export function DifficultyBadge({
difficulty, difficulty,
@@ -11,15 +11,27 @@ export function DifficultyBadge({
const level = (difficulty as Difficulty) in DIFFICULTY_STYLES const level = (difficulty as Difficulty) in DIFFICULTY_STYLES
? (difficulty as Difficulty) ? (difficulty as Difficulty)
: 'medium'; : 'medium';
const filled = DIFFICULTY_PIPS[level];
return ( return (
<span <span
className={cn( className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold capitalize', 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium capitalize',
DIFFICULTY_STYLES[level], DIFFICULTY_STYLES[level],
className, className,
)} )}
> >
<span className="flex items-center gap-0.5" aria-hidden="true">
{[1, 2, 3].map((pip) => (
<span
key={pip}
className={cn(
'h-1.5 w-1.5 rounded-full',
pip <= filled ? 'bg-foreground' : 'bg-foreground/25',
)}
/>
))}
</span>
{difficulty} {difficulty}
</span> </span>
); );
@@ -0,0 +1,68 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { DifficultyBadge } from './difficulty-badge';
import { DIFFICULTY_PIPS } from './difficulty-styles';
/**
* The ordinal pips beside the difficulty word - the thing that now carries
* difficulty instead of colour. Kept separate from ui.test.tsx so this WP's
* addition (the pips + the DIFFICULTY_PIPS map) is easy to find on its own.
*/
function pipsFor(difficulty: string) {
const badge = screen.getByText(difficulty);
const container = badge.querySelector('span[aria-hidden="true"]');
expect(container).not.toBeNull();
return Array.from(container?.querySelectorAll('span') ?? []);
}
describe('DifficultyBadge pips', () => {
it('fills one pip for easy', () => {
render(<DifficultyBadge difficulty="easy" />);
const pips = pipsFor('easy');
expect(pips).toHaveLength(3);
const [first, ...rest] = pips;
expect(first?.className).toContain('bg-foreground');
expect(first?.className).not.toContain('bg-foreground/25');
for (const pip of rest) {
expect(pip.className).toContain('bg-foreground/25');
}
});
it('fills two pips for medium', () => {
render(<DifficultyBadge difficulty="medium" />);
const pips = pipsFor('medium');
const filled = pips.filter((p) => !p.className.includes('bg-foreground/25'));
const empty = pips.filter((p) => p.className.includes('bg-foreground/25'));
expect(filled).toHaveLength(2);
expect(empty).toHaveLength(1);
});
it('fills three pips for hard', () => {
render(<DifficultyBadge difficulty="hard" />);
const pips = pipsFor('hard');
const filled = pips.filter((p) => !p.className.includes('bg-foreground/25'));
expect(filled).toHaveLength(3);
});
it('falls back to two filled pips (medium) for an unknown difficulty', () => {
render(<DifficultyBadge difficulty="expert" />);
const pips = pipsFor('expert');
const filled = pips.filter((p) => !p.className.includes('bg-foreground/25'));
expect(filled).toHaveLength(2);
});
it('renders the pip container hidden from assistive tech', () => {
render(<DifficultyBadge difficulty="easy" />);
const badge = screen.getByText('easy');
expect(badge.querySelector('span[aria-hidden="true"]')).not.toBeNull();
});
});
describe('DIFFICULTY_PIPS', () => {
it('maps easy to 1, medium to 2 and hard to 3', () => {
expect(DIFFICULTY_PIPS.easy).toBe(1);
expect(DIFFICULTY_PIPS.medium).toBe(2);
expect(DIFFICULTY_PIPS.hard).toBe(3);
});
});
+18 -9
View File
@@ -1,21 +1,30 @@
export type Difficulty = 'easy' | 'medium' | 'hard'; export type Difficulty = 'easy' | 'medium' | 'hard';
/**
* Difficulty is monochrome. Hue used to carry it (emerald / amber / rose) but
* that is a traffic light, and it collided with the game's own colours. The
* ordinal pips and the word carry it now, which also makes it safe in both
* themes without a contrast check per hue.
*/
export const DIFFICULTY_STYLES: Record<Difficulty, string> = { export const DIFFICULTY_STYLES: Record<Difficulty, string> = {
easy: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300', easy: 'bg-muted text-foreground',
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', medium: 'bg-muted text-foreground',
hard: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300', hard: 'bg-muted text-foreground',
}; };
const DIFFICULTY_BUTTON_ACTIVE: Record<Difficulty, string> = { /** How many of the three pips are filled. */
easy: 'border-emerald-500 bg-emerald-100 text-emerald-800 dark:border-emerald-500 dark:bg-emerald-900/40 dark:text-emerald-300', export const DIFFICULTY_PIPS: Record<Difficulty, 1 | 2 | 3> = {
medium: 'border-amber-500 bg-amber-100 text-amber-800 dark:border-amber-500 dark:bg-amber-900/40 dark:text-amber-300', easy: 1,
hard: 'border-rose-500 bg-rose-100 text-rose-800 dark:border-rose-500 dark:bg-rose-900/40 dark:text-rose-300', medium: 2,
hard: 3,
}; };
const DIFFICULTY_BUTTON_ACTIVE = 'border-foreground bg-foreground text-background hover:bg-foreground hover:text-background';
export function getDifficultyButtonClass( export function getDifficultyButtonClass(
difficulty: Difficulty, _difficulty: Difficulty,
isActive: boolean, isActive: boolean,
): string { ): string {
if (!isActive) return ''; if (!isActive) return '';
return DIFFICULTY_BUTTON_ACTIVE[difficulty]; return DIFFICULTY_BUTTON_ACTIVE;
} }
@@ -1,7 +1,14 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router'; import { MemoryRouter, Route, Routes } from 'react-router';
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
// The completion modal waits for the loop-closing animation; there is none
// to wait for in jsdom, and these tests are about the content, not the delay.
vi.mock('@/lib/motion', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/motion')>()),
prefersReducedMotion: () => true,
}));
import AsyncCompletionModal from './AsyncCompletionModal'; import AsyncCompletionModal from './AsyncCompletionModal';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import type { ScoreBreakdown } from '@/types'; import type { ScoreBreakdown } from '@/types';
@@ -63,8 +70,8 @@ describe('AsyncCompletionModal', () => {
describe('creator mode', () => { describe('creator mode', () => {
it('shows the submitted score and link count', () => { it('shows the submitted score and link count', () => {
renderModal({ mode: 'creator', myScore: score(1234), myChainLength: 7 }); renderModal({ mode: 'creator', myScore: score(1234), myChainLength: 7 });
expect(screen.getByRole('heading', { name: /Score Submitted!/ })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /Score submitted/ })).toBeInTheDocument();
expect(screen.getByText('1234')).toBeInTheDocument(); expect(screen.getByText('1,234')).toBeInTheDocument();
expect(screen.getByText(/7 links/)).toBeInTheDocument(); expect(screen.getByText(/7 links/)).toBeInTheDocument();
}); });
@@ -76,7 +83,7 @@ describe('AsyncCompletionModal', () => {
it('navigates to the match leaderboard', async () => { it('navigates to the match leaderboard', async () => {
renderModal({ mode: 'creator', matchId: 'm1' }); renderModal({ mode: 'creator', matchId: 'm1' });
await userEvent.click(screen.getByRole('button', { name: 'View Leaderboard' })); await userEvent.click(screen.getByRole('button', { name: 'View leaderboard' }));
expect(screen.getByText('leaderboard route')).toBeInTheDocument(); expect(screen.getByText('leaderboard route')).toBeInTheDocument();
}); });
}); });
@@ -88,7 +95,7 @@ describe('AsyncCompletionModal', () => {
myScore: score(1000), myScore: score(1000),
creatorScore: { totalScore: 1000 }, creatorScore: { totalScore: 1000 },
}); });
expect(screen.getByText("It's a tie!")).toBeInTheDocument(); expect(screen.getByText('A tie')).toBeInTheDocument();
}); });
it('declares a win when the challenger scores higher', () => { it('declares a win when the challenger scores higher', () => {
@@ -97,7 +104,7 @@ describe('AsyncCompletionModal', () => {
myScore: score(1500), myScore: score(1500),
creatorScore: { totalScore: 1000 }, creatorScore: { totalScore: 1000 },
}); });
expect(screen.getByText('You beat the creator!')).toBeInTheDocument(); expect(screen.getByText('You beat the creator')).toBeInTheDocument();
}); });
it('declares the creator the winner otherwise', () => { it('declares the creator the winner otherwise', () => {
@@ -106,19 +113,19 @@ describe('AsyncCompletionModal', () => {
myScore: score(500), myScore: score(500),
creatorScore: { totalScore: 1000 }, creatorScore: { totalScore: 1000 },
}); });
expect(screen.getByText('Creator wins!')).toBeInTheDocument(); expect(screen.getByText('Creator wins')).toBeInTheDocument();
}); });
it('defaults a missing creator score to 0, tying against a zero score', () => { it('defaults a missing creator score to 0, tying against a zero score', () => {
renderModal({ mode: 'challenger', myScore: score(0), creatorScore: null }); renderModal({ mode: 'challenger', myScore: score(0), creatorScore: null });
expect(screen.getByText("It's a tie!")).toBeInTheDocument(); expect(screen.getByText('A tie')).toBeInTheDocument();
const zeros = screen.getAllByText('0'); const zeros = screen.getAllByText('0');
expect(zeros.length).toBeGreaterThanOrEqual(2); expect(zeros.length).toBeGreaterThanOrEqual(2);
}); });
it('beats a creator whose score defaults to 0', () => { it('beats a creator whose score defaults to 0', () => {
renderModal({ mode: 'challenger', myScore: score(50), creatorScore: null }); renderModal({ mode: 'challenger', myScore: score(50), creatorScore: null });
expect(screen.getByText('You beat the creator!')).toBeInTheDocument(); expect(screen.getByText('You beat the creator')).toBeInTheDocument();
}); });
it('shows "?" for an unknown creator chain length', () => { it('shows "?" for an unknown creator chain length', () => {
@@ -165,7 +172,7 @@ describe('AsyncCompletionModal', () => {
it('links to the full leaderboard', async () => { it('links to the full leaderboard', async () => {
renderModal({ mode: 'challenger', matchId: 'm1' }); renderModal({ mode: 'challenger', matchId: 'm1' });
await userEvent.click(screen.getByRole('button', { name: 'Full Leaderboard' })); await userEvent.click(screen.getByRole('button', { name: 'Full leaderboard' }));
expect(screen.getByText('leaderboard route')).toBeInTheDocument(); expect(screen.getByText('leaderboard route')).toBeInTheDocument();
}); });
}); });
+21 -15
View File
@@ -7,7 +7,9 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Trophy } from 'lucide-react'; import { Trophy } from 'lucide-react';
import type { ScoreBreakdown } from '@/types'; import { getLinkCount, type ScoreBreakdown } from '@/types';
import { useGameStore } from '@/stores/game-store';
import { useLoopReveal } from '@/hooks/use-loop-reveal';
interface AsyncCompletionModalProps { interface AsyncCompletionModalProps {
open: boolean; open: boolean;
@@ -31,8 +33,12 @@ export default function AsyncCompletionModal({
rank, rank,
}: AsyncCompletionModalProps) { }: AsyncCompletionModalProps) {
const navigate = useNavigate(); const navigate = useNavigate();
// The loop gets the screen to itself before the result covers it.
const gameStatus = useGameStore((s) => s.status);
const chain = useGameStore((s) => s.chain);
const revealed = useLoopReveal(gameStatus, getLinkCount(chain));
if (!open || !myScore) return null; if (!open || !revealed || !myScore) return null;
const myTotal = myScore.totalScore ?? 0; const myTotal = myScore.totalScore ?? 0;
@@ -42,13 +48,13 @@ export default function AsyncCompletionModal({
<DialogContent className="sm:max-w-md" showCloseButton={false}> <DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" /> <Trophy className="h-5 w-5 text-primary" />
Score Submitted! Score submitted
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="text-center"> <div className="text-center">
<p className="text-3xl font-bold">{myTotal}</p> <p className="font-display text-3xl font-bold tabular-nums">{myTotal.toLocaleString()}</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Your score &middot; {myChainLength} links Your score &middot; {myChainLength} links
</p> </p>
@@ -68,7 +74,7 @@ export default function AsyncCompletionModal({
className="flex-1" className="flex-1"
onClick={() => navigate(`/versus/async/${matchId}/leaderboard`)} onClick={() => navigate(`/versus/async/${matchId}/leaderboard`)}
> >
View Leaderboard View leaderboard
</Button> </Button>
</div> </div>
</div> </div>
@@ -87,31 +93,31 @@ export default function AsyncCompletionModal({
<DialogContent className="sm:max-w-md" showCloseButton={false}> <DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" /> <Trophy className="h-5 w-5 text-primary" />
Challenge Complete! Challenge complete
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="text-center"> <div className="text-center">
{tied ? ( {tied ? (
<p className="text-xl font-bold text-muted-foreground">It's a tie!</p> <p className="text-xl font-bold text-muted-foreground">A tie</p>
) : won ? ( ) : won ? (
<p className="text-xl font-bold text-green-600">You beat the creator!</p> <p className="text-xl font-bold text-primary-text">You beat the creator</p>
) : ( ) : (
<p className="text-xl font-bold text-red-500">Creator wins!</p> <p className="text-xl font-bold text-muted-foreground">Creator wins</p>
)} )}
</div> </div>
<div className="flex items-center justify-around rounded-lg border border-border p-4"> <div className="flex items-center justify-around rounded-lg bg-card p-4 ring-1 ring-foreground/10">
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground">You</p> <p className="text-sm text-muted-foreground">You</p>
<p className="text-2xl font-bold">{myTotal}</p> <p className="font-display text-2xl font-bold tabular-nums">{myTotal.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{myChainLength} links</p> <p className="text-xs text-muted-foreground">{myChainLength} links</p>
</div> </div>
<span className="text-lg font-bold text-muted-foreground">vs</span> <span className="text-lg font-bold text-muted-foreground">vs</span>
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground">Creator</p> <p className="text-sm text-muted-foreground">Creator</p>
<p className="text-2xl font-bold">{creatorTotal}</p> <p className="font-display text-2xl font-bold tabular-nums">{creatorTotal.toLocaleString()}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{creatorChainLength ?? '?'} links {creatorChainLength ?? '?'} links
</p> </p>
@@ -136,7 +142,7 @@ export default function AsyncCompletionModal({
className="flex-1" className="flex-1"
onClick={() => navigate(`/versus/async/${matchId}/leaderboard`)} onClick={() => navigate(`/versus/async/${matchId}/leaderboard`)}
> >
Full Leaderboard Full leaderboard
</Button> </Button>
</div> </div>
</div> </div>
@@ -30,7 +30,17 @@ describe('OpponentProgress', () => {
render(<OpponentProgress />); render(<OpponentProgress />);
expect(screen.getByText('Rival')).toBeInTheDocument(); expect(screen.getByText('Rival')).toBeInTheDocument();
expect(screen.getByText(/Chain: 1 links/)).toBeInTheDocument(); expect(screen.getByText(/1 links/)).toBeInTheDocument();
});
it('shows zero links, not minus one, before the opponent has made a move', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({ player1: ME, player2: RIVAL, opponentChainLength: 0 });
render(<OpponentProgress />);
expect(screen.getByText(/^0 links/)).toBeInTheDocument();
expect(screen.queryByText(/-1 links/)).toBeNull();
}); });
it('identifies the opponent as player1 when I am player2', () => { it('identifies the opponent as player1 when I am player2', () => {
@@ -52,6 +62,6 @@ describe('OpponentProgress', () => {
expect( expect(
screen.getByText('Your opponent has finished — hurry up!'), screen.getByText('Your opponent has finished — hurry up!'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByText(/Chain:/)).toBeNull(); expect(screen.queryByText(/links/)).toBeNull();
}); });
}); });
+4 -4
View File
@@ -14,25 +14,25 @@ export default function OpponentProgress() {
if (!opponent) return null; if (!opponent) return null;
return ( return (
<div className="rounded-lg border border-border bg-card p-3"> <div className="rounded-lg bg-card p-5 ring-1 ring-foreground/10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{opponent.username}</span> <span className="text-sm font-medium">{opponent.username}</span>
</div> </div>
{opponentFinished ? ( {opponentFinished ? (
<span className="flex items-center gap-1 text-xs font-medium text-green-600"> <span className="flex items-center gap-1 text-xs font-medium text-success">
<CheckCircle className="h-3.5 w-3.5" /> <CheckCircle className="h-3.5 w-3.5" />
Finished! Finished!
</span> </span>
) : ( ) : (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Chain: {rawToLinkCount(opponentChainLength)} links {rawToLinkCount(opponentChainLength)} links
</span> </span>
)} )}
</div> </div>
{opponentFinished && ( {opponentFinished && (
<p className="mt-1 text-xs text-amber-600"> <p className="mt-1 text-xs text-primary-text">
Your opponent has finished — hurry up! Your opponent has finished — hurry up!
</p> </p>
)} )}
@@ -1,7 +1,14 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router'; import { MemoryRouter, Route, Routes } from 'react-router';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
// The completion modal waits for the loop-closing animation; there is none
// to wait for in jsdom, and these tests are about the content, not the delay.
vi.mock('@/lib/motion', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/motion')>()),
prefersReducedMotion: () => true,
}));
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useVersusStore } from '@/stores/versus-store'; import { useVersusStore } from '@/stores/versus-store';
import VersusCompletionModal from './VersusCompletionModal'; import VersusCompletionModal from './VersusCompletionModal';
@@ -57,9 +64,9 @@ describe('VersusCompletionModal', () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'playing' }); useVersusStore.setState({ myScore: score(1500), lobbyState: 'playing' });
renderModal(); renderModal();
expect(screen.getByRole('dialog', { name: 'You finished!' })).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: 'You finished' })).toBeInTheDocument();
expect(screen.getByText('1500')).toBeInTheDocument(); expect(screen.getByText('1,500')).toBeInTheDocument();
expect(screen.getByText('Waiting for opponent to finish...')).toBeInTheDocument(); expect(screen.getByText('Waiting for opponent to finish…')).toBeInTheDocument();
}); });
it('defaults myTotal to 0 when the score object has no totalScore field', () => { it('defaults myTotal to 0 when the score object has no totalScore field', () => {
@@ -85,7 +92,7 @@ describe('VersusCompletionModal', () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'finished', matchResult: null }); useVersusStore.setState({ myScore: score(1500), lobbyState: 'finished', matchResult: null });
renderModal(); renderModal();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.queryByText('Match Complete!')).toBeNull(); expect(screen.queryByText('Match complete')).toBeNull();
}); });
it('declares a tie when the match has no winner', () => { it('declares a tie when the match has no winner', () => {
@@ -103,8 +110,8 @@ describe('VersusCompletionModal', () => {
}); });
renderModal(); renderModal();
expect(screen.getByRole('heading', { name: /Match Complete!/ })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /Match complete/ })).toBeInTheDocument();
expect(screen.getByText("It's a tie!")).toBeInTheDocument(); expect(screen.getByText('A tie')).toBeInTheDocument();
expect(screen.getByText('Me')).toBeInTheDocument(); expect(screen.getByText('Me')).toBeInTheDocument();
expect(screen.getByText('Rival')).toBeInTheDocument(); expect(screen.getByText('Rival')).toBeInTheDocument();
}); });
@@ -124,7 +131,7 @@ describe('VersusCompletionModal', () => {
}); });
renderModal(); renderModal();
expect(screen.getByText('You win!')).toBeInTheDocument(); expect(screen.getByText('You won')).toBeInTheDocument();
}); });
it('declares a loss when the winner is the other player', () => { it('declares a loss when the winner is the other player', () => {
@@ -142,7 +149,7 @@ describe('VersusCompletionModal', () => {
}); });
renderModal(); renderModal();
expect(screen.getByText('You lose!')).toBeInTheDocument(); expect(screen.getByText('You lost')).toBeInTheDocument();
}); });
it('defaults a missing player score to 0', () => { it('defaults a missing player score to 0', () => {
+19 -12
View File
@@ -1,5 +1,8 @@
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useVersusStore } from '@/stores/versus-store'; import { useVersusStore } from '@/stores/versus-store';
import { useGameStore } from '@/stores/game-store';
import { useLoopReveal } from '@/hooks/use-loop-reveal';
import { getLinkCount } from '@/types';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { import {
Dialog, Dialog,
@@ -17,9 +20,13 @@ export default function VersusCompletionModal() {
const matchResult = useVersusStore((s) => s.matchResult); const matchResult = useVersusStore((s) => s.matchResult);
const myScore = useVersusStore((s) => s.myScore); const myScore = useVersusStore((s) => s.myScore);
const reset = useVersusStore((s) => s.reset); const reset = useVersusStore((s) => s.reset);
// The loop gets the screen to itself before the result covers it.
const gameStatus = useGameStore((s) => s.status);
const chain = useGameStore((s) => s.chain);
const revealed = useLoopReveal(gameStatus, getLinkCount(chain));
const iAmDone = !!myScore; const iAmDone = !!myScore;
const isOpen = iAmDone; const isOpen = iAmDone && revealed;
if (!isOpen) return null; if (!isOpen) return null;
const waitingForOpponent = iAmDone && lobbyState !== 'finished'; const waitingForOpponent = iAmDone && lobbyState !== 'finished';
@@ -36,14 +43,14 @@ export default function VersusCompletionModal() {
{waitingForOpponent ? ( {waitingForOpponent ? (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>You finished!</DialogTitle> <DialogTitle>You finished</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col items-center gap-4 py-6"> <div className="flex flex-col items-center gap-4 py-6">
<p className="text-3xl font-bold">{myTotal}</p> <p className="font-display text-3xl font-bold tabular-nums">{myTotal.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Your score</p> <p className="text-sm text-muted-foreground">Your score</p>
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<span>Waiting for opponent to finish...</span> <span>Waiting for opponent to finish…</span>
</div> </div>
{/* An opponent who never finishes must not strand this player in {/* An opponent who never finishes must not strand this player in
a modal with no controls. */} a modal with no controls. */}
@@ -56,8 +63,8 @@ export default function VersusCompletionModal() {
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" /> <Trophy className="h-5 w-5 text-primary" />
Match Complete! Match complete
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
@@ -93,23 +100,23 @@ function MatchResultDisplay({
<div className="space-y-4"> <div className="space-y-4">
<div className="text-center"> <div className="text-center">
{isTie ? ( {isTie ? (
<p className="text-xl font-bold text-muted-foreground">It's a tie!</p> <p className="text-xl font-bold text-muted-foreground">A tie</p>
) : isWinner ? ( ) : isWinner ? (
<p className="text-xl font-bold text-green-600">You win!</p> <p className="text-xl font-bold text-primary-text">You won</p>
) : ( ) : (
<p className="text-xl font-bold text-red-500">You lose!</p> <p className="text-xl font-bold text-muted-foreground">You lost</p>
)} )}
</div> </div>
<div className="flex items-center justify-around rounded-lg border border-border p-4"> <div className="flex items-center justify-around rounded-lg bg-card p-4 ring-1 ring-foreground/10">
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground">{matchResult.player1.username}</p> <p className="text-sm text-muted-foreground">{matchResult.player1.username}</p>
<p className="text-2xl font-bold">{p1Score}</p> <p className="font-display text-2xl font-bold tabular-nums">{p1Score.toLocaleString()}</p>
</div> </div>
<span className="text-lg font-bold text-muted-foreground">vs</span> <span className="text-lg font-bold text-muted-foreground">vs</span>
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground">{matchResult.player2.username}</p> <p className="text-sm text-muted-foreground">{matchResult.player2.username}</p>
<p className="text-2xl font-bold">{p2Score}</p> <p className="font-display text-2xl font-bold tabular-nums">{p2Score.toLocaleString()}</p>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -76,7 +76,7 @@ describe('useGameScreen', () => {
}); });
const { result } = renderHook(() => useGameScreen()); const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('Who was in "Movie A"?'); expect(result.current.searchLabel).toBe('Who was in "Movie A"?');
expect(result.current.searchPlaceholder).toBe('Search for an actor...'); expect(result.current.searchPlaceholder).toBe('Search for an actor…');
}); });
it('prompts for a movie after the last link is an actor', () => { it('prompts for a movie after the last link is an actor', () => {
@@ -89,7 +89,7 @@ describe('useGameScreen', () => {
}); });
const { result } = renderHook(() => useGameScreen()); const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('What movie was Keanu also in?'); expect(result.current.searchLabel).toBe('What movie was Keanu also in?');
expect(result.current.searchPlaceholder).toBe('Search for a movie...'); expect(result.current.searchPlaceholder).toBe('Search for a movie…');
}); });
it('has no label when the chain is empty', () => { it('has no label when the chain is empty', () => {
+2 -2
View File
@@ -59,10 +59,10 @@ export function useGameScreen(): GameScreen {
if (currentSearchMode === 'actor' && lastLink?.type === 'movie') { if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
searchLabel = `Who was in "${lastLink.title}"?`; searchLabel = `Who was in "${lastLink.title}"?`;
searchPlaceholder = 'Search for an actor...'; searchPlaceholder = 'Search for an actor…';
} else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') { } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
searchLabel = `What movie was ${lastLink.name} also in?`; searchLabel = `What movie was ${lastLink.name} also in?`;
searchPlaceholder = 'Search for a movie...'; searchPlaceholder = 'Search for a movie…';
} }
// Hint for closing the loop // Hint for closing the loop
+46
View File
@@ -0,0 +1,46 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useImageFallback } from './use-image-fallback';
describe('useImageFallback', () => {
it('returns the given src initially', () => {
const { result } = renderHook(() => useImageFallback('/poster.jpg', '/placeholder.svg'));
expect(result.current.src).toBe('/poster.jpg');
});
it('returns the fallback when src is null', () => {
const { result } = renderHook(() => useImageFallback(null, '/placeholder.svg'));
expect(result.current.src).toBe('/placeholder.svg');
});
it('switches to the fallback after onError fires', () => {
const { result } = renderHook(() => useImageFallback('/poster.jpg', '/placeholder.svg'));
act(() => {
result.current.onError();
});
expect(result.current.src).toBe('/placeholder.svg');
});
it('remembers the failure only for that src, using a new src again', () => {
const { result, rerender } = renderHook(
({ src }) => useImageFallback(src, '/placeholder.svg'),
{ initialProps: { src: '/poster.jpg' as string | null } },
);
act(() => {
result.current.onError();
});
expect(result.current.src).toBe('/placeholder.svg');
rerender({ src: '/other.jpg' });
expect(result.current.src).toBe('/other.jpg');
});
it('does nothing on error when src is already null', () => {
const { result } = renderHook(() => useImageFallback(null, '/placeholder.svg'));
act(() => {
result.current.onError();
});
expect(result.current.src).toBe('/placeholder.svg');
});
});
+18
View File
@@ -0,0 +1,18 @@
import { useState } from 'react';
/**
* A src that swaps to a fallback when the image fails to load. TMDB image
* paths are editable on TMDB's side, so a poster that existed when a chain
* was played can 404 later; without this an old game's review, or the Home
* example loop, shows broken-image icons instead of placeholder art.
*/
export function useImageFallback(src: string | null, fallback: string) {
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const resolved = src && failedSrc !== src ? src : fallback;
return {
src: resolved,
onError: () => {
if (src) setFailedSrc(src);
},
};
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import type { GameStatus } from '@/types';
import { loopCloseDurationMs, prefersReducedMotion } from '@/lib/motion';
/**
* Whether a completion modal may open yet. When a loop has just closed on
* screen the answer is "not until the closing sequence has played"; when no
* loop closed (a forfeit, a timeout, an opponent finishing first) or the OS
* asks for reduced motion, the answer is "now".
*/
export function useLoopReveal(status: GameStatus, pairs: number): boolean {
const closed = status === 'completed';
// True at once when there is nothing to wait for; otherwise the effect
// below flips it after the sequence has run.
const [elapsed, setElapsed] = useState(() => closed && prefersReducedMotion());
const [prevStatus, setPrevStatus] = useState(status);
// Derived during render so a new completion never shows a stale value.
if (status !== prevStatus) {
setPrevStatus(status);
setElapsed(closed && prefersReducedMotion());
}
useEffect(() => {
if (!closed || prefersReducedMotion()) return;
const timer = setTimeout(() => setElapsed(true), loopCloseDurationMs(pairs));
return () => clearTimeout(timer);
}, [closed, pairs]);
return !closed || elapsed;
}
+144 -70
View File
@@ -2,82 +2,137 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "@fontsource-variable/geist"; @import "@fontsource-variable/geist";
@import "@fontsource-variable/bricolage-grotesque/standard.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* Light theme — soft warm gray with indigo accent */ /*
* Palette: the theatre, not the app.
*
* Light is the matinee (paper-white, gold deepened until dark ink reads on
* it); dark is house lights down (indigo-slate, one gold marquee light). Gold
* is the primary in BOTH themes so buttons, focus rings and the wordmark keep
* their identity when the theme toggles.
*
* The three game colours follow three-strip Technicolor: the blue record is
* Movie A, the magenta record is Movie B, the yellow record is the loop
* itself. Green is reserved for non-game "ok" states (online, ready) and never
* means "correct" - a forged link is gold, and so is the closed loop.
*
* --primary-text is the gold to use *as text*: on light it is a bronze that
* clears 4.5:1 on the page and on cards; on dark it is the primary itself.
*/
/* Light - "Matinee" */
:root { :root {
--background: oklch(0.975 0.005 260); --background: oklch(0.972 0.006 265);
--foreground: oklch(0.175 0.015 260); --foreground: oklch(0.20 0.025 268);
--card: oklch(0.995 0.002 260); --card: oklch(0.995 0.002 265);
--card-foreground: oklch(0.175 0.015 260); --card-foreground: oklch(0.20 0.025 268);
--popover: oklch(0.995 0.002 260); --popover: oklch(0.995 0.002 265);
--popover-foreground: oklch(0.175 0.015 260); --popover-foreground: oklch(0.20 0.025 268);
--primary: oklch(0.51 0.17 265); --primary: oklch(0.64 0.13 72);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.20 0.025 268);
--secondary: oklch(0.945 0.01 260); --primary-text: oklch(0.50 0.11 65);
--secondary-foreground: oklch(0.25 0.02 260); --secondary: oklch(0.94 0.01 265);
--muted: oklch(0.945 0.01 260); --secondary-foreground: oklch(0.25 0.02 265);
--muted-foreground: oklch(0.52 0.015 260); --muted: oklch(0.94 0.01 265);
--accent: oklch(0.935 0.015 260); --muted-foreground: oklch(0.50 0.02 265);
--accent-foreground: oklch(0.25 0.02 260); --accent: oklch(0.93 0.015 265);
--accent-foreground: oklch(0.25 0.02 265);
--destructive: oklch(0.58 0.22 27); --destructive: oklch(0.58 0.22 27);
--border: oklch(0.905 0.01 260); --success: oklch(0.55 0.12 160);
--input: oklch(0.905 0.01 260); --movie-a: oklch(0.48 0.17 268);
--ring: oklch(0.51 0.17 265); --movie-b: oklch(0.53 0.17 340);
--chart-1: oklch(0.65 0.18 265); --connector: oklch(0.78 0.015 265);
--chart-2: oklch(0.60 0.16 340); --connector-closed: var(--primary);
--chart-3: oklch(0.70 0.14 150); --border: oklch(0.89 0.012 265);
--chart-4: oklch(0.65 0.15 45); --input: oklch(0.89 0.012 265);
--chart-5: oklch(0.55 0.20 265); --ring: var(--primary);
--chart-1: var(--primary);
--chart-2: var(--movie-a);
--chart-3: var(--movie-b);
--chart-4: var(--success);
--chart-5: var(--muted-foreground);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.97 0.005 260); --sidebar: var(--background);
--sidebar-foreground: oklch(0.175 0.015 260); --sidebar-foreground: var(--foreground);
--sidebar-primary: oklch(0.51 0.17 265); --sidebar-primary: var(--primary);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: oklch(0.94 0.01 260); --sidebar-accent: var(--accent);
--sidebar-accent-foreground: oklch(0.25 0.02 260); --sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: oklch(0.905 0.01 260); --sidebar-border: var(--border);
--sidebar-ring: oklch(0.51 0.17 265); --sidebar-ring: var(--ring);
} }
/* Dark theme — deep slate with warm amber accent */ /* Dark - "House lights down" */
.dark { .dark {
--background: oklch(0.155 0.015 265); --background: oklch(0.20 0.03 272);
--foreground: oklch(0.935 0.01 260); --foreground: oklch(0.935 0.01 260);
--card: oklch(0.195 0.015 265); --card: oklch(0.24 0.03 272);
--card-foreground: oklch(0.935 0.01 260); --card-foreground: oklch(0.935 0.01 260);
--popover: oklch(0.195 0.015 265); --popover: oklch(0.24 0.03 272);
--popover-foreground: oklch(0.935 0.01 260); --popover-foreground: oklch(0.935 0.01 260);
--primary: oklch(0.72 0.15 75); --primary: oklch(0.72 0.15 75);
--primary-foreground: oklch(0.16 0.02 265); --primary-foreground: oklch(0.16 0.02 265);
--secondary: oklch(0.24 0.015 265); --primary-text: var(--primary);
--secondary: oklch(0.28 0.03 272);
--secondary-foreground: oklch(0.90 0.01 260); --secondary-foreground: oklch(0.90 0.01 260);
--muted: oklch(0.24 0.015 265); --muted: oklch(0.28 0.03 272);
--muted-foreground: oklch(0.62 0.01 260); --muted-foreground: oklch(0.68 0.015 265);
--accent: oklch(0.28 0.015 265); --accent: oklch(0.32 0.03 272);
--accent-foreground: oklch(0.93 0.01 260); --accent-foreground: oklch(0.93 0.01 260);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.76 0.12 160);
--movie-a: oklch(0.72 0.12 268);
--movie-b: oklch(0.75 0.13 345);
--connector: oklch(0.42 0.02 268);
--connector-closed: var(--primary);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 12%); --input: oklch(1 0 0 / 12%);
--ring: oklch(0.72 0.15 75); --ring: var(--primary);
--chart-1: oklch(0.72 0.15 75);
--chart-2: oklch(0.65 0.16 340);
--chart-3: oklch(0.70 0.14 150);
--chart-4: oklch(0.60 0.18 265);
--chart-5: oklch(0.55 0.15 45);
--sidebar: oklch(0.185 0.015 265);
--sidebar-foreground: oklch(0.935 0.01 260);
--sidebar-primary: oklch(0.72 0.15 75);
--sidebar-primary-foreground: oklch(0.16 0.02 265);
--sidebar-accent: oklch(0.26 0.015 265);
--sidebar-accent-foreground: oklch(0.93 0.01 260);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.72 0.15 75);
} }
@theme inline { @theme inline {
/*
* Two families, two jobs. Bricolage carries the personality (wordmark,
* titles, the puzzle statement, the score) and its width axis lets the
* same face run condensed in the header and wide in the hero. Geist does
* everything that has to be read quickly: body, labels, buttons, tables.
*/
--font-sans: 'Geist Variable', sans-serif; --font-sans: 'Geist Variable', sans-serif;
--font-display: 'Bricolage Grotesque Variable', 'Geist Variable', sans-serif;
/*
* Type scale. 12/14/16/18 step by two for the UI range, then open up
* (x1.22, 1.27, 1.29, 1.33, 1.33, 1.5) for display. 12px is the floor:
* nothing in the app is set smaller. 7xl+ are unset so a one-off giant
* size cannot creep back in.
*/
--text-xs: 0.75rem;
--text-xs--line-height: 1rem;
--text-sm: 0.875rem;
--text-sm--line-height: 1.25rem;
--text-base: 1rem;
--text-base--line-height: 1.5rem;
--text-lg: 1.125rem;
--text-lg--line-height: 1.625rem;
--text-xl: 1.375rem;
--text-xl--line-height: 1.75rem;
--text-2xl: 1.75rem;
--text-2xl--line-height: 2.125rem;
--text-3xl: 2.25rem;
--text-3xl--line-height: 2.5rem;
--text-4xl: 3rem;
--text-4xl--line-height: 3.25rem;
--text-5xl: 4rem;
--text-5xl--line-height: 4rem;
--text-6xl: 6rem;
--text-6xl--line-height: 6rem;
--text-7xl: initial;
--text-8xl: initial;
--text-9xl: initial;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -95,6 +150,11 @@
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-success: var(--success);
--color-movie-a: var(--movie-a);
--color-movie-b: var(--movie-b);
--color-connector: var(--connector);
--color-connector-closed: var(--connector-closed);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
@@ -102,6 +162,7 @@
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary-text: var(--primary-text);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
@@ -118,23 +179,18 @@
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
/*
* Motion. Each keyframe answers one game moment: a new row slides in, a
* rejected link shakes, a forged link flashes, the loop draws itself closed
* and the closing row glows, a countdown ticks. There is no ambient motion.
*/
@theme inline { @theme inline {
--animate-confetti: confetti-fall 2.5s ease-in forwards;
--animate-slide-in: slide-in-from-bottom 0.3s ease-out; --animate-slide-in: slide-in-from-bottom 0.3s ease-out;
--animate-shake: shake 0.4s ease-in-out; --animate-shake: shake 0.4s ease-in-out;
--animate-check-flash: check-flash 0.6s ease-out; --animate-check-flash: check-flash 0.6s ease-out;
--animate-glow-pulse: glow-pulse 1.5s ease-in-out infinite; --animate-glow-pulse: glow-pulse 1.5s ease-in-out 2;
} --animate-loop-draw: loop-draw 0.5s ease-out both;
--animate-countdown-tick: countdown-tick 0.35s ease-out;
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
} }
@keyframes slide-in-from-bottom { @keyframes slide-in-from-bottom {
@@ -162,9 +218,22 @@
100% { opacity: 0; transform: scale(1); } 100% { opacity: 0; transform: scale(1); }
} }
/* The closing row wears ring-primary (a 1px box-shadow); the glow carries that
ring inside its own shadow so the two do not fight, and ends where the ring
class leaves it. */
@keyframes glow-pulse { @keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 4px oklch(0.72 0.15 75 / 0.3); } 0%, 100% { box-shadow: 0 0 0 1px var(--primary), 0 0 4px color-mix(in oklch, var(--primary) 30%, transparent); }
50% { box-shadow: 0 0 16px oklch(0.72 0.15 75 / 0.5); } 50% { box-shadow: 0 0 0 1px var(--primary), 0 0 16px color-mix(in oklch, var(--primary) 50%, transparent); }
}
@keyframes loop-draw {
0% { transform: scaleY(0); }
100% { transform: scaleY(1); }
}
@keyframes countdown-tick {
0% { transform: scale(1.15); opacity: 0.6; }
100% { transform: scale(1); opacity: 1; }
} }
@layer base { @layer base {
@@ -177,6 +246,9 @@
html { html {
@apply font-sans; @apply font-sans;
} }
h1, h2, h3 {
text-wrap: balance;
}
button, button,
[role="button"], [role="button"],
a, a,
@@ -194,16 +266,18 @@
* transition in the app collapses to effectively instant - Tailwind's own * transition in the app collapses to effectively instant - Tailwind's own
* utilities, the custom keyframes above, and anything added later. A single * utilities, the custom keyframes above, and anything added later. A single
* rule here is more reliable than remembering a motion-reduce: variant on each * rule here is more reliable than remembering a motion-reduce: variant on each
* animated element. The confetti overlay additionally does not render at all, * animated element. The loop-draw keyframe ends fully drawn, so under this
* since a still frame of it has no meaning. * rule the closed loop simply appears.
*/ */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
*::after { *::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
transition-delay: 0ms !important;
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
} }
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import type { MovieChainLink } from '@/types';
import { EXAMPLE_LOOP } from './example-loop';
describe('EXAMPLE_LOOP', () => {
it('starts and ends the chain on movieAId', () => {
const { chain, movieAId } = EXAMPLE_LOOP;
expect((chain[0] as MovieChainLink).id).toBe(movieAId);
expect((chain[chain.length - 1] as MovieChainLink).id).toBe(movieAId);
expect(chain[0]?.type).toBe('movie');
expect(chain[chain.length - 1]?.type).toBe('movie');
});
it('contains movieBId somewhere in the chain', () => {
const { chain, movieBId } = EXAMPLE_LOOP;
expect(chain.some((link) => link.type === 'movie' && link.id === movieBId)).toBe(true);
});
it('alternates movie and actor links', () => {
const { chain } = EXAMPLE_LOOP;
for (let i = 0; i < chain.length - 1; i += 1) {
expect(chain[i]?.type).not.toBe(chain[i + 1]?.type);
}
});
it('has seven links: three actor-movie pairs plus the closing movie', () => {
expect(EXAMPLE_LOOP.chain).toHaveLength(7);
expect(EXAMPLE_LOOP.chain.filter((link) => link.type === 'actor')).toHaveLength(3);
expect(EXAMPLE_LOOP.chain.filter((link) => link.type === 'movie')).toHaveLength(4);
});
it('gives every link an image path', () => {
for (const link of EXAMPLE_LOOP.chain) {
const imagePath = link.type === 'movie' ? link.posterPath : link.profilePath;
expect(typeof imagePath).toBe('string');
expect(imagePath?.length).toBeGreaterThan(0);
}
});
});
+74
View File
@@ -0,0 +1,74 @@
import type { ChainLink } from '@/types';
/**
* The finished loop shown on the Home page: Speed to Apocalypse Now and back
* in three links, every name famous enough that the example explains itself.
*
* Every credit was verified against TMDB when this was written. The ids are
* stable; the image paths are TMDB-editable and could one day 404, which the
* cards handle by falling back to the placeholder art - so the worst case is
* a duller hero, not a broken one.
*/
export const EXAMPLE_LOOP: {
movieAId: number;
movieBId: number;
chain: ChainLink[];
} = {
movieAId: 1637,
movieBId: 28,
chain: [
{
type: 'movie',
id: 1637,
title: 'Speed',
posterPath: '/82PkCE4R95KhHICUDF7G4Ly2z3l.jpg',
releaseDate: '1994-06-09',
popularity: 14,
},
{
type: 'actor',
id: 6384,
name: 'Keanu Reeves',
profilePath: '/8RZLOyYGsoRe9p44q3xin9QkMHv.jpg',
popularity: 10,
},
{
type: 'movie',
id: 603,
title: 'The Matrix',
posterPath: '/aOIuZAjPaRIE6CMzbazvcHuHXDc.jpg',
releaseDate: '1999-03-31',
popularity: 48,
},
{
type: 'actor',
id: 2975,
name: 'Laurence Fishburne',
profilePath: '/2GbXERENPpl5MmlqOLlPVaVtifD.jpg',
popularity: 3,
},
{
type: 'movie',
id: 28,
title: 'Apocalypse Now',
posterPath: '/gQB8Y5RCMkv2zwzFHbUJX3kAhvA.jpg',
releaseDate: '1979-05-19',
popularity: 21,
},
{
type: 'actor',
id: 2778,
name: 'Dennis Hopper',
profilePath: '/kqyNvLHi5RZGZcZMxl7e6XvoB5U.jpg',
popularity: 3,
},
{
type: 'movie',
id: 1637,
title: 'Speed',
posterPath: '/82PkCE4R95KhHICUDF7G4Ly2z3l.jpg',
releaseDate: '1994-06-09',
popularity: 14,
},
],
};
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { prefersReducedMotion } from './motion';
describe('prefersReducedMotion', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('is false when window.matchMedia is undefined', () => {
vi.stubGlobal('matchMedia', undefined);
expect(prefersReducedMotion()).toBe(false);
});
it('is false when matchMedia reports no preference', () => {
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({ matches: false }),
);
expect(prefersReducedMotion()).toBe(false);
});
it('is true when matchMedia reports reduced motion', () => {
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({ matches: true }),
);
expect(prefersReducedMotion()).toBe(true);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* True when the OS asks for reduced motion. index.css already collapses every
* animation under that preference; this is for the few places that also
* change *behaviour* - a delay that exists only so an animation can be seen
* should not exist when the animation does not.
*/
export function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
/*
* The loop-closing sequence, in order: each connector segment turns gold one
* after another (staggered by row), the return line draws itself back up to
* Movie A, the closing row glows, and only then does the score arrive. The
* timings below are the one place that choreography is written down; the
* chain, the return line and every completion modal read from here so a
* longer chain simply takes longer rather than being cut off mid-sequence.
*/
/** Gap between one connector segment lighting and the next. */
export const CONNECTOR_STAGGER_MS = 60;
/** How long the return line takes to draw (matches --animate-loop-draw). */
export const LOOP_DRAW_MS = 500;
/** A beat on the finished loop before the score covers it. */
const LOOP_SETTLE_MS = 300;
/** When the return line starts drawing: once the last segment has lit. */
export function loopReturnDelayMs(pairs: number): number {
return Math.max(0, pairs - 1) * CONNECTOR_STAGGER_MS + 120;
}
/** The whole sequence, from the loop closing to the score being shown. */
export function loopCloseDurationMs(pairs: number): number {
return loopReturnDelayMs(pairs) + LOOP_DRAW_MS + LOOP_SETTLE_MS;
}
+21 -26
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { Badge } from '@/components/ui/badge'; import PageHeader from '@/components/layout/PageHeader';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import PageLayout from '@/components/layout/PageLayout'; import PageLayout from '@/components/layout/PageLayout';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -93,17 +93,12 @@ export default function Achievements() {
return ( return (
<PageLayout> <PageLayout>
<div className="space-y-6 py-6"> <PageHeader
<div className="flex items-center justify-between"> title="Achievements"
<div className="flex items-center gap-2"> description={`${unlocked.length} / ${achievements.length} unlocked`}
<Trophy className="h-5 w-5" /> />
<h1 className="text-2xl font-bold">Achievements</h1>
</div>
<Badge variant="secondary">
{unlocked.length} / {achievements.length} unlocked
</Badge>
</div>
<div className="space-y-8">
{loading && ( {loading && (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
@@ -114,33 +109,33 @@ export default function Achievements() {
{!loading && {!loading &&
Object.entries(grouped).map(([category, items]) => ( Object.entries(grouped).map(([category, items]) => (
<div key={category}> <section key={category} className="border-t border-border pt-6">
<h2 className="mb-3 text-sm font-semibold uppercase text-muted-foreground"> <h2 className="mb-4 font-display text-xl font-semibold">
{CATEGORY_LABELS[category] ?? category} {CATEGORY_LABELS[category] ?? category}
</h2> </h2>
<div className="grid gap-3 sm:grid-cols-2"> <ul className="grid gap-3 sm:grid-cols-2">
{items.map((a) => ( {items.map((a) => (
<div <li
key={a.id} key={a.id}
className={cn( className={cn(
'flex items-start gap-3 rounded-lg border p-4 transition-colors', 'flex items-start gap-3 rounded-lg bg-card p-4 ring-1',
a.unlocked a.unlocked ? 'ring-primary/40' : 'ring-foreground/10',
? 'border-green-500/30 bg-green-500/5'
: 'border-border bg-card opacity-60',
)} )}
> >
<div <div
className={cn( className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full', 'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
a.unlocked a.unlocked
? 'bg-green-500/20 text-green-600' ? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground', : 'bg-muted text-muted-foreground',
)} )}
> >
{ICON_MAP[a.icon] ?? <Trophy className="h-5 w-5" />} {ICON_MAP[a.icon] ?? <Trophy className="h-5 w-5" />}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium">{a.name}</p> <p className={cn('font-medium', !a.unlocked && 'text-muted-foreground')}>
{a.name}
</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{a.description} {a.description}
</p> </p>
@@ -154,22 +149,22 @@ export default function Achievements() {
}} }}
/> />
</div> </div>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
{a.progress} / {a.threshold} {a.progress} / {a.threshold}
</p> </p>
</div> </div>
)} )}
{a.unlocked && a.unlockedAt && ( {a.unlocked && a.unlockedAt && (
<p className="mt-1 text-xs text-green-600"> <p className="mt-1 text-xs text-primary-text">
Unlocked{' '} Unlocked{' '}
{new Date(a.unlockedAt).toLocaleDateString()} {new Date(a.unlockedAt).toLocaleDateString()}
</p> </p>
)} )}
</div> </div>
</div> </li>
))} ))}
</div> </ul>
</div> </section>
))} ))}
</div> </div>
</PageLayout> </PageLayout>
+5 -5
View File
@@ -210,7 +210,7 @@ describe('result status variants', () => {
await typeAndWait(user, 'sea'); await typeAndWait(user, 'sea');
const name = await screen.findByText('Searched User'); const name = await screen.findByText('Searched User');
const card = name.closest<HTMLElement>('div[data-slot="card"]'); const card = name.closest<HTMLElement>('li');
if (!card) throw new Error('card not found'); if (!card) throw new Error('card not found');
expect(within(card).queryByRole('button')).toBeNull(); expect(within(card).queryByRole('button')).toBeNull();
}); });
@@ -228,7 +228,7 @@ describe('sending a request', () => {
searchUsers.mockResolvedValue([ searchUsers.mockResolvedValue([
searchResult({ friendshipStatus: 'pending', friendshipDirection: 'sent' }), searchResult({ friendshipStatus: 'pending', friendshipDirection: 'sent' }),
]); ]);
await user.click(screen.getByRole('button', { name: 'Add Friend' })); await user.click(screen.getByRole('button', { name: 'Add friend' }));
expect(sendFriendRequest).toHaveBeenCalledWith({ addresseeId: 'result-1' }); expect(sendFriendRequest).toHaveBeenCalledWith({ addresseeId: 'result-1' });
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Friend request sent!')); await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Friend request sent!'));
@@ -250,7 +250,7 @@ describe('sending a request', () => {
resolveSend = resolve; resolveSend = resolve;
}), }),
); );
const addButton = screen.getByRole('button', { name: 'Add Friend' }); const addButton = screen.getByRole('button', { name: 'Add friend' });
await user.click(addButton); await user.click(addButton);
expect(addButton).toBeDisabled(); expect(addButton).toBeDisabled();
@@ -268,9 +268,9 @@ describe('sending a request', () => {
sendFriendRequest.mockRejectedValue({ sendFriendRequest.mockRejectedValue({
response: { data: { message: 'Already friends' } }, response: { data: { message: 'Already friends' } },
}); });
await user.click(screen.getByRole('button', { name: 'Add Friend' })); await user.click(screen.getByRole('button', { name: 'Add friend' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('Already friends')); await waitFor(() => expect(toastError).toHaveBeenCalledWith('Already friends'));
expect(screen.getByRole('button', { name: 'Add Friend' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Add friend' })).toBeEnabled();
}); });
}); });
+13 -20
View File
@@ -1,14 +1,14 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link, useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout'; import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useFriendsStore } from '@/stores/friends-store'; import { useFriendsStore } from '@/stores/friends-store';
import { getErrorMessage } from '@/lib/error'; import { getErrorMessage } from '@/lib/error';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ArrowLeft, Search, UserPlus, Clock, Check, Loader2 } from 'lucide-react'; import { Search, UserPlus, Clock, Check, Loader2 } from 'lucide-react';
export default function AddFriend() { export default function AddFriend() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -58,14 +58,7 @@ export default function AddFriend() {
return ( return (
<PageLayout> <PageLayout>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <PageHeader title="Add a friend" back={{ to: '/friends', label: 'Back to friends' }} />
<Link to="/friends">
<Button variant="ghost" size="sm" aria-label="Back to friends">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold">Add Friend</h1>
</div>
<div className="relative"> <div className="relative">
<Search <Search
@@ -75,21 +68,21 @@ export default function AddFriend() {
<Input <Input
type="search" type="search"
aria-label="Search by username" aria-label="Search by username"
placeholder="Search by username..." placeholder="Search by username…"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9" className="pl-9"
/> />
</div> </div>
<div className="space-y-2"> <div>
{query.trim() && searchResults.length === 0 && ( {query.trim() && searchResults.length === 0 && (
<p className="py-4 text-center text-muted-foreground">No users found</p> <p className="py-4 text-center text-muted-foreground">No users found</p>
)} )}
{searchResults.map((result) => ( <ul className="divide-y divide-border">
<Card key={result.id}> {searchResults.map((result) => (
<CardContent className="flex items-center justify-between py-3 px-4"> <li key={result.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
{(result.displayName || result.username).charAt(0).toUpperCase()} {(result.displayName || result.username).charAt(0).toUpperCase()}
@@ -121,13 +114,13 @@ export default function AddFriend() {
) : ( ) : (
<UserPlus className="mr-1 h-3.5 w-3.5" /> <UserPlus className="mr-1 h-3.5 w-3.5" />
)} )}
Add Friend Add friend
</Button> </Button>
)} )}
</div> </div>
</CardContent> </li>
</Card> ))}
))} </ul>
</div> </div>
</div> </div>
</PageLayout> </PageLayout>
+5 -5
View File
@@ -152,7 +152,7 @@ describe('access gate', () => {
it('renders nothing while auth is still loading, even with no user yet', () => { it('renders nothing while auth is still loading, even with no user yet', () => {
useAuthStore.setState({ user: null, isLoading: true }); useAuthStore.setState({ user: null, isLoading: true });
renderAdmin(); renderAdmin();
expect(screen.queryByRole('heading', { name: 'Admin Panel' })).toBeNull(); expect(screen.queryByRole('heading', { name: 'Admin' })).toBeNull();
expect(screen.queryByText('Login Page')).toBeNull(); expect(screen.queryByText('Login Page')).toBeNull();
}); });
@@ -160,7 +160,7 @@ describe('access gate', () => {
useAuthStore.setState({ user: ADMIN_USER, isLoading: false }); useAuthStore.setState({ user: ADMIN_USER, isLoading: false });
renderAdmin(); renderAdmin();
expect( expect(
await screen.findByRole('heading', { name: 'Admin Panel' }), await screen.findByRole('heading', { name: 'Admin' }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
@@ -192,7 +192,7 @@ describe('platform stats', () => {
// isAbortError guard the request-cancellation tests below rely on. // isAbortError guard the request-cancellation tests below rely on.
statsSpy.mockImplementation(() => new Promise(() => undefined)); statsSpy.mockImplementation(() => new Promise(() => undefined));
renderAdmin(); renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' }); await screen.findByRole('heading', { name: 'Admin' });
await userEvent.click(screen.getByRole('link', { name: 'Home' })); await userEvent.click(screen.getByRole('link', { name: 'Home' }));
@@ -425,7 +425,7 @@ describe('users table', () => {
() => new Promise((resolve) => { resolveUsers = resolve; }), () => new Promise((resolve) => { resolveUsers = resolve; }),
); );
renderAdmin(); renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' }); await screen.findByRole('heading', { name: 'Admin' });
await userEvent.click(screen.getByRole('link', { name: 'Home' })); await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument(); expect(await screen.findByText('Home Page')).toBeInTheDocument();
@@ -442,7 +442,7 @@ describe('users table', () => {
() => new Promise((_resolve, reject) => { rejectUsers = reject; }), () => new Promise((_resolve, reject) => { rejectUsers = reject; }),
); );
renderAdmin(); renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' }); await screen.findByRole('heading', { name: 'Admin' });
await userEvent.click(screen.getByRole('link', { name: 'Home' })); await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument(); expect(await screen.findByText('Home Page')).toBeInTheDocument();
+4 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout'; import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -26,7 +27,6 @@ import {
type BackfillUsernamesResult, type BackfillUsernamesResult,
} from '@/api/admin'; } from '@/api/admin';
import { import {
Shield,
BarChart3, BarChart3,
Users, Users,
RefreshCw, RefreshCw,
@@ -61,10 +61,7 @@ export default function Admin() {
return ( return (
<PageLayout> <PageLayout>
<div className="space-y-8 py-6"> <div className="space-y-8 py-6">
<div className="flex items-center gap-2"> <PageHeader title="Admin" />
<Shield className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">Admin Panel</h1>
</div>
<StatsSection /> <StatsSection />
<Separator /> <Separator />
@@ -327,7 +324,7 @@ function GenerateChallengeSection() {
<div> <div>
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold"> <h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
<CalendarPlus className="h-5 w-5" /> <CalendarPlus className="h-5 w-5" />
Generate Daily Challenge Generate daily challenge
</h2> </h2>
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div> <div>
@@ -439,7 +436,7 @@ function UsersSection() {
) : users.length > 0 ? ( ) : users.length > 0 ? (
<> <>
<div className="rounded-lg border border-border"> <div className="rounded-lg border border-border">
<div className="grid grid-cols-[1fr_1fr_5rem_5rem] gap-2 border-b border-border bg-muted/50 px-4 py-2 text-xs font-medium uppercase text-muted-foreground"> <div className="grid grid-cols-[1fr_1fr_5rem_5rem] gap-2 border-b border-border bg-muted/50 px-4 py-2 text-xs text-muted-foreground">
<span>Username</span> <span>Username</span>
<span>Joined</span> <span>Joined</span>
<span className="text-right">Games</span> <span className="text-right">Games</span>
+7 -7
View File
@@ -116,7 +116,7 @@ describe('a loaded leaderboard', () => {
getAsyncLeaderboard.mockResolvedValue(response()); getAsyncLeaderboard.mockResolvedValue(response());
renderPage(); renderPage();
expect(await screen.findByText('Async Challenge')).toBeInTheDocument(); expect(await screen.findByText('Async challenge')).toBeInTheDocument();
expect(screen.getByText(/Movie A \(2001\)/)).toBeInTheDocument(); expect(screen.getByText(/Movie A \(2001\)/)).toBeInTheDocument();
expect(screen.getByText(/Movie B \(2002\)/)).toBeInTheDocument(); expect(screen.getByText(/Movie B \(2002\)/)).toBeInTheDocument();
expect(screen.getByText('by Creator')).toBeInTheDocument(); expect(screen.getByText('by Creator')).toBeInTheDocument();
@@ -178,7 +178,7 @@ describe('a loaded leaderboard', () => {
// "Expired" appears twice: the countdown text itself, and the status badge. // "Expired" appears twice: the countdown text itself, and the status badge.
const matches = await screen.findAllByText('Expired'); const matches = await screen.findAllByText('Expired');
expect(matches).toHaveLength(2); expect(matches).toHaveLength(2);
expect(matches.some((el) => el.classList.contains('bg-red-100'))).toBe(true); expect(matches.some((el) => el.classList.contains('rounded-4xl'))).toBe(true);
}); });
it('renders ranked rows, marks the creator with a crown and highlights the viewer as YOU', async () => { it('renders ranked rows, marks the creator with a crown and highlights the viewer as YOU', async () => {
@@ -188,8 +188,8 @@ describe('a loaded leaderboard', () => {
expect(await screen.findAllByText('#1')).toHaveLength(2); expect(await screen.findAllByText('#1')).toHaveLength(2);
expect(screen.getByTitle('Creator')).toBeInTheDocument(); expect(screen.getByTitle('Creator')).toBeInTheDocument();
expect(screen.getByText('YOU')).toBeInTheDocument(); expect(screen.getByText('YOU')).toBeInTheDocument();
expect(screen.getByText('1000')).toBeInTheDocument(); expect(screen.getByText('1,000')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument(); expect(screen.getByText('2,400')).toBeInTheDocument();
expect(screen.getByText('2 links')).toBeInTheDocument(); expect(screen.getByText('2 links')).toBeInTheDocument();
expect(screen.getByText('3 links')).toBeInTheDocument(); expect(screen.getByText('3 links')).toBeInTheDocument();
}); });
@@ -225,9 +225,9 @@ describe('a loaded leaderboard', () => {
it('goes back to Versus from the back button', async () => { it('goes back to Versus from the back button', async () => {
getAsyncLeaderboard.mockResolvedValue(response()); getAsyncLeaderboard.mockResolvedValue(response());
renderPage(); renderPage();
await screen.findByText('Async Challenge'); await screen.findByText('Async challenge');
await userEvent.click(screen.getByRole('button', { name: /Back/ })); await userEvent.click(screen.getByRole('link', { name: /Back/ }));
expect(await screen.findByText('versus route')).toBeInTheDocument(); expect(await screen.findByText('versus route')).toBeInTheDocument();
}); });
}); });
@@ -250,7 +250,7 @@ describe('errors', () => {
}), }),
); );
renderPage(); renderPage();
await screen.findByText('Async Challenge'); await screen.findByText('Async challenge');
const pairText = screen.getByText(/Movie A.*Movie B/); const pairText = screen.getByText(/Movie A.*Movie B/);
expect(pairText.textContent).not.toContain('('); expect(pairText.textContent).not.toContain('(');
}); });
+20 -29
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout'; import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DifficultyBadge } from '@/components/ui/difficulty-badge'; import { DifficultyBadge } from '@/components/ui/difficulty-badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -10,7 +12,7 @@ import {
type AsyncLeaderboardResponse, type AsyncLeaderboardResponse,
type LeaderboardEntry, type LeaderboardEntry,
} from '@/api/versus'; } from '@/api/versus';
import { Trophy, Crown, Clock, ArrowLeft } from 'lucide-react'; import { Crown, Clock } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { rawToLinkCount } from '@/types'; import { rawToLinkCount } from '@/types';
import { releaseYear } from '@/lib/tmdb'; import { releaseYear } from '@/lib/tmdb';
@@ -70,7 +72,7 @@ export default function AsyncMatchLeaderboard() {
return ( return (
<PageLayout> <PageLayout>
<div className="flex flex-col items-center gap-4 py-12"> <div className="flex flex-col items-center gap-4 py-12">
<p className="text-red-500">{error ?? 'Match not found'}</p> <p className="text-destructive">{error ?? 'Match not found'}</p>
<Button onClick={() => navigate('/versus')}>Back to Versus</Button> <Button onClick={() => navigate('/versus')}>Back to Versus</Button>
</div> </div>
</PageLayout> </PageLayout>
@@ -82,17 +84,11 @@ export default function AsyncMatchLeaderboard() {
return ( return (
<PageLayout> <PageLayout>
<div className="space-y-6 py-6"> <div className="space-y-6">
<div className="flex items-center gap-2"> <PageHeader title="Async challenge" back={{ to: '/versus', label: 'Back' }} />
<Button variant="ghost" size="sm" onClick={() => navigate('/versus')}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back
</Button>
<h1 className="text-2xl font-bold">Async Challenge</h1>
</div>
{/* Match info */} {/* Match info */}
<div className="rounded-lg border border-border bg-card p-4"> <div className="rounded-lg bg-card p-5 ring-1 ring-foreground/10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-semibold"> <p className="font-semibold">
@@ -126,27 +122,22 @@ export default function AsyncMatchLeaderboard() {
</span> </span>
)} )}
{match.status === 'expired' && ( {match.status === 'expired' && (
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"> <Badge variant="outline">Expired</Badge>
Expired
</span>
)} )}
</div> </div>
</div> </div>
{/* Leaderboard */} {/* Leaderboard */}
<div className="rounded-lg border border-border bg-card"> <div className="border-t border-border pt-6">
<div className="border-b border-border px-4 py-3"> <div className="mb-4 flex items-center gap-2">
<div className="flex items-center gap-2"> <h2 className="font-display text-xl font-semibold">Leaderboard</h2>
<Trophy className="h-4 w-4 text-yellow-500" /> <span className="text-sm text-muted-foreground">
<h2 className="font-semibold">Leaderboard</h2> ({leaderboard.length} player{leaderboard.length !== 1 ? 's' : ''})
<span className="text-sm text-muted-foreground"> </span>
({leaderboard.length} player{leaderboard.length !== 1 ? 's' : ''})
</span>
</div>
</div> </div>
{leaderboard.length === 0 ? ( {leaderboard.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground"> <p className="py-6 text-center text-sm text-muted-foreground">
No results yet No results yet
</p> </p>
) : ( ) : (
@@ -178,9 +169,9 @@ function LeaderboardRow({
return ( return (
<div <div
className={cn( className={cn(
'flex items-center gap-3 px-4 py-3', 'flex items-center gap-3 py-3',
isMe && 'bg-primary/5', isMe && 'bg-primary/5',
entry.isCreator && 'bg-yellow-50/50 dark:bg-yellow-900/10', entry.isCreator && 'bg-primary/5',
)} )}
> >
<span className="w-8 text-center text-sm font-bold text-muted-foreground"> <span className="w-8 text-center text-sm font-bold text-muted-foreground">
@@ -192,17 +183,17 @@ function LeaderboardRow({
{entry.username} {entry.username}
</span> </span>
{entry.isCreator && ( {entry.isCreator && (
<span title="Creator"><Crown className="h-3.5 w-3.5 text-yellow-500" /></span> <span title="Creator"><Crown className="h-3.5 w-3.5 text-primary" /></span>
)} )}
{isMe && ( {isMe && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary"> <span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary">
YOU YOU
</span> </span>
)} )}
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm font-bold">{totalScore}</p> <p className="font-display text-sm font-bold tabular-nums">{totalScore.toLocaleString()}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{entry.chainLength != null ? rawToLinkCount(entry.chainLength) : '?'} links {entry.chainLength != null ? rawToLinkCount(entry.chainLength) : '?'} links
</p> </p>
+16 -9
View File
@@ -2,6 +2,13 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { Link, MemoryRouter, Route, Routes } from 'react-router'; import { Link, MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The completion modal waits for the loop-closing animation; there is none
// to wait for in jsdom, and these tests are about the content, not the delay.
vi.mock('@/lib/motion', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/motion')>()),
prefersReducedMotion: () => true,
}));
import type { import type {
ActorChainLink, ActorChainLink,
MovieChainLink, MovieChainLink,
@@ -231,7 +238,7 @@ function renderPage(initialEntry = '/versus/async-game/match-1') {
* target card + target badge), so wait on the chain-length badge instead - * target card + target badge), so wait on the chain-length badge instead -
* it renders exactly once, as soon as the game is ready to play. */ * it renders exactly once, as soon as the game is ready to play. */
async function waitForReady() { async function waitForReady() {
await screen.findByText('Chain: 0 links'); await screen.findByText('0 links');
} }
beforeEach(() => { beforeEach(() => {
@@ -364,7 +371,7 @@ describe('abort on unmount (WP cleanup)', () => {
// Switch to a different match before match-1's slow session finishes. // Switch to a different match before match-1's slow session finishes.
await userEvent.click(screen.getByText('go to match 2')); await userEvent.click(screen.getByText('go to match 2'));
await flush(); await flush();
expect(await screen.findByText('Chain: 0 links')).toBeInTheDocument(); expect(await screen.findByText('0 links')).toBeInTheDocument();
expect(useGameStore.getState().movieA?.title).toBe('Third'); expect(useGameStore.getState().movieA?.title).toBe('Third');
// match-1's delayed movie details now arrive - after being superseded. // match-1's delayed movie details now arrive - after being superseded.
@@ -391,8 +398,8 @@ describe('creator mode', () => {
expect(submitCreatorScore).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' }); expect(submitCreatorScore).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' });
expect(submitCreatorScore).toHaveBeenCalledTimes(1); expect(submitCreatorScore).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Score Submitted!')).toBeInTheDocument(); expect(await screen.findByText('Score submitted')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument(); expect(screen.getByText('2,400')).toBeInTheDocument();
}); });
it('navigates from the completion modal to the leaderboard', async () => { it('navigates from the completion modal to the leaderboard', async () => {
@@ -400,9 +407,9 @@ describe('creator mode', () => {
renderPage(); renderPage();
await waitForReady(); await waitForReady();
closeTheLoop(); closeTheLoop();
await screen.findByText('Score Submitted!'); await screen.findByText('Score submitted');
await userEvent.click(screen.getByRole('button', { name: 'View Leaderboard' })); await userEvent.click(screen.getByRole('button', { name: 'View leaderboard' }));
expect(await screen.findByText('leaderboard route')).toBeInTheDocument(); expect(await screen.findByText('leaderboard route')).toBeInTheDocument();
}); });
@@ -440,8 +447,8 @@ describe('challenger mode', () => {
expect(submitAsyncAttempt).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' }); expect(submitAsyncAttempt).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' });
expect(submitAsyncAttempt).toHaveBeenCalledTimes(1); expect(submitAsyncAttempt).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Challenge Complete!')).toBeInTheDocument(); expect(await screen.findByText('Challenge complete')).toBeInTheDocument();
expect(screen.getByText('You beat the creator!')).toBeInTheDocument(); expect(screen.getByText('You beat the creator')).toBeInTheDocument();
expect(screen.getByText('Your rank:')).toBeInTheDocument(); expect(screen.getByText('Your rank:')).toBeInTheDocument();
expect(screen.getByText('#1')).toBeInTheDocument(); expect(screen.getByText('#1')).toBeInTheDocument();
}); });
@@ -464,7 +471,7 @@ describe('challenger mode', () => {
closeTheLoop(); closeTheLoop();
await flush(); await flush();
expect(await screen.findByText('Challenge Complete!')).toBeInTheDocument(); expect(await screen.findByText('Challenge complete')).toBeInTheDocument();
expect(screen.queryByText('Your rank:')).toBeNull(); expect(screen.queryByText('Your rank:')).toBeNull();
}); });
+5 -8
View File
@@ -9,7 +9,6 @@ import ChainDisplay from '@/components/game/ChainDisplay';
import GameSearchPanel from '@/components/game/GameSearchPanel'; import GameSearchPanel from '@/components/game/GameSearchPanel';
import AsyncCompletionModal from '@/components/versus/AsyncCompletionModal'; import AsyncCompletionModal from '@/components/versus/AsyncCompletionModal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Target, Trophy } from 'lucide-react'; import { Target, Trophy } from 'lucide-react';
import { import {
@@ -201,7 +200,7 @@ export default function AsyncVersusGame() {
return ( return (
<PageLayout> <PageLayout>
<div className="flex flex-col items-center gap-4 py-12"> <div className="flex flex-col items-center gap-4 py-12">
<p className="text-red-500">{error}</p> <p className="text-destructive">{error}</p>
<Button onClick={() => navigate('/versus')}>Back to Versus</Button> <Button onClick={() => navigate('/versus')}>Back to Versus</Button>
</div> </div>
</PageLayout> </PageLayout>
@@ -214,22 +213,22 @@ export default function AsyncVersusGame() {
return ( return (
<PageLayout> <PageLayout>
<div className="space-y-4"> <div className="space-y-6">
<GameHeader /> <GameHeader />
{/* Mode indicator */} {/* Mode indicator */}
<div className="rounded-lg border border-border bg-card p-3"> <div className="rounded-lg bg-card p-5 ring-1 ring-foreground/10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{mode === 'creator' ? ( {mode === 'creator' ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-yellow-500" /> <Trophy className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Your score will be the benchmark! Your score will be the benchmark!
</span> </span>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Target className="h-4 w-4 text-blue-500" /> <Target className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Target: {targetChainLength != null ? rawToLinkCount(targetChainLength) : '?'} links Target: {targetChainLength != null ? rawToLinkCount(targetChainLength) : '?'} links
</span> </span>
@@ -238,8 +237,6 @@ export default function AsyncVersusGame() {
</div> </div>
</div> </div>
<Separator />
<ChainDisplay /> <ChainDisplay />
{status === 'playing' && <GameSearchPanel screen={screen} />} {status === 'playing' && <GameSearchPanel screen={screen} />}
+3 -3
View File
@@ -233,13 +233,13 @@ describe('friend header and initials', () => {
it('shows a typing indicator only while the friend is typing', async () => { it('shows a typing indicator only while the friend is typing', async () => {
renderChat(); renderChat();
await screen.findByText('Friendly Name'); await screen.findByText('Friendly Name');
expect(screen.queryByText('typing...')).toBeNull(); expect(screen.queryByText('typing…')).toBeNull();
useChatStore.setState({ typingFriendIds: new Set(['friend-1']) }); useChatStore.setState({ typingFriendIds: new Set(['friend-1']) });
expect(await screen.findByText('typing...')).toBeInTheDocument(); expect(await screen.findByText('typing…')).toBeInTheDocument();
useChatStore.setState({ typingFriendIds: new Set() }); useChatStore.setState({ typingFriendIds: new Set() });
await waitFor(() => expect(screen.queryByText('typing...')).toBeNull()); await waitFor(() => expect(screen.queryByText('typing…')).toBeNull());
}); });
}); });
+3 -3
View File
@@ -169,7 +169,7 @@ export default function Chat() {
<div> <div>
<p className="text-sm font-medium">{friend?.displayName || friend?.username || 'Unknown'}</p> <p className="text-sm font-medium">{friend?.displayName || friend?.username || 'Unknown'}</p>
{typingFriendIds.has(friendId!) && ( {typingFriendIds.has(friendId!) && (
<p className="text-xs text-muted-foreground">typing...</p> <p className="text-xs text-muted-foreground">typing…</p>
)} )}
</div> </div>
</div> </div>
@@ -208,7 +208,7 @@ export default function Chat() {
}`} }`}
> >
<p className="whitespace-pre-wrap break-words">{msg.content}</p> <p className="whitespace-pre-wrap break-words">{msg.content}</p>
<p className={`mt-1 text-[10px] ${isMine ? 'text-primary-foreground/60' : 'text-muted-foreground'}`}> <p className={`mt-1 text-xs ${isMine ? 'text-primary-foreground/60' : 'text-muted-foreground'}`}>
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p> </p>
</div> </div>
@@ -222,7 +222,7 @@ export default function Chat() {
<form onSubmit={handleSend} className="flex shrink-0 gap-2 border-t border-border bg-background px-4 py-3"> <form onSubmit={handleSend} className="flex shrink-0 gap-2 border-t border-border bg-background px-4 py-3">
<Input <Input
aria-label="Message" aria-label="Message"
placeholder="Type a message..." placeholder="Type a message…"
value={input} value={input}
onChange={(e) => { onChange={(e) => {
setInput(e.target.value); setInput(e.target.value);
+26 -26
View File
@@ -138,40 +138,40 @@ beforeEach(() => {
createSessionSpy.mockResolvedValue({ id: 'session-1', startedAt: 'now' }); createSessionSpy.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
}); });
it('navigates home from the Back button', async () => { it('navigates home from the Back link', async () => {
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: /Back/ })); await userEvent.click(screen.getByRole('link', { name: /Back/ }));
expect(await screen.findByText('Home Page')).toBeInTheDocument(); expect(await screen.findByText('Home Page')).toBeInTheDocument();
}); });
describe('picking movies', () => { describe('picking movies', () => {
it('shows a poster image when the picked movie has one, and none when it does not', async () => { it('shows the poster for a picked movie, falling back to placeholder art', async () => {
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
expect(await screen.findByText('Heat')).toBeInTheDocument(); expect(await screen.findByText('Heat')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Heat' })).toBeInTheDocument(); expect(screen.getByRole('img', { name: 'Heat' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
expect(await screen.findByText('Se7en')).toBeInTheDocument(); expect(await screen.findByText('Se7en')).toBeInTheDocument();
expect(screen.queryByRole('img', { name: 'Se7en' })).toBeNull(); expect(screen.getByRole('img', { name: 'Se7en' })).toHaveAttribute('src', '/placeholder-movie.svg');
}); });
it('lets either pick be changed back to search', async () => { it('lets either pick be changed back to search', async () => {
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
await screen.findByText('Heat'); await screen.findByText('Heat');
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
await screen.findByText('Se7en'); await screen.findByText('Se7en');
const [changeA, changeB] = screen.getAllByRole('button', { name: 'Change' }); const [changeA, changeB] = screen.getAllByRole('button', { name: 'Change' });
if (!changeA || !changeB) throw new Error('expected two Change buttons'); if (!changeA || !changeB) throw new Error('expected two Change buttons');
await userEvent.click(changeB); await userEvent.click(changeB);
expect(screen.getByRole('button', { name: 'Search for Movie B...' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Search for Movie B…' })).toBeInTheDocument();
expect(screen.queryByText('Se7en')).toBeNull(); expect(screen.queryByText('Se7en')).toBeNull();
await userEvent.click(changeA); await userEvent.click(changeA);
expect(screen.getByRole('button', { name: 'Search for Movie A...' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Search for Movie A…' })).toBeInTheDocument();
expect(screen.queryByText('Heat')).toBeNull(); expect(screen.queryByText('Heat')).toBeNull();
}); });
@@ -181,14 +181,14 @@ describe('picking movies', () => {
// interface, and this pins that CustomGame's placeholder for it is inert. // interface, and this pins that CustomGame's placeholder for it is inert.
renderPage(); renderPage();
await userEvent.click( await userEvent.click(
screen.getByRole('button', { name: 'Simulate actor select for "Search for Movie A..."' }), screen.getByRole('button', { name: 'Simulate actor select for "Search for Movie A…"' }),
); );
await userEvent.click( await userEvent.click(
screen.getByRole('button', { name: 'Simulate actor select for "Search for Movie B..."' }), screen.getByRole('button', { name: 'Simulate actor select for "Search for Movie B…"' }),
); );
expect(screen.getByRole('button', { name: 'Search for Movie A...' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Search for Movie A…' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Search for Movie B...' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Search for Movie B…' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
}); });
@@ -196,23 +196,23 @@ describe('picking movies', () => {
renderPage(); renderPage();
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled();
}); });
it('warns and keeps Play disabled when the same movie is picked twice', async () => { it('warns and keeps Play disabled when the same movie is picked twice', async () => {
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
await screen.findByText('Heat'); await screen.findByText('Heat');
await userEvent.click( await userEvent.click(
screen.getByRole('button', { name: 'Pick Heat for "Search for Movie B..."' }), screen.getByRole('button', { name: 'Pick Heat for "Search for Movie B…"' }),
); );
expect( expect(
await screen.findByText('Please select two different movies.'), await screen.findByText('Pick two different movies.'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
}); });
@@ -221,8 +221,8 @@ describe('picking movies', () => {
describe('starting the game', () => { describe('starting the game', () => {
it('starts a custom game and navigates to /game', async () => { it('starts a custom game and navigates to /game', async () => {
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument(); expect(await screen.findByText('Game Page')).toBeInTheDocument();
@@ -241,11 +241,11 @@ describe('starting the game', () => {
() => new Promise((resolve) => { release = () => resolve(movieDetails(1)); }), () => new Promise((resolve) => { release = () => resolve(movieDetails(1)); }),
); );
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
const loadingButton = await screen.findByRole('button', { name: 'Loading...' }); const loadingButton = await screen.findByRole('button', { name: 'Starting…' });
expect(loadingButton).toBeDisabled(); expect(loadingButton).toBeDisabled();
release(); release();
@@ -258,8 +258,8 @@ describe('starting the game', () => {
// with the message never shown. // with the message never shown.
movieDetailsSpy.mockRejectedValue(new Error('network')); movieDetailsSpy.mockRejectedValue(new Error('network'));
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent( expect(await screen.findByRole('alert')).toHaveTextContent(
+69 -93
View File
@@ -1,15 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import PageLayout from '@/components/layout/PageLayout'; import PageLayout from '@/components/layout/PageLayout';
import SearchAutocomplete from '@/components/game/SearchAutocomplete'; import SearchAutocomplete from '@/components/game/SearchAutocomplete';
import { useGameStore } from '@/stores/game-store'; import { useGameStore } from '@/stores/game-store';
import { posterUrl } from '@/lib/tmdb'; import { posterUrl } from '@/lib/tmdb';
import { Loader2, ArrowLeft, Play } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import PageHeader from '@/components/layout/PageHeader';
import MovieMarker from '@/components/game/MovieMarker';
import type { TmdbMovieResult } from '@/types'; import type { TmdbMovieResult } from '@/types';
import { cn } from '@/lib/utils';
interface SelectedMovie { interface SelectedMovie {
id: number; id: number;
@@ -73,98 +75,24 @@ export default function CustomGame() {
return ( return (
<PageLayout> <PageLayout>
<div className="mx-auto max-w-lg space-y-6 py-8"> <PageHeader
<div className="space-y-2"> title="Custom"
<Button variant="ghost" size="sm" onClick={() => navigate('/')}> description="Pick any two movies and build a loop between them."
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" /> back={{ to: '/', label: 'Back' }}
Back />
</Button>
<h1 className="text-2xl font-bold">Custom Game</h1>
<p className="text-sm text-muted-foreground">
Pick any two movies and build a chain between them.
</p>
</div>
<div className="space-y-4"> <div className="max-w-lg space-y-6">
<div className="space-y-2" role="group" aria-labelledby="custom-game-movie-a-label"> <div className="space-y-5">
<span id="custom-game-movie-a-label" className="text-sm font-medium">Movie A</span> <MovieSlot target="A" movie={movieA} onSelect={handleSelectMovieA} onClear={() => setMovieA(null)} />
{movieA ? ( <MovieSlot target="B" movie={movieB} onSelect={handleSelectMovieB} onClear={() => setMovieB(null)} />
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
{movieA.posterPath && (
<img
src={posterUrl(movieA.posterPath, 'w92')!}
alt={movieA.title}
className="h-16 w-11 rounded object-cover"
/>
)}
<div className="flex-1">
<p className="font-medium">{movieA.title}</p>
<p className="text-xs text-muted-foreground">
{movieA.releaseDate?.slice(0, 4)}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setMovieA(null)}
>
Change
</Button>
</div>
) : (
<SearchAutocomplete
mode="movie"
onSelectActor={() => {}}
onSelectMovie={handleSelectMovieA}
placeholder="Search for Movie A..."
/>
)}
</div>
<div className="space-y-2" role="group" aria-labelledby="custom-game-movie-b-label">
<span id="custom-game-movie-b-label" className="text-sm font-medium">Movie B</span>
{movieB ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
{movieB.posterPath && (
<img
src={posterUrl(movieB.posterPath, 'w92')!}
alt={movieB.title}
className="h-16 w-11 rounded object-cover"
/>
)}
<div className="flex-1">
<p className="font-medium">{movieB.title}</p>
<p className="text-xs text-muted-foreground">
{movieB.releaseDate?.slice(0, 4)}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setMovieB(null)}
>
Change
</Button>
</div>
) : (
<SearchAutocomplete
mode="movie"
onSelectActor={() => {}}
onSelectMovie={handleSelectMovieB}
placeholder="Search for Movie B..."
/>
)}
</div>
{movieA && movieB && movieA.id === movieB.id && ( {movieA && movieB && movieA.id === movieB.id && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
Please select two different movies. Pick two different movies.
</p> </p>
)} )}
</div> </div>
<Separator />
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
@@ -172,23 +100,71 @@ export default function CustomGame() {
)} )}
<Button <Button
size="lg" size="lg"
className="w-full text-base" className="h-11 px-6 text-base"
disabled={!canPlay} disabled={!canPlay}
onClick={handlePlay} onClick={handlePlay}
> >
{loading ? ( {loading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="animate-spin" aria-hidden="true" />
Loading... Starting…
</> </>
) : ( ) : (
<> 'Play'
<Play className="mr-2 h-4 w-4" />
Play
</>
)} )}
</Button> </Button>
</div> </div>
</PageLayout> </PageLayout>
); );
} }
/** One of the two pickers: a search box until a movie is chosen, then the chosen movie with its identity ring. */
function MovieSlot({
target,
movie,
onSelect,
onClear,
}: {
target: 'A' | 'B';
movie: SelectedMovie | null;
onSelect: (movie: TmdbMovieResult) => void;
onClear: () => void;
}) {
const labelId = `custom-game-movie-${target.toLowerCase()}-label`;
return (
<div className="space-y-2" role="group" aria-labelledby={labelId}>
<span id={labelId} className="inline-flex items-center gap-2 text-sm font-medium">
<span aria-hidden="true">
<MovieMarker target={target} />
</span>
Movie {target}
</span>
{movie ? (
<div className="flex items-center gap-3 rounded-lg bg-card p-2.5 ring-1 ring-foreground/10">
<img
src={posterUrl(movie.posterPath, 'w154') ?? '/placeholder-movie.svg'}
alt={movie.title}
className={cn(
'h-18 w-12 rounded-sm object-cover ring-2 ring-offset-1 ring-offset-card',
target === 'A' ? 'ring-movie-a' : 'ring-movie-b',
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-lg font-semibold">{movie.title}</p>
<p className="text-sm text-muted-foreground">{movie.releaseDate?.slice(0, 4)}</p>
</div>
<Button variant="ghost" size="sm" onClick={onClear}>
Change
</Button>
</div>
) : (
<SearchAutocomplete
mode="movie"
onSelectActor={() => {}}
onSelectMovie={onSelect}
placeholder={`Search for Movie ${target}…`}
/>
)}
</div>
);
}
+8 -8
View File
@@ -112,20 +112,20 @@ describe('loading and listing', () => {
expect(await screen.findByText('easy')).toBeInTheDocument(); expect(await screen.findByText('easy')).toBeInTheDocument();
expect(screen.getByText('medium')).toBeInTheDocument(); expect(screen.getByText('medium')).toBeInTheDocument();
expect(screen.getByText('hard')).toBeInTheDocument(); expect(screen.getByText('hard')).toBeInTheDocument();
expect(screen.getAllByText('Par: 3 links')).toHaveLength(3); expect(screen.getAllByText('Par 3')).toHaveLength(3);
}); });
it("shows the first challenge's date once loaded", async () => { it("shows the first challenge's date once loaded", async () => {
renderPage(); renderPage();
expect( expect(
await screen.findByText('Saturday, June 15, 2024'), await screen.findByText(/Saturday, June 15, 2024/),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('shows no date subheading when there are no challenges to derive one from', async () => { it('shows no date subheading when there are no challenges to derive one from', async () => {
challengesSpy.mockResolvedValue([]); challengesSpy.mockResolvedValue([]);
renderPage(); renderPage();
await screen.findByRole('heading', { name: 'Daily Challenges' }); await screen.findByRole('heading', { name: "Today's challenge" });
expect(screen.queryByText(/2024/)).toBeNull(); expect(screen.queryByText(/2024/)).toBeNull();
}); });
@@ -145,7 +145,7 @@ describe('loading and listing', () => {
}); });
renderPage(); renderPage();
expect( expect(
await screen.findByText(/haven't been generated yet/), await screen.findByText(/aren't ready yet/),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByText(/Failed to load/)).toBeNull(); expect(screen.queryByText(/Failed to load/)).toBeNull();
}); });
@@ -162,7 +162,7 @@ describe('completion state', () => {
localStorage.setItem('daily-completed-2024-06-15-medium', 'true'); localStorage.setItem('daily-completed-2024-06-15-medium', 'true');
renderPage(); renderPage();
await screen.findByText('easy'); await screen.findByText('easy');
expect(screen.getAllByRole('button', { name: 'Completed' })).toHaveLength(1); expect(screen.getAllByRole('button', { name: 'Played' })).toHaveLength(1);
}); });
it('merges server-reported completion for a signed-in user', async () => { it('merges server-reported completion for a signed-in user', async () => {
@@ -170,7 +170,7 @@ describe('completion state', () => {
completionSpy.mockResolvedValue({ easy: true, medium: false, hard: false }); completionSpy.mockResolvedValue({ easy: true, medium: false, hard: false });
renderPage(); renderPage();
expect(completionSpy).not.toHaveBeenCalled(); // not yet, until challenges resolve expect(completionSpy).not.toHaveBeenCalled(); // not yet, until challenges resolve
expect(await screen.findByRole('button', { name: 'Completed' })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Played' })).toBeInTheDocument();
}); });
it('falls back to localStorage-only when the server completion check fails', async () => { it('falls back to localStorage-only when the server completion check fails', async () => {
@@ -178,7 +178,7 @@ describe('completion state', () => {
completionSpy.mockRejectedValue(new Error('500')); completionSpy.mockRejectedValue(new Error('500'));
localStorage.setItem('daily-completed-2024-06-15-hard', 'true'); localStorage.setItem('daily-completed-2024-06-15-hard', 'true');
renderPage(); renderPage();
expect(await screen.findByRole('button', { name: 'Completed' })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Played' })).toBeInTheDocument();
// No crash, and the other two remain playable. // No crash, and the other two remain playable.
expect(screen.getAllByRole('button', { name: 'Play' })).toHaveLength(2); expect(screen.getAllByRole('button', { name: 'Play' })).toHaveLength(2);
}); });
@@ -228,7 +228,7 @@ describe('starting a challenge', () => {
if (!playButton) throw new Error('expected a play button'); if (!playButton) throw new Error('expected a play button');
await userEvent.click(playButton); await userEvent.click(playButton);
const loadingButtons = await screen.findAllByRole('button', { name: 'Loading...' }); const loadingButtons = await screen.findAllByRole('button', { name: 'Starting…' });
expect(loadingButtons).toHaveLength(3); expect(loadingButtons).toHaveLength(3);
expect(loadingButtons[0]).toBeDisabled(); expect(loadingButtons[0]).toBeDisabled();
+105 -99
View File
@@ -3,12 +3,15 @@ import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DifficultyBadge } from '@/components/ui/difficulty-badge'; import { DifficultyBadge } from '@/components/ui/difficulty-badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useGameStore } from '@/stores/game-store'; import { useGameStore } from '@/stores/game-store';
import PageLayout from '@/components/layout/PageLayout'; import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import MovieMarker from '@/components/game/MovieMarker';
import { getTodaysChallenges, type DailyChallenge as DailyChallengeType } from '@/api/daily-challenges'; import { getTodaysChallenges, type DailyChallenge as DailyChallengeType } from '@/api/daily-challenges';
import { checkDailyCompletion, type DailyCompletionStatus } from '@/api/leaderboards'; import { checkDailyCompletion, type DailyCompletionStatus } from '@/api/leaderboards';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { Loader2, Calendar, Trophy, Clock } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { storage } from '@/lib/storage'; import { storage } from '@/lib/storage';
import { getErrorStatus } from '@/lib/error'; import { getErrorStatus } from '@/lib/error';
import { releaseYear } from '@/lib/tmdb'; import { releaseYear } from '@/lib/tmdb';
@@ -19,6 +22,9 @@ export default function DailyChallenge() {
const [challenges, setChallenges] = useState<DailyChallengeType[]>([]); const [challenges, setChallenges] = useState<DailyChallengeType[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// A day with nothing generated yet is an ordinary state, kept apart from
// a real failure so it is not dressed as one.
const [notReady, setNotReady] = useState(false);
const [completed, setCompleted] = useState<Record<Difficulty, boolean>>({ const [completed, setCompleted] = useState<Record<Difficulty, boolean>>({
easy: false, easy: false,
medium: false, medium: false,
@@ -34,6 +40,10 @@ export default function DailyChallenge() {
useEffect(() => { useEffect(() => {
getTodaysChallenges() getTodaysChallenges()
.then((list) => { .then((list) => {
// A refetch (auth resolving after a direct load) replaces whatever
// the previous attempt concluded.
setError(null);
setNotReady(false);
setChallenges(list); setChallenges(list);
// Check localStorage for anonymous replay prevention // Check localStorage for anonymous replay prevention
@@ -60,17 +70,19 @@ export default function DailyChallenge() {
}); });
} }
}) })
.catch((err: unknown) => .catch((err: unknown) => {
// The API answers 404 when nothing has been generated for the day - // The API answers 404 when nothing has been generated for the day -
// a fresh install before the first midnight job, or a job that // a fresh install before the first midnight job, or a job that
// failed. That is an ordinary state, not an outage, and it used to // failed. That is an ordinary state, not an outage, and it used to
// read as one. // read as one.
setError( if (getErrorStatus(err) === 404) {
getErrorStatus(err) === 404 setError(null);
? "Today's challenges haven't been generated yet. They are created at 00:00 UTC - check back then." setNotReady(true);
: 'Failed to load today\'s challenges.', } else {
), setNotReady(false);
) setError("Failed to load today's challenges.");
}
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [userId]); }, [userId]);
@@ -118,109 +130,103 @@ export default function DailyChallenge() {
}; };
const firstChallenge = challenges[0]; const firstChallenge = challenges[0];
const resetNote = `New puzzles at 00:00 UTC, which is ${getLocalResetTime()} for you.`;
return ( return (
<PageLayout> <PageLayout>
<div className="flex flex-col items-center gap-8 py-12"> <PageHeader
<div className="space-y-2 text-center"> title="Today's challenge"
<div className="flex items-center justify-center gap-2"> description={
<Calendar className="h-5 w-5 text-muted-foreground" /> firstChallenge
<h1 className="text-2xl font-bold">Daily Challenges</h1> ? `${formatDate(firstChallenge.date)}. Three puzzles, the same for everyone. ${resetNote}`
</div> : `Three puzzles a day, the same for everyone. ${resetNote}`
{firstChallenge && ( }
<p className="text-sm text-muted-foreground"> />
{formatDate(firstChallenge.date)}
</p> {loading && (
)} <div className="space-y-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div> </div>
)}
{loading && ( {notReady && (
<div className="w-full max-w-md space-y-4"> <Alert>
<Skeleton className="h-40 w-full" /> <AlertDescription>
<Skeleton className="h-40 w-full" /> Today's puzzles aren't ready yet. They're made at 00:00 UTC ({getLocalResetTime()});
<Skeleton className="h-40 w-full" /> check back then.
</div> </AlertDescription>
)} </Alert>
)}
{error && ( {error && (
<p role="alert" className="text-destructive">{error}</p> <Alert variant="destructive" className="mb-4">
)} <AlertDescription>{error}</AlertDescription>
</Alert>
)}
{challenges.length > 0 && ( {challenges.length > 0 && (
<div className="w-full max-w-md space-y-4"> <ul className="divide-y divide-border border-y border-border">
{challenges.map((challenge) => { {challenges.map((challenge) => {
const diff = challenge.difficulty as Difficulty; const diff = challenge.difficulty as Difficulty;
const isCompleted = completed[diff]; const isCompleted = completed[diff];
return ( return (
<div <li
key={challenge.id} key={challenge.id}
className="rounded-lg border border-border bg-card p-6" className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-3 py-4 sm:grid-cols-[7rem_1fr_auto_auto] sm:gap-x-6"
> >
<div className="flex items-center justify-between"> <DifficultyBadge difficulty={challenge.difficulty} />
<DifficultyBadge difficulty={challenge.difficulty} />
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Trophy className="h-3.5 w-3.5" />
Par: {challenge.par} links
</div>
</div>
<div className="mt-4 space-y-3"> <div className="col-span-2 flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1 sm:col-span-1">
<div className="rounded-md bg-muted/50 p-3 text-center"> <span className="inline-flex items-center gap-1.5 font-medium">
<p className="text-xs font-medium uppercase text-muted-foreground"> <MovieMarker target="A" />
Movie A <span className="text-movie-a">{challenge.movieATitle}</span>
</p> {challenge.movieAReleaseDate && (
<p className="text-lg font-semibold">{challenge.movieATitle}</p> <span className="text-xs text-muted-foreground">
{challenge.movieAReleaseDate && ( {releaseYear(challenge.movieAReleaseDate)}
<p className="text-xs text-muted-foreground"> </span>
{releaseYear(challenge.movieAReleaseDate)}
</p>
)}
</div>
<div className="flex justify-center text-muted-foreground">
<span className="text-xl">&#8693;</span>
</div>
<div className="rounded-md bg-muted/50 p-3 text-center">
<p className="text-xs font-medium uppercase text-muted-foreground">
Movie B
</p>
<p className="text-lg font-semibold">{challenge.movieBTitle}</p>
{challenge.movieBReleaseDate && (
<p className="text-xs text-muted-foreground">
{releaseYear(challenge.movieBReleaseDate)}
</p>
)}
</div>
</div>
<Button
className="mt-6 w-full"
size="lg"
onClick={() => handlePlay(challenge)}
disabled={isValidating || isCompleted}
>
{isCompleted ? (
'Completed'
) : isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Play'
)} )}
</Button> </span>
<span className="text-sm text-muted-foreground">to</span>
<span className="inline-flex items-center gap-1.5 font-medium">
<MovieMarker target="B" />
<span className="text-movie-b">{challenge.movieBTitle}</span>
{challenge.movieBReleaseDate && (
<span className="text-xs text-muted-foreground">
{releaseYear(challenge.movieBReleaseDate)}
</span>
)}
</span>
</div> </div>
);
})}
<div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"> <span className="text-sm text-muted-foreground tabular-nums">
<Clock className="h-3 w-3" /> Par {challenge.par}
<span>New challenges at midnight UTC ({getLocalResetTime()} local)</span> </span>
</div>
</div> <Button
)} className="justify-self-end sm:min-w-24"
</div> variant={isCompleted ? 'outline' : 'default'}
onClick={() => handlePlay(challenge)}
disabled={isValidating || isCompleted}
>
{isCompleted ? (
'Played'
) : isValidating ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Starting…
</>
) : (
'Play'
)}
</Button>
</li>
);
})}
</ul>
)}
</PageLayout> </PageLayout>
); );
} }
+14 -14
View File
@@ -91,14 +91,14 @@ afterEach(() => {
}); });
describe('filters panel', () => { describe('filters panel', () => {
it('is hidden until "Show Filters" is toggled', async () => { it('is hidden until "Show filters" is toggled', async () => {
renderPage(); renderPage();
expect(screen.queryByText('Genre')).toBeNull(); expect(screen.queryByText('Genre')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Show Filters' })); await userEvent.click(screen.getByRole('button', { name: 'Show filters' }));
expect(screen.getByText('Genre')).toBeInTheDocument(); expect(screen.getByText('Genre')).toBeInTheDocument();
expect(screen.getByText('Decade')).toBeInTheDocument(); expect(screen.getByText('Decade')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Hide Filters' })); await userEvent.click(screen.getByRole('button', { name: 'Hide filters' }));
expect(screen.queryByText('Genre')).toBeNull(); expect(screen.queryByText('Genre')).toBeNull();
}); });
}); });
@@ -107,10 +107,10 @@ describe('starting a game', () => {
it('uses a preset pair for the chosen decade', async () => { it('uses a preset pair for the chosen decade', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0); vi.spyOn(Math, 'random').mockReturnValue(0);
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Show Filters' })); await userEvent.click(screen.getByRole('button', { name: 'Show filters' }));
await userEvent.click(screen.getByText('1970s')); await userEvent.click(screen.getByText('1970s'));
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument(); expect(await screen.findByText('Game Page')).toBeInTheDocument();
expect(discoverSpy).not.toHaveBeenCalled(); expect(discoverSpy).not.toHaveBeenCalled();
@@ -122,14 +122,14 @@ describe('starting a game', () => {
it('falls back to a TMDB discover pair once a filter is reset to "Any"', async () => { it('falls back to a TMDB discover pair once a filter is reset to "Any"', async () => {
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Show Filters' })); await userEvent.click(screen.getByRole('button', { name: 'Show filters' }));
await userEvent.click(screen.getByText('Comedy')); await userEvent.click(screen.getByText('Comedy'));
await userEvent.click(screen.getByText('1970s')); await userEvent.click(screen.getByText('1970s'));
// Reset both filters back to unfiltered before starting. // Reset both filters back to unfiltered before starting.
await userEvent.click(screen.getAllByText('Any')[0] as HTMLElement); await userEvent.click(screen.getAllByText('Any')[0] as HTMLElement);
await userEvent.click(screen.getAllByText('Any')[1] as HTMLElement); await userEvent.click(screen.getAllByText('Any')[1] as HTMLElement);
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument(); expect(await screen.findByText('Game Page')).toBeInTheDocument();
expect(discoverSpy).toHaveBeenCalledTimes(1); expect(discoverSpy).toHaveBeenCalledTimes(1);
@@ -144,11 +144,11 @@ describe('starting a game', () => {
); );
vi.spyOn(Math, 'random').mockReturnValue(0); vi.spyOn(Math, 'random').mockReturnValue(0);
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Show Filters' })); await userEvent.click(screen.getByRole('button', { name: 'Show filters' }));
await userEvent.click(screen.getByText('1970s')); await userEvent.click(screen.getByText('1970s'));
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
const loadingButton = await screen.findByRole('button', { name: 'Loading...' }); const loadingButton = await screen.findByRole('button', { name: 'Finding a pair…' });
expect(loadingButton).toBeDisabled(); expect(loadingButton).toBeDisabled();
release(); release();
@@ -162,11 +162,11 @@ describe('starting a game', () => {
discoverSpy.mockRejectedValue(new Error('Network Error')); discoverSpy.mockRejectedValue(new Error('Network Error'));
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/Network error/); expect(await screen.findByRole('alert')).toHaveTextContent(/Network error/);
expect(screen.queryByText('Game Page')).toBeNull(); expect(screen.queryByText('Game Page')).toBeNull();
expect(screen.getByRole('button', { name: 'Start Practice Game' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled();
}); });
it('explains a page with too few usable movies rather than failing silently', async () => { it('explains a page with too few usable movies rather than failing silently', async () => {
@@ -178,7 +178,7 @@ describe('starting a game', () => {
}); });
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/not return enough movies/); expect(await screen.findByRole('alert')).toHaveTextContent(/not return enough movies/);
}); });
@@ -187,7 +187,7 @@ describe('starting a game', () => {
movieDetailsSpy.mockRejectedValue(new Error('network')); movieDetailsSpy.mockRejectedValue(new Error('network'));
renderPage(); renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' })); await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent( expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to load movie details. Please try again.', 'Failed to load movie details. Please try again.',
+38 -43
View File
@@ -9,7 +9,8 @@ import { discoverMovies } from '@/api/movies';
import { pickDistinctPair } from '@/lib/movie-pair'; import { pickDistinctPair } from '@/lib/movie-pair';
import { getErrorMessage } from '@/lib/error'; import { getErrorMessage } from '@/lib/error';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, Shuffle, Filter } from 'lucide-react'; import { Loader2, Filter } from 'lucide-react';
import PageHeader from '@/components/layout/PageHeader';
export default function Endless() { export default function Endless() {
const startGame = useGameStore((s) => s.startGame); const startGame = useGameStore((s) => s.startGame);
@@ -66,27 +67,22 @@ export default function Endless() {
return ( return (
<PageLayout> <PageLayout>
<div className="flex flex-col items-center gap-8 py-12"> <PageHeader
<div className="space-y-2 text-center"> title="Endless"
<div className="flex items-center justify-center gap-2"> description="Random pairs, no clock, no leaderboard. Narrow the pairs by genre or decade if you like."
<Shuffle className="h-5 w-5 text-muted-foreground" /> />
<h1 className="text-2xl font-bold">Endless Mode</h1>
</div>
<p className="text-muted-foreground">
Practice with random movie pairs. No pressure, no leaderboard.
</p>
</div>
<div className="w-full max-w-md space-y-4"> <div className="max-w-md space-y-5">
<div className="rounded-lg border border-border bg-card p-6"> <div>
<button <button
type="button" type="button"
className="mt-4 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground" className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
> aria-expanded={showFilters}
<Filter className="h-3.5 w-3.5" /> >
{showFilters ? 'Hide Filters' : 'Show Filters'} <Filter className="h-3.5 w-3.5" aria-hidden="true" />
</button> {showFilters ? 'Hide filters' : 'Show filters'}
</button>
{showFilters && ( {showFilters && (
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
@@ -136,30 +132,29 @@ export default function Endless() {
</div> </div>
</div> </div>
</div> </div>
)}
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)} )}
<Button
size="lg"
className="w-full text-base"
onClick={handleStart}
disabled={starting || isValidating}
>
{starting || isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Start Practice Game'
)}
</Button>
</div> </div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
size="lg"
className="h-11 px-6 text-base"
onClick={handleStart}
disabled={starting || isValidating}
>
{starting || isValidating ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Finding a pair…
</>
) : (
'Play'
)}
</Button>
</div> </div>
</PageLayout> </PageLayout>
); );
+17 -13
View File
@@ -6,7 +6,7 @@ import type { FriendEntry, FriendProfile, PendingRequest } from '@/api/friends';
/** /**
* Friends page: the friends list, pending requests, remove/accept/decline, * Friends page: the friends list, pending requests, remove/accept/decline,
* online presence, and the Challenge Friend dialog (a real Dialog since * online presence, and the Challenge friend dialog (a real Dialog since
* WP-16). Requests are intercepted at `@/api/client` and routed by URL - the * WP-16). Requests are intercepted at `@/api/client` and routed by URL - the
* seam `game-store.test.ts` and `Versus.test.tsx` use. The presence socket * seam `game-store.test.ts` and `Versus.test.tsx` use. The presence socket
* lives on the (real) notification store, seeded directly rather than * lives on the (real) notification store, seeded directly rather than
@@ -173,8 +173,12 @@ describe('authentication gate', () => {
describe('friends list', () => { describe('friends list', () => {
it('shows an empty state with a link to find friends', async () => { it('shows an empty state with a link to find friends', async () => {
renderFriends(); renderFriends();
expect(await screen.findByText('No friends yet')).toBeInTheDocument(); expect(
expect(screen.getByRole('link', { name: 'Find Friends' })).toHaveAttribute( await screen.findByText(
"You haven't added anyone yet. Find a friend by username to challenge them.",
),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Find friends' })).toHaveAttribute(
'href', 'href',
'/friends/add', '/friends/add',
); );
@@ -208,13 +212,13 @@ describe('friends list', () => {
it('shows an online dot with a text alternative for a friend who is online', async () => { it('shows an online dot with a text alternative for a friend who is online', async () => {
getFriends.mockResolvedValue([friendEntry()]); getFriends.mockResolvedValue([friendEntry()]);
renderFriends(); renderFriends();
const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('div[data-slot="card"]'); const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('li');
if (!card) throw new Error('card not found'); if (!card) throw new Error('card not found');
expect(within(card).queryByText('Online')).toBeNull(); expect(within(card).queryByText('Online')).toBeNull();
useFriendsStore.getState().setOnlineFriends(['friend-1']); useFriendsStore.getState().setOnlineFriends(['friend-1']);
expect(await within(card).findByText('Online')).toBeInTheDocument(); expect(await within(card).findByText('Online')).toBeInTheDocument();
expect(card.querySelector('[aria-hidden="true"].bg-green-500')).not.toBeNull(); expect(card.querySelector('[aria-hidden="true"].bg-success')).not.toBeNull();
}); });
it('shows "No pending requests" when there are none', async () => { it('shows "No pending requests" when there are none', async () => {
@@ -258,7 +262,7 @@ describe('removing a friend', () => {
it('has a working remove control, and removes the friend from the list', async () => { it('has a working remove control, and removes the friend from the list', async () => {
getFriends.mockResolvedValueOnce([friendEntry()]); getFriends.mockResolvedValueOnce([friendEntry()]);
renderFriends(); renderFriends();
const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('div[data-slot="card"]'); const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('li');
if (!card) throw new Error('card not found'); if (!card) throw new Error('card not found');
const removeButton = within(card).getByRole('button', { const removeButton = within(card).getByRole('button', {
@@ -308,7 +312,7 @@ describe('presence', () => {
useNotificationStore.setState({ socket: socket as unknown as ReturnType<typeof useNotificationStore.getState>['socket'] }); useNotificationStore.setState({ socket: socket as unknown as ReturnType<typeof useNotificationStore.getState>['socket'] });
getFriends.mockResolvedValue([friendEntry()]); getFriends.mockResolvedValue([friendEntry()]);
renderFriends(); renderFriends();
const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('div[data-slot="card"]'); const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('li');
if (!card) throw new Error('card not found'); if (!card) throw new Error('card not found');
socket.trigger('friend-online', { userId: 'friend-1' }); socket.trigger('friend-online', { userId: 'friend-1' });
@@ -323,7 +327,7 @@ describe('presence', () => {
useNotificationStore.setState({ socket: socket as unknown as ReturnType<typeof useNotificationStore.getState>['socket'] }); useNotificationStore.setState({ socket: socket as unknown as ReturnType<typeof useNotificationStore.getState>['socket'] });
getFriends.mockResolvedValue([friendEntry()]); getFriends.mockResolvedValue([friendEntry()]);
renderFriends(); renderFriends();
const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('div[data-slot="card"]'); const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('li');
if (!card) throw new Error('card not found'); if (!card) throw new Error('card not found');
socket.trigger('friends-online', { onlineIds: ['friend-1'] }); socket.trigger('friends-online', { onlineIds: ['friend-1'] });
@@ -336,7 +340,7 @@ describe('challenge friend dialog', () => {
getFriends.mockResolvedValue([friendEntry()]); getFriends.mockResolvedValue([friendEntry()]);
renderFriends(); renderFriends();
await userEvent.click(await screen.findByRole('button', { name: 'Challenge' })); await userEvent.click(await screen.findByRole('button', { name: 'Challenge' }));
return screen.findByRole('dialog', { name: 'Challenge Friend' }); return screen.findByRole('dialog', { name: 'Challenge friend' });
} }
it('defaults to medium difficulty and async off', async () => { it('defaults to medium difficulty and async off', async () => {
@@ -353,7 +357,7 @@ describe('challenge friend dialog', () => {
const dialog = await openDialog(); const dialog = await openDialog();
await userEvent.click(within(dialog).getByRole('button', { name: 'hard' })); await userEvent.click(within(dialog).getByRole('button', { name: 'hard' }));
await userEvent.click(within(dialog).getByRole('button', { name: 'Send Challenge' })); await userEvent.click(within(dialog).getByRole('button', { name: 'Send challenge' }));
expect(createFriendChallenge).toHaveBeenCalledWith({ expect(createFriendChallenge).toHaveBeenCalledWith({
friendId: 'friend-1', friendId: 'friend-1',
@@ -369,7 +373,7 @@ describe('challenge friend dialog', () => {
const dialog = await openDialog(); const dialog = await openDialog();
await userEvent.click(within(dialog).getByLabelText('Async (play at their own pace)')); await userEvent.click(within(dialog).getByLabelText('Async (play at their own pace)'));
await userEvent.click(within(dialog).getByRole('button', { name: 'Send Challenge' })); await userEvent.click(within(dialog).getByRole('button', { name: 'Send challenge' }));
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Challenge sent!')); await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Challenge sent!'));
expect(screen.queryByRole('dialog')).toBeNull(); expect(screen.queryByRole('dialog')).toBeNull();
@@ -382,10 +386,10 @@ describe('challenge friend dialog', () => {
}); });
const dialog = await openDialog(); const dialog = await openDialog();
await userEvent.click(within(dialog).getByRole('button', { name: 'Send Challenge' })); await userEvent.click(within(dialog).getByRole('button', { name: 'Send challenge' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('Friend is offline')); await waitFor(() => expect(toastError).toHaveBeenCalledWith('Friend is offline'));
expect(screen.getByRole('dialog', { name: 'Challenge Friend' })).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: 'Challenge friend' })).toBeInTheDocument();
}); });
it('closes on Cancel without sending a challenge', async () => { it('closes on Cancel without sending a challenge', async () => {
+41 -41
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout'; import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -115,15 +115,17 @@ export default function Friends() {
return ( return (
<PageLayout> <PageLayout>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<h1 className="text-2xl font-bold">Friends</h1> title="Friends"
<Link to="/friends/add"> actions={
<Button variant="outline" size="sm"> <Link to="/friends/add">
<UserPlus className="mr-2 h-4 w-4" /> <Button variant="outline" size="sm">
Add Friend <UserPlus className="mr-2 h-4 w-4" />
</Button> Add friend
</Link> </Button>
</div> </Link>
}
/>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-2 border-b border-border"> <div className="flex gap-2 border-b border-border">
@@ -157,21 +159,21 @@ export default function Friends() {
{/* Friends List */} {/* Friends List */}
{tab === 'friends' && !loading && ( {tab === 'friends' && !loading && (
<div className="space-y-2"> <div>
{friends.length === 0 ? ( {friends.length === 0 ? (
<Card> <div className="flex flex-col items-center py-8 text-center">
<CardContent className="flex flex-col items-center py-8 text-center"> <Users className="mb-2 h-8 w-8 text-muted-foreground" />
<Users className="mb-2 h-8 w-8 text-muted-foreground" /> <p className="text-muted-foreground">
<p className="text-muted-foreground">No friends yet</p> You haven&apos;t added anyone yet. Find a friend by username to challenge them.
<Link to="/friends/add" className="mt-2"> </p>
<Button variant="outline" size="sm">Find Friends</Button> <Link to="/friends/add" className="mt-2">
</Link> <Button variant="outline" size="sm">Find friends</Button>
</CardContent> </Link>
</Card> </div>
) : ( ) : (
friends.map(({ friendshipId, friend }) => ( <ul className="divide-y divide-border">
<Card key={friendshipId}> {friends.map(({ friendshipId, friend }) => (
<CardContent className="flex items-center justify-between py-3 px-4"> <li key={friendshipId} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
@@ -181,7 +183,7 @@ export default function Friends() {
<> <>
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-card bg-green-500" className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-card bg-success"
/> />
<span className="sr-only">Online</span> <span className="sr-only">Online</span>
</> </>
@@ -222,26 +224,24 @@ export default function Friends() {
/> />
</Button> </Button>
</div> </div>
</CardContent> </li>
</Card> ))}
)) </ul>
)} )}
</div> </div>
)} )}
{/* Pending Requests */} {/* Pending Requests */}
{tab === 'pending' && !loading && ( {tab === 'pending' && !loading && (
<div className="space-y-2"> <div>
{pendingRequests.length === 0 ? ( {pendingRequests.length === 0 ? (
<Card> <p className="py-8 text-center text-muted-foreground">
<CardContent className="py-8 text-center text-muted-foreground"> No pending requests
No pending requests </p>
</CardContent>
</Card>
) : ( ) : (
pendingRequests.map(({ friendshipId, from }) => ( <ul className="divide-y divide-border">
<Card key={friendshipId}> {pendingRequests.map(({ friendshipId, from }) => (
<CardContent className="flex items-center justify-between py-3 px-4"> <li key={friendshipId} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
{(from.displayName || from.username).charAt(0).toUpperCase()} {(from.displayName || from.username).charAt(0).toUpperCase()}
@@ -274,9 +274,9 @@ export default function Friends() {
Decline Decline
</Button> </Button>
</div> </div>
</CardContent> </li>
</Card> ))}
)) </ul>
)} )}
</div> </div>
)} )}
@@ -292,7 +292,7 @@ export default function Friends() {
> >
<DialogContent className="sm:max-w-sm" showCloseButton={false}> <DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle>Challenge Friend</DialogTitle> <DialogTitle>Challenge friend</DialogTitle>
<DialogDescription> <DialogDescription>
Pick a difficulty and how the match is played. Pick a difficulty and how the match is played.
</DialogDescription> </DialogDescription>
@@ -356,7 +356,7 @@ export default function Friends() {
) : ( ) : (
<Swords className="mr-2 h-4 w-4" aria-hidden="true" /> <Swords className="mr-2 h-4 w-4" aria-hidden="true" />
)} )}
Send Challenge Send challenge
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+11 -5
View File
@@ -13,6 +13,12 @@ function unrouted(method: string, url: string): never {
throw new Error(`test reached an unrouted request: ${method} ${url}`); throw new Error(`test reached an unrouted request: ${method} ${url}`);
} }
// The score dialog waits for the loop-closing animation; there is none to
// wait for in jsdom, and these tests are about the page, not the delay.
vi.mock('@/lib/motion', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/motion')>()),
prefersReducedMotion: () => true,
}));
vi.mock('@/api/client', () => { vi.mock('@/api/client', () => {
const call = (method: string) => async (url: string) => unrouted(method, url); const call = (method: string) => async (url: string) => unrouted(method, url);
return { return {
@@ -106,7 +112,7 @@ describe('a game in progress', () => {
expect( expect(
screen.getByText('Who was in "Heat"?'), screen.getByText('Who was in "Heat"?'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByText('Congratulations!')).toBeNull(); expect(screen.queryByText('Loop closed.')).toBeNull();
}); });
it('does not redirect while a game is playing', async () => { it('does not redirect while a game is playing', async () => {
@@ -129,8 +135,8 @@ describe('a completed game', () => {
}); });
renderPage(); renderPage();
expect(await screen.findByText('Congratulations!')).toBeInTheDocument(); expect(await screen.findByText('Loop closed.')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument(); expect(screen.getByText('2,400')).toBeInTheDocument();
}); });
it('shows a calculating spinner when the score has not arrived yet', async () => { it('shows a calculating spinner when the score has not arrived yet', async () => {
@@ -146,7 +152,7 @@ describe('a completed game', () => {
}); });
renderPage(); renderPage();
expect(await screen.findByText('Calculating score...')).toBeInTheDocument(); expect(await screen.findByText('Calculating score…')).toBeInTheDocument();
}); });
it('shows the validation error instead of a spinner when scoring failed', async () => { it('shows the validation error instead of a spinner when scoring failed', async () => {
@@ -163,6 +169,6 @@ describe('a completed game', () => {
renderPage(); renderPage();
expect(await screen.findByText('Failed to calculate score.')).toBeInTheDocument(); expect(await screen.findByText('Failed to calculate score.')).toBeInTheDocument();
expect(screen.queryByText('Calculating score...')).toBeNull(); expect(screen.queryByText('Calculating score…')).toBeNull();
}); });
}); });
+1 -4
View File
@@ -6,7 +6,6 @@ import GameHeader from '@/components/game/GameHeader';
import ChainDisplay from '@/components/game/ChainDisplay'; import ChainDisplay from '@/components/game/ChainDisplay';
import GameSearchPanel from '@/components/game/GameSearchPanel'; import GameSearchPanel from '@/components/game/GameSearchPanel';
import GameCompletionModal from '@/components/game/GameCompletionModal'; import GameCompletionModal from '@/components/game/GameCompletionModal';
import { Separator } from '@/components/ui/separator';
export default function Game() { export default function Game() {
const screen = useGameScreen(); const screen = useGameScreen();
@@ -25,11 +24,9 @@ export default function Game() {
return ( return (
<PageLayout> <PageLayout>
<div className="space-y-4"> <div className="space-y-6">
<GameHeader /> <GameHeader />
<Separator />
<ChainDisplay /> <ChainDisplay />
{status === 'playing' && <GameSearchPanel screen={screen} />} {status === 'playing' && <GameSearchPanel screen={screen} />}
+37 -37
View File
@@ -187,13 +187,13 @@ describe('logged out', () => {
expect( expect(
await screen.findByText('You need to be logged in to play Game Night.'), await screen.findByText('You need to be logged in to play Game Night.'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Browse Rooms' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Browse rooms' })).toBeNull();
}); });
it('sends the Log In button to the login route', async () => { it('sends the Sign in button to the login route', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderPage(); renderPage();
await user.click(await screen.findByRole('button', { name: 'Log In' })); await user.click(await screen.findByRole('button', { name: 'Sign in' }));
expect(await screen.findByText('login page')).toBeInTheDocument(); expect(await screen.findByText('login page')).toBeInTheDocument();
}); });
}); });
@@ -201,7 +201,7 @@ describe('logged out', () => {
it('sends Back to the home route regardless of auth', async () => { it('sends Back to the home route regardless of auth', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderPage(); renderPage();
await user.click(screen.getByRole('button', { name: /Back/ })); await user.click(screen.getByRole('link', { name: /Back/ }));
expect(await screen.findByText('home page')).toBeInTheDocument(); expect(await screen.findByText('home page')).toBeInTheDocument();
}); });
@@ -215,11 +215,11 @@ describe('browse rooms', () => {
renderPage(); renderPage();
expect(screen.getByRole('button', { name: /Refresh/ })).toBeDisabled(); expect(screen.getByRole('button', { name: /Refresh/ })).toBeDisabled();
expect(screen.queryByText('No open rooms. Create one!')).toBeNull(); expect(screen.queryByText('No rooms open. Host one and send friends the link.')).toBeNull();
gate.resolve([]); gate.resolve([]);
expect( expect(
await screen.findByText('No open rooms. Create one!'), await screen.findByText('No rooms open. Host one and send friends the link.'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Refresh/ })).toBeEnabled(); expect(screen.getByRole('button', { name: /Refresh/ })).toBeEnabled();
}); });
@@ -228,7 +228,7 @@ describe('browse rooms', () => {
loginUser(); loginUser();
const user = userEvent.setup(); const user = userEvent.setup();
renderPage(); renderPage();
await screen.findByText('No open rooms. Create one!'); await screen.findByText('No rooms open. Host one and send friends the link.');
expect(getOpenRoomsApi).toHaveBeenCalledTimes(1); expect(getOpenRoomsApi).toHaveBeenCalledTimes(1);
getOpenRoomsApi.mockResolvedValue([room({ roomName: 'Second Fetch' })]); getOpenRoomsApi.mockResolvedValue([room({ roomName: 'Second Fetch' })]);
@@ -384,19 +384,19 @@ describe('create room', () => {
async function openCreateTab() { async function openCreateTab() {
const user = userEvent.setup(); const user = userEvent.setup();
renderPage(); renderPage();
await user.click(await screen.findByRole('button', { name: 'Create Room' })); await user.click(await screen.findByRole('button', { name: 'Create room' }));
return user; return user;
} }
/** /**
* "Create Room" names both the tab toggle (always present) and the form's * "Create room" names both the tab toggle (always present) and the form's
* submit button (only once the create tab is open) - the toggle is always * submit button (only once the create tab is open) - the toggle is always
* the first of the two in document order. * the first of the two in document order.
*/ */
function submitButton() { function submitButton() {
const buttons = screen.getAllByRole('button', { name: /Create Room/ }); const buttons = screen.getAllByRole('button', { name: /Create room/ });
const submit = buttons[1]; const submit = buttons[1];
if (!submit) throw new Error('expected the Create Room submit button'); if (!submit) throw new Error('expected the Create room submit button');
return submit; return submit;
} }
@@ -405,22 +405,22 @@ describe('create room', () => {
nextMovieSelection = movieResult(100, 'Movie A'); nextMovieSelection = movieResult(100, 'Movie A');
}); });
it('disables Create Room until two distinct movies are chosen', async () => { it('disables Create room until two distinct movies are chosen', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
expect(submitButton()).toBeDisabled(); expect(submitButton()).toBeDisabled();
nextMovieSelection = movieResult(100, 'Movie A'); nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
expect(submitButton()).toBeDisabled(); expect(submitButton()).toBeDisabled();
nextMovieSelection = movieResult(200, 'Movie B'); nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
expect(submitButton()).toBeEnabled(); expect(submitButton()).toBeEnabled();
}); });
it('shows a poster once a movie is picked, and clears it via Change', async () => { it('shows a poster once a movie is picked, and clears it via Change', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
const group = screen.getByRole('group', { name: 'Movie A' }); const group = screen.getByRole('group', { name: 'Movie A' });
expect(within(group).getByRole('img', { name: 'Movie A' })).toHaveAttribute( expect(within(group).getByRole('img', { name: 'Movie A' })).toHaveAttribute(
@@ -430,14 +430,14 @@ describe('create room', () => {
await user.click(within(group).getByRole('button', { name: 'Change' })); await user.click(within(group).getByRole('button', { name: 'Change' }));
expect( expect(
screen.getByRole('button', { name: 'Search for Movie A...' }), screen.getByRole('button', { name: 'Search for Movie A…' }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('omits the poster image when the movie has none', async () => { it('omits the poster image when the movie has none', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A', null); nextMovieSelection = movieResult(100, 'Movie A', null);
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
const group = screen.getByRole('group', { name: 'Movie A' }); const group = screen.getByRole('group', { name: 'Movie A' });
expect(within(group).queryByRole('img')).toBeNull(); expect(within(group).queryByRole('img')).toBeNull();
@@ -446,32 +446,32 @@ describe('create room', () => {
it('clears movie B via its own Change button', async () => { it('clears movie B via its own Change button', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
nextMovieSelection = movieResult(200, 'Movie B'); nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
const group = screen.getByRole('group', { name: 'Movie B' }); const group = screen.getByRole('group', { name: 'Movie B' });
expect(within(group).getByRole('button', { name: 'Change' })).toBeInTheDocument(); expect(within(group).getByRole('button', { name: 'Change' })).toBeInTheDocument();
await user.click(within(group).getByRole('button', { name: 'Change' })); await user.click(within(group).getByRole('button', { name: 'Change' }));
expect( expect(
screen.getByRole('button', { name: 'Search for Movie B...' }), screen.getByRole('button', { name: 'Search for Movie B…' }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('returns to the browse tab from the create tab', async () => { it('returns to the browse tab from the create tab', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
expect(screen.getByRole('heading', { name: 'Create Room' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Create room' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Browse Rooms' })); await user.click(screen.getByRole('button', { name: 'Browse rooms' }));
expect(screen.getByText('Open Rooms')).toBeInTheDocument(); expect(screen.getByText('Open rooms')).toBeInTheDocument();
}); });
it('warns when the same movie is chosen for both slots', async () => { it('warns when the same movie is chosen for both slots', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Same Movie'); nextMovieSelection = movieResult(100, 'Same Movie');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
nextMovieSelection = movieResult(100, 'Same Movie'); nextMovieSelection = movieResult(100, 'Same Movie');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
expect( expect(
screen.getByText('Please select two different movies.'), screen.getByText('Please select two different movies.'),
@@ -493,21 +493,21 @@ describe('create room', () => {
it('updates the max-players label as the slider moves', async () => { it('updates the max-players label as the slider moves', async () => {
await openCreateTab(); await openCreateTab();
const slider = screen.getByLabelText(/Max Players:/); const slider = screen.getByLabelText(/Max players:/);
// userEvent has no reliable range-input driver; fireEvent is standard here. // userEvent has no reliable range-input driver; fireEvent is standard here.
const { fireEvent } = await import('@testing-library/react'); const { fireEvent } = await import('@testing-library/react');
fireEvent.change(slider, { target: { value: '12' } }); fireEvent.change(slider, { target: { value: '12' } });
expect(screen.getByText('Max Players: 12')).toBeInTheDocument(); expect(screen.getByText('Max players: 12')).toBeInTheDocument();
}); });
it('creates a room with the selected options and goes to its lobby', async () => { it('creates a room with the selected options and goes to its lobby', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A'); nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
nextMovieSelection = movieResult(200, 'Movie B'); nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
await user.type(screen.getByLabelText('Room Name (optional)'), 'Fam Night'); await user.type(screen.getByLabelText('Room name (optional)'), 'Fam Night');
await user.type(screen.getByLabelText('Password (optional)'), 'shh'); await user.type(screen.getByLabelText('Password (optional)'), 'shh');
await user.click(screen.getByRole('switch', { name: 'I want to play' })); // -> spectating await user.click(screen.getByRole('switch', { name: 'I want to play' })); // -> spectating
await user.click(screen.getByRole('button', { name: '10 min' })); await user.click(screen.getByRole('button', { name: '10 min' }));
@@ -532,9 +532,9 @@ describe('create room', () => {
it('omits room name, password and time limit when left at their defaults', async () => { it('omits room name, password and time limit when left at their defaults', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A'); nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
nextMovieSelection = movieResult(200, 'Movie B'); nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
createRoomApi.mockResolvedValue(room({ id: 'new-room' })); createRoomApi.mockResolvedValue(room({ id: 'new-room' }));
await user.click(submitButton()); await user.click(submitButton());
@@ -549,18 +549,18 @@ describe('create room', () => {
expect(body.timeoutMinutes).toBeUndefined(); expect(body.timeoutMinutes).toBeUndefined();
}); });
it('shows "Creating..." and disables the button while the request is in flight', async () => { it('shows "Creating…" and disables the button while the request is in flight', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A'); nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
nextMovieSelection = movieResult(200, 'Movie B'); nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
const gate = deferred<GameNightRoom>(); const gate = deferred<GameNightRoom>();
createRoomApi.mockReturnValue(gate.promise); createRoomApi.mockReturnValue(gate.promise);
await user.click(submitButton()); await user.click(submitButton());
const button = screen.getByRole('button', { name: /Creating.../ }); const button = screen.getByRole('button', { name: /Creating…/ });
expect(button).toBeDisabled(); expect(button).toBeDisabled();
gate.resolve(room({ id: 'new-room' })); gate.resolve(room({ id: 'new-room' }));
@@ -570,9 +570,9 @@ describe('create room', () => {
it('re-enables the form when room creation fails', async () => { it('re-enables the form when room creation fails', async () => {
const user = await openCreateTab(); const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A'); nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie A…' }));
nextMovieSelection = movieResult(200, 'Movie B'); nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' })); await user.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
createRoomApi.mockRejectedValue(new Error('500')); createRoomApi.mockRejectedValue(new Error('500'));
await user.click(submitButton()); await user.click(submitButton());
+29 -36
View File
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import PageLayout from '@/components/layout/PageLayout'; import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import SearchAutocomplete from '@/components/game/SearchAutocomplete'; import SearchAutocomplete from '@/components/game/SearchAutocomplete';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { createRoom, getOpenRooms, joinRoom } from '@/api/game-night'; import { createRoom, getOpenRooms, joinRoom } from '@/api/game-night';
@@ -12,7 +13,6 @@ import { getErrorMessage } from '@/lib/error';
import type { GameNightRoom } from '@/api/game-night'; import type { GameNightRoom } from '@/api/game-night';
import type { TmdbMovieResult } from '@/types'; import type { TmdbMovieResult } from '@/types';
import { import {
ArrowLeft,
Loader2, Loader2,
Lock, Lock,
Plus, Plus,
@@ -146,20 +146,15 @@ export default function GameNight() {
return ( return (
<PageLayout> <PageLayout>
<div className="mx-auto max-w-2xl space-y-6 py-8"> <div className="space-y-6">
<div className="space-y-2"> <PageHeader
<Button variant="ghost" size="sm" onClick={() => navigate('/')}> title="Game night"
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" /> description="Compete with friends on the same movie pair. 2-16 players."
Back back={{ to: '/', label: 'Back' }}
</Button> />
<h1 className="text-2xl font-bold">Game Night</h1>
<p className="text-sm text-muted-foreground">
Compete with friends on the same movie pair. 2-16 players.
</p>
</div>
{!user && ( {!user && (
<div className="rounded-lg border border-border bg-card p-4 text-center"> <div className="rounded-lg bg-card p-5 ring-1 ring-foreground/10 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
You need to be logged in to play Game Night. You need to be logged in to play Game Night.
</p> </p>
@@ -169,7 +164,7 @@ export default function GameNight() {
className="mt-2" className="mt-2"
onClick={() => navigate('/login')} onClick={() => navigate('/login')}
> >
Log In Sign in
</Button> </Button>
</div> </div>
)} )}
@@ -182,7 +177,7 @@ export default function GameNight() {
size="sm" size="sm"
onClick={() => setTab('browse')} onClick={() => setTab('browse')}
> >
Browse Rooms Browse rooms
</Button> </Button>
<Button <Button
variant={tab === 'create' ? 'default' : 'outline'} variant={tab === 'create' ? 'default' : 'outline'}
@@ -190,16 +185,14 @@ export default function GameNight() {
onClick={() => setTab('create')} onClick={() => setTab('create')}
> >
<Plus className="mr-1.5 h-3.5 w-3.5" /> <Plus className="mr-1.5 h-3.5 w-3.5" />
Create Room Create room
</Button> </Button>
</div> </div>
<Separator />
{tab === 'browse' && ( {tab === 'browse' && (
<div className="space-y-4"> <div className="space-y-4 border-t border-border pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Open Rooms</h2> <h2 className="font-display text-xl font-semibold">Open rooms</h2>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -215,7 +208,7 @@ export default function GameNight() {
{openRooms.length === 0 && !loadingRooms && ( {openRooms.length === 0 && !loadingRooms && (
<p className="text-center text-muted-foreground py-8"> <p className="text-center text-muted-foreground py-8">
No open rooms. Create one! No rooms open. Host one and send friends the link.
</p> </p>
)} )}
@@ -225,11 +218,11 @@ export default function GameNight() {
</div> </div>
)} )}
<div className="space-y-3"> <div className="divide-y divide-border">
{openRooms.map((room) => ( {openRooms.map((room) => (
<div <div
key={room.id} key={room.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-4" className="flex items-center justify-between py-3"
> >
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -279,7 +272,7 @@ export default function GameNight() {
{/* Password prompt dialog */} {/* Password prompt dialog */}
{joiningRoom && ( {joiningRoom && (
<div className="rounded-lg border border-border bg-card p-4 space-y-3"> <div className="rounded-lg bg-card p-5 ring-1 ring-foreground/10 space-y-3">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
This room requires a password This room requires a password
</p> </p>
@@ -292,7 +285,7 @@ export default function GameNight() {
value={joinPassword} value={joinPassword}
onChange={(e) => setJoinPassword(e.target.value)} onChange={(e) => setJoinPassword(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Enter password..." placeholder="Enter password…"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" onClick={handleJoinWithPassword}> <Button size="sm" onClick={handleJoinWithPassword}>
@@ -312,13 +305,13 @@ export default function GameNight() {
)} )}
{tab === 'create' && ( {tab === 'create' && (
<div className="space-y-4"> <div className="space-y-4 border-t border-border pt-6">
<h2 className="text-lg font-semibold">Create Room</h2> <h2 className="font-display text-xl font-semibold">Create room</h2>
<div className="space-y-2" role="group" aria-labelledby="game-night-movie-a-label"> <div className="space-y-2" role="group" aria-labelledby="game-night-movie-a-label">
<span id="game-night-movie-a-label" className="text-sm font-medium">Movie A</span> <span id="game-night-movie-a-label" className="text-sm font-medium">Movie A</span>
{movieA ? ( {movieA ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3"> <div className="flex items-center gap-3 rounded-lg bg-card p-5 ring-1 ring-foreground/10">
{movieA.posterPath && ( {movieA.posterPath && (
<img <img
src={posterUrl(movieA.posterPath, 'w92')!} src={posterUrl(movieA.posterPath, 'w92')!}
@@ -345,7 +338,7 @@ export default function GameNight() {
mode="movie" mode="movie"
onSelectActor={() => {}} onSelectActor={() => {}}
onSelectMovie={handleSelectMovieA} onSelectMovie={handleSelectMovieA}
placeholder="Search for Movie A..." placeholder="Search for Movie A…"
/> />
)} )}
</div> </div>
@@ -353,7 +346,7 @@ export default function GameNight() {
<div className="space-y-2" role="group" aria-labelledby="game-night-movie-b-label"> <div className="space-y-2" role="group" aria-labelledby="game-night-movie-b-label">
<span id="game-night-movie-b-label" className="text-sm font-medium">Movie B</span> <span id="game-night-movie-b-label" className="text-sm font-medium">Movie B</span>
{movieB ? ( {movieB ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3"> <div className="flex items-center gap-3 rounded-lg bg-card p-5 ring-1 ring-foreground/10">
{movieB.posterPath && ( {movieB.posterPath && (
<img <img
src={posterUrl(movieB.posterPath, 'w92')!} src={posterUrl(movieB.posterPath, 'w92')!}
@@ -380,7 +373,7 @@ export default function GameNight() {
mode="movie" mode="movie"
onSelectActor={() => {}} onSelectActor={() => {}}
onSelectMovie={handleSelectMovieB} onSelectMovie={handleSelectMovieB}
placeholder="Search for Movie B..." placeholder="Search for Movie B…"
/> />
)} )}
</div> </div>
@@ -395,7 +388,7 @@ export default function GameNight() {
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="game-night-room-name" className="text-sm font-medium"> <label htmlFor="game-night-room-name" className="text-sm font-medium">
Room Name (optional) Room name (optional)
</label> </label>
<input <input
id="game-night-room-name" id="game-night-room-name"
@@ -448,7 +441,7 @@ export default function GameNight() {
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="game-night-max-players" className="text-sm font-medium"> <label htmlFor="game-night-max-players" className="text-sm font-medium">
Max Players: {maxPlayers} Max players: {maxPlayers}
</label> </label>
<input <input
id="game-night-max-players" id="game-night-max-players"
@@ -462,7 +455,7 @@ export default function GameNight() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<span id="game-night-time-limit-label" className="text-sm font-medium">Time Limit</span> <span id="game-night-time-limit-label" className="text-sm font-medium">Time limit</span>
<div className="flex flex-wrap gap-2" role="group" aria-labelledby="game-night-time-limit-label"> <div className="flex flex-wrap gap-2" role="group" aria-labelledby="game-night-time-limit-label">
{[null, 5, 10, 15, 30].map((mins) => ( {[null, 5, 10, 15, 30].map((mins) => (
<Button <Button
@@ -490,10 +483,10 @@ export default function GameNight() {
{creating ? ( {creating ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating... Creating…
</> </>
) : ( ) : (
'Create Room' 'Create room'
)} )}
</Button> </Button>
</div> </div>
+14 -14
View File
@@ -188,7 +188,7 @@ describe('connecting', () => {
it('shows a connecting indicator and joins the room once the socket connects', async () => { it('shows a connecting indicator and joins the room once the socket connects', async () => {
renderLobby(); renderLobby();
expect(createSocketMock).toHaveBeenCalledWith('/game-night'); expect(createSocketMock).toHaveBeenCalledWith('/game-night');
expect(screen.getByText('Connecting...')).toBeInTheDocument(); expect(screen.getByText('Connecting…')).toBeInTheDocument();
act(() => socket().fire('connect')); act(() => socket().fire('connect'));
@@ -218,8 +218,8 @@ describe('lobby - non-host', () => {
expect(await screen.findByText('Fun Room')).toBeInTheDocument(); expect(await screen.findByText('Fun Room')).toBeInTheDocument();
expect(screen.getByText('2/4')).toBeInTheDocument(); expect(screen.getByText('2/4')).toBeInTheDocument();
expect(screen.getByText('15m limit')).toBeInTheDocument(); expect(screen.getByText('15m limit')).toBeInTheDocument();
expect(screen.getByText('Waiting for host to start...')).toBeInTheDocument(); expect(screen.getByText('Waiting for host to start…')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Start Game/ })).toBeNull(); expect(screen.queryByRole('button', { name: /Start game/ })).toBeNull();
await user.click(screen.getByRole('button', { name: 'Ready' })); await user.click(screen.getByRole('button', { name: 'Ready' }));
expect(socket().emit).toHaveBeenCalledWith('toggle-ready', { roomId: 'room-1' }); expect(socket().emit).toHaveBeenCalledWith('toggle-ready', { roomId: 'room-1' });
@@ -238,7 +238,7 @@ describe('lobby - non-host', () => {
}), }),
); );
await user.click(await screen.findByRole('button', { name: /Leave Room/ })); await user.click(await screen.findByRole('button', { name: /Leave room/ }));
expect(socket().emit).toHaveBeenCalledWith('leave-room', { roomId: 'room-1' }); expect(socket().emit).toHaveBeenCalledWith('leave-room', { roomId: 'room-1' });
expect(await screen.findByText('game night home')).toBeInTheDocument(); expect(await screen.findByText('game night home')).toBeInTheDocument();
@@ -260,11 +260,11 @@ describe('lobby - non-host', () => {
}); });
describe('lobby - host', () => { describe('lobby - host', () => {
it('disables Start Game and explains why with fewer than two active players', async () => { it('disables Start game and explains why with fewer than two active players', async () => {
renderLobby(); renderLobby();
connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] })); connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] }));
const startButton = await screen.findByRole('button', { name: /Start Game/ }); const startButton = await screen.findByRole('button', { name: /Start game/ });
expect(startButton).toBeDisabled(); expect(startButton).toBeDisabled();
expect( expect(
screen.getByText('Need at least 2 active players'), screen.getByText('Need at least 2 active players'),
@@ -297,10 +297,10 @@ describe('lobby - host', () => {
}), }),
); );
expect(await screen.findByText('Waiting for all players to ready up')).toBeInTheDocument(); expect(await screen.findByText('Waiting for all players to ready up')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Start Game/ })).toBeDisabled(); expect(screen.getByRole('button', { name: /Start game/ })).toBeDisabled();
}); });
it('enables Start Game once all non-host players are ready, and starts the countdown', async () => { it('enables Start game once all non-host players are ready, and starts the countdown', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderLobby(); renderLobby();
connectAndJoin( connectAndJoin(
@@ -313,7 +313,7 @@ describe('lobby - host', () => {
}), }),
); );
const startButton = await screen.findByRole('button', { name: /Start Game/ }); const startButton = await screen.findByRole('button', { name: /Start game/ });
expect(startButton).toBeEnabled(); expect(startButton).toBeEnabled();
await user.click(startButton); await user.click(startButton);
@@ -325,7 +325,7 @@ describe('lobby - host', () => {
renderLobby(); renderLobby();
connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] })); connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] }));
await user.click(await screen.findByRole('button', { name: /Cancel Room/ })); await user.click(await screen.findByRole('button', { name: /Cancel room/ }));
expect(socket().emit).toHaveBeenCalledWith('leave-room', { roomId: 'room-1' }); expect(socket().emit).toHaveBeenCalledWith('leave-room', { roomId: 'room-1' });
expect(await screen.findByText('game night home')).toBeInTheDocument(); expect(await screen.findByText('game night home')).toBeInTheDocument();
@@ -338,15 +338,15 @@ describe('countdown', () => {
it('announces the countdown assertively and hides the lobby roster', async () => { it('announces the countdown assertively and hides the lobby roster', async () => {
renderLobby(); renderLobby();
connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] })); connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] }));
await screen.findByRole('button', { name: /Start Game/ }); await screen.findByRole('button', { name: /Start game/ });
act(() => socket().fire('countdown', { value: 3 })); act(() => socket().fire('countdown', { value: 3 }));
const timer = screen.getByRole('timer'); const timer = screen.getByRole('timer');
expect(timer).toHaveAttribute('aria-live', 'assertive'); expect(timer).toHaveAttribute('aria-live', 'assertive');
expect(timer).toHaveTextContent('3'); expect(timer).toHaveTextContent('3');
expect(screen.getByText('Get ready...')).toBeInTheDocument(); expect(screen.getByText('Get ready…')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Start Game/ })).toBeNull(); expect(screen.queryByRole('button', { name: /Start game/ })).toBeNull();
}); });
}); });
@@ -430,7 +430,7 @@ describe('unmount', () => {
it('unregisters its socket listeners', async () => { it('unregisters its socket listeners', async () => {
const { unmount } = renderLobby(); const { unmount } = renderLobby();
connectAndJoin(); connectAndJoin();
await screen.findByText(/Waiting for host|Start Game/); await screen.findByText(/Waiting for host|Start game/);
unmount(); unmount();
+9 -6
View File
@@ -128,10 +128,13 @@ export default function GameNightLobby() {
{/* A 3-2-1 that launches the game is time-critical, so it is {/* A 3-2-1 that launches the game is time-critical, so it is
announced assertively; without this a screen-reader user was announced assertively; without this a screen-reader user was
dropped into a running match with no warning. */} dropped into a running match with no warning. */}
<div className="animate-pulse text-8xl font-black text-primary motion-reduce:animate-none"> <div
key={countdownValue}
className="font-display text-6xl font-extrabold tabular-nums font-stretch-condensed animate-countdown-tick text-primary motion-reduce:animate-none"
>
{countdownValue} {countdownValue}
</div> </div>
<p className="mt-4 text-lg text-muted-foreground">Get ready...</p> <p className="mt-4 text-lg text-muted-foreground">Get ready…</p>
</div> </div>
)} )}
@@ -177,11 +180,11 @@ export default function GameNightLobby() {
disabled={!canStart} disabled={!canStart}
> >
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
Start Game Start game
</Button> </Button>
) : ( ) : (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Waiting for host to start... Waiting for host to start…
</p> </p>
)} )}
@@ -195,7 +198,7 @@ export default function GameNightLobby() {
<Button variant="ghost" size="sm" onClick={handleLeave}> <Button variant="ghost" size="sm" onClick={handleLeave}>
<X className="mr-1.5 h-3.5 w-3.5" /> <X className="mr-1.5 h-3.5 w-3.5" />
{isHost ? 'Cancel Room' : 'Leave Room'} {isHost ? 'Cancel room' : 'Leave room'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -206,7 +209,7 @@ export default function GameNightLobby() {
lobbyState !== 'countdown' && ( lobbyState !== 'countdown' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
<span className="text-muted-foreground">Connecting...</span> <span className="text-muted-foreground">Connecting…</span>
</div> </div>
)} )}
</div> </div>
+11 -4
View File
@@ -2,6 +2,13 @@ import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router'; import { MemoryRouter, Routes, Route } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The completion modal waits for the loop-closing animation; there is none
// to wait for in jsdom, and these tests are about the content, not the delay.
vi.mock('@/lib/motion', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/motion')>()),
prefersReducedMotion: () => true,
}));
import type { MovieChainLink, ScoreBreakdown } from '@/types'; import type { MovieChainLink, ScoreBreakdown } from '@/types';
/** /**
@@ -127,7 +134,7 @@ describe('while playing', () => {
useGameNightStore.setState({ isSpectator: false }); useGameNightStore.setState({ isSpectator: false });
renderPage(); renderPage();
expect(screen.getByRole('button', { name: /I Give Up/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Give up/ })).toBeInTheDocument();
}); });
it('hides the search panel and forfeit control for a spectator', () => { it('hides the search panel and forfeit control for a spectator', () => {
@@ -135,16 +142,16 @@ describe('while playing', () => {
useGameNightStore.setState({ isSpectator: true }); useGameNightStore.setState({ isSpectator: true });
renderPage(); renderPage();
expect(screen.queryByRole('button', { name: /I Give Up/ })).toBeNull(); expect(screen.queryByRole('button', { name: /Give up/ })).toBeNull();
}); });
it('sends a forfeit when "I Give Up" is clicked', async () => { it('sends a forfeit when "Give up" is clicked', async () => {
playingState(); playingState();
useGameNightStore.setState({ isSpectator: false }); useGameNightStore.setState({ isSpectator: false });
const user = userEvent.setup(); const user = userEvent.setup();
renderPage(); renderPage();
await user.click(screen.getByRole('button', { name: /I Give Up/ })); await user.click(screen.getByRole('button', { name: /Give up/ }));
expect(sendPlayerForfeit).toHaveBeenCalledTimes(1); expect(sendPlayerForfeit).toHaveBeenCalledTimes(1);
}); });
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more