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
+2872 -1832

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]
## [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
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/
├── api/ one module per backend resource, all through api/client.ts (axios)
├── 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/
│ ├── layout/ PageLayout, nav, NotificationBell
│ ├── layout/ PageLayout, PageHeader, LogoMark, nav, NotificationBell
│ ├── profile/ PushNotificationsSection
│ └── ui/ shadcn primitives (Base UI)
├── hooks/ use-game-screen (the shared game-page shell), use-chain-validation, use-timer, use-hints, use-push-subscription
├── lib/ config, error, format, movie-pair(s), push, sounds, create-socket, storage
├── 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), example-loop (the Home hero), motion, push, sounds, create-socket, storage
├── pages/ one file per route
├── stores/ Zustand: auth, game, versus, game-night, chat, friends, notifications
├── types/
└── App.tsx routes (lazy) + Suspense
public/
├── 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/
```
### 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
`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
`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
+2 -1
View File
@@ -2,12 +2,13 @@
<html lang="en">
<head>
<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="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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="theme-color" content="#1e1b30" />
<meta name="theme-color" content="#111524" />
<link rel="manifest" href="/manifest.json" />
<!-- Open Graph -->
+12 -2
View File
@@ -1,15 +1,16 @@
{
"name": "movie-loop-frontend",
"version": "2.2.3",
"version": "2.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "movie-loop-frontend",
"version": "2.2.3",
"version": "2.3.0",
"license": "MIT",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource-variable/geist": "^5.2.8",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
@@ -1684,6 +1685,15 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"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": {
"version": "5.3.0",
"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",
"private": true,
"version": "2.2.3",
"version": "2.3.0",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",
@@ -20,6 +20,7 @@
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource-variable/geist": "^5.2.8",
"axios": "^1.13.6",
"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",
"start_url": "/",
"display": "standalone",
"background_color": "#1e1b30",
"theme_color": "#1e1b30",
"background_color": "#111524",
"theme_color": "#111524",
"icons": [
{
"src": "/icon-192.png",
+6 -4
View File
@@ -72,7 +72,7 @@ describe('routing', () => {
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
expect(
await screen.findByRole('heading', { name: 'Daily Challenges' }),
await screen.findByRole('heading', { name: "Today's challenge" }),
).toBeInTheDocument();
expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull();
});
@@ -83,7 +83,7 @@ describe('routing', () => {
render(<App />);
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();
expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull();
});
@@ -93,8 +93,10 @@ describe('routing', () => {
render(<App />);
expect(await screen.findByText('404')).toBeInTheDocument();
expect(screen.getByText('Page not found.')).toBeInTheDocument();
// The 404 page is a lazy chunk; under coverage instrumentation it can
// 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 userEvent from '@testing-library/user-event';
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 { useGameNightStore } from '@/stores/game-night-store';
import GameNightCompletionModal from './GameNightCompletionModal';
@@ -72,18 +79,18 @@ describe('GameNightCompletionModal', () => {
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) });
renderModal();
expect(screen.getByRole('heading', { name: 'Chain Complete!' })).toBeInTheDocument();
expect(screen.getByText('1200')).toBeInTheDocument();
expect(screen.getByText('Your Score')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Chain complete' })).toBeInTheDocument();
expect(screen.getByText('1,200')).toBeInTheDocument();
expect(screen.getByText('Your score')).toBeInTheDocument();
});
it('shows no score panel when the store has none yet', () => {
useGameStore.setState({ status: 'completed', score: null });
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', () => {
@@ -94,7 +101,7 @@ describe('GameNightCompletionModal', () => {
});
renderModal();
expect(screen.getByText('Waiting for 1 player...')).toBeInTheDocument();
expect(screen.getByText('Waiting for 1 player…')).toBeInTheDocument();
expect(screen.getByText('Rival')).toBeInTheDocument();
});
@@ -106,7 +113,7 @@ describe('GameNightCompletionModal', () => {
});
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', () => {
@@ -157,12 +164,12 @@ describe('GameNightCompletionModal', () => {
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' });
renderModal('/game-night/room1');
expect(screen.getByRole('heading', { name: 'Game Over!' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'View Results' }));
expect(screen.getByRole('heading', { name: 'Game over' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'View results' }));
expect(screen.getByText('results route')).toBeInTheDocument();
});
@@ -170,7 +177,7 @@ describe('GameNightCompletionModal', () => {
useGameNightStore.setState({ lobbyState: 'finished', roomId: null });
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();
});
@@ -1,5 +1,7 @@
import { useNavigate } from 'react-router';
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 { Button } from '@/components/ui/button';
import {
@@ -13,6 +15,9 @@ import { CheckCircle2, Flag, Loader2 } from 'lucide-react';
export default function GameNightCompletionModal() {
const status = useGameStore((s) => s.status);
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 lobbyState = useGameNightStore((s) => s.lobbyState);
@@ -23,7 +28,7 @@ export default function GameNightCompletionModal() {
const leaveRoom = useGameNightStore((s) => s.leaveRoom);
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 waitingCount = activePlayers.filter(
@@ -49,15 +54,15 @@ export default function GameNightCompletionModal() {
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{lobbyState === 'finished' ? 'Game Over!' : 'Chain Complete!'}
{lobbyState === 'finished' ? 'Game over' : 'Chain complete'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{score && (
<div className="rounded-lg border border-border bg-card p-4 text-center">
<p className="text-3xl font-bold">{score.totalScore}</p>
<p className="text-sm text-muted-foreground">Your Score</p>
<div className="rounded-lg bg-card p-5 ring-1 ring-foreground/10 text-center">
<p className="font-display text-3xl font-bold tabular-nums">{score.totalScore.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Your score</p>
</div>
)}
@@ -67,24 +72,24 @@ export default function GameNightCompletionModal() {
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">
Waiting for {waitingCount} player
{waitingCount !== 1 ? 's' : ''}...
{waitingCount !== 1 ? 's' : ''}…
</span>
</div>
<div className="space-y-1">
<div className="divide-y divide-border">
{activePlayers.map((player) => {
const isFinished = finishedPlayers.has(player.userId);
const isForfeited = forfeitedPlayers.has(player.userId);
return (
<div
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>
{isForfeited ? (
<Flag className="h-3.5 w-3.5 text-destructive" />
) : 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" />
)}
@@ -106,7 +111,7 @@ export default function GameNightCompletionModal() {
{lobbyState === 'finished' && (
<Button className="w-full" onClick={handleViewResults}>
View Results
View results
</Button>
)}
</div>
@@ -81,10 +81,10 @@ describe('PlayerCard', () => {
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: theirs } = render(<PlayerCard player={player()} isCurrentUser={false} />);
expect(mine.firstElementChild?.className).toContain('border-primary');
expect(theirs.firstElementChild?.className).toContain('border-border');
expect(mine.firstElementChild?.className).toContain('ring-primary');
expect(theirs.firstElementChild?.className).toContain('ring-foreground/10');
});
});
+4 -4
View File
@@ -16,15 +16,15 @@ export default function PlayerCard({
}: PlayerCardProps) {
return (
<div
className={`rounded-lg border bg-card p-4 space-y-2 ${
isCurrentUser ? 'border-primary' : 'border-border'
className={`rounded-lg bg-card p-5 space-y-2 ring-1 ${
isCurrentUser ? 'ring-2 ring-primary' : 'ring-foreground/10'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="font-medium">{player.user.username}</p>
{player.isHost && (
<Crown className="h-4 w-4 text-yellow-500" />
<Crown className="h-4 w-4 text-primary" />
)}
</div>
{player.isSpectator ? (
@@ -33,7 +33,7 @@ export default function PlayerCard({
Spectator
</Badge>
) : 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" />
)}
@@ -15,7 +15,7 @@ export default function PlayersProgress() {
<h3 className="text-sm font-semibold">Players</h3>
{/* Polite, so a player finishing or forfeiting is announced without
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) => {
const chainLength = playerProgress.get(player.userId) ?? 0;
const isFinished = finishedPlayers.has(player.userId);
@@ -24,13 +24,13 @@ export default function PlayersProgress() {
return (
<div
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">
{isForfeited ? (
<Flag className="h-4 w-4 text-destructive" aria-hidden="true" />
) : 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 ? (
<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 { useImageFallback } from '@/hooks/use-image-fallback';
import type { ActorChainLink } from '@/types';
interface ActorCardProps {
@@ -7,29 +9,24 @@ interface 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">
<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
src={imgSrc || '/placeholder-person.svg'}
src={img.src}
onError={img.onError}
alt={actor.name}
className="h-10 w-10 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="text-sm font-medium leading-tight break-words">{actor.name}</p>
</div>
);
}
return (
<div className="flex items-center gap-3 rounded-lg border border-border p-2">
<img
src={imgSrc || '/placeholder-person.svg'}
alt={actor.name}
className="h-12 w-12 shrink-0 rounded-full object-cover"
/>
<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>
);
}
@@ -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.getByText('Actor 1')).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', () => {
@@ -56,15 +56,17 @@ describe('ChainDisplay in static mode (a finished chain passed as props)', () =>
];
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('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', () => {
const chain: ChainLink[] = [MOVIE_A, actorLink(1)];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
expect(screen.queryByText('Validating...')).toBeNull();
expect(screen.queryByText('Checking…')).toBeNull();
expect(screen.queryByText('?')).toBeNull();
});
});
@@ -86,7 +88,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
});
render(<ChainDisplay />);
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', () => {
@@ -100,7 +102,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
render(<ChainDisplay />);
// Two "Validating..." labels: the pending-movie slot and (since the chain
// 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', () => {
@@ -112,7 +114,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
isValidating: true,
});
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', () => {
@@ -124,7 +126,7 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
isValidating: true,
});
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', () => {
@@ -136,8 +138,8 @@ describe('ChainDisplay in live mode (reading the game store)', () => {
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.queryByText('Validating...')).toBeNull();
expect(screen.getByText('Loop Complete!')).toBeInTheDocument();
expect(screen.queryByText('Checking…')).toBeNull();
expect(screen.getByTestId('loop-return')).toHaveAttribute('data-state', 'closed');
});
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,
});
render(<ChainDisplay />);
expect(screen.queryByText('A')).toBeNull();
// Only the anchor card's own marker.
expect(screen.getAllByText('A')).toHaveLength(1);
});
});
+65 -54
View File
@@ -1,8 +1,11 @@
import { useRef } from 'react';
import { useGameStore } from '@/stores/game-store';
import MovieCard from './MovieCard';
import ActorCard from './ActorCard';
import SerpentineConnector from './SerpentineConnector';
import LoopReturn from './LoopReturn';
import { cn } from '@/lib/utils';
import { CONNECTOR_STAGGER_MS, loopReturnDelayMs } from '@/lib/motion';
import { Loader2 } from 'lucide-react';
import type {
ActorChainLink,
@@ -73,6 +76,9 @@ interface ChainRowsProps {
live: boolean;
}
const CONNECTOR_OPEN = 'border-connector';
const CONNECTOR_CLOSED = 'border-connector-closed';
function ChainRows({
chain,
movieAId,
@@ -81,6 +87,8 @@ function ChainRows({
isValidating,
live,
}: ChainRowsProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
// chain[0] = MovieA (standalone starting point)
// chain[1..2] = pair 0 (Actor, Movie)
// chain[3..4] = pair 1 (Actor, Movie)
@@ -93,6 +101,15 @@ function ChainRows({
}
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:
// 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
// AND continues into the card row down to its vertical center, where
// 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 (
<div>
<div ref={wrapperRef} className="relative px-3">
{/* Starting movie (Movie A) — full width */}
{startMovie && (
<div data-loop-anchor="">
<MovieCard movie={startMovie} isTarget="A" />
</div>
)}
{/* Chain pairs */}
@@ -133,34 +158,38 @@ function ChainRows({
// For the horizontal arm: left% and right% in CSS
const armLeft = Math.min(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 (
<div key={pairIndex}>
{/* Vertical line through the gap */}
<SerpentineConnector lineX={lineX} />
<SerpentineConnector lineX={lineX} closed={closed} index={pairIndex} />
{/* Pair row — 85% width, alternating alignment */}
<div className={cn('relative flex', isOdd ? 'justify-end' : 'justify-start')}>
{/* Vertical continuation from top of row to vertical center */}
<div
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30"
style={{ left: `${lineX}%` }}
className={cn('absolute top-0 h-1/2 w-0 border-l-2', connectorClass)}
style={{ left: `${lineX}%`, ...connectorDelay }}
/>
{/* Horizontal arm at vertical center to card edge */}
<div
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30"
style={{ left: `${armLeft}%`, right: `${armRight}%` }}
className={cn('absolute top-1/2 h-0 border-t-2', connectorClass)}
style={{ left: `${armLeft}%`, right: `${armRight}%`, ...connectorDelay }}
/>
<div
data-loop-end={isLastPair && closed ? '' : undefined}
className={cn(
'flex w-[85%] items-center overflow-hidden rounded-lg border p-2.5',
movieTarget === 'B' && 'border-green-500 bg-green-500/5',
movieTarget === 'A' && 'border-primary bg-primary/5',
!movieTarget && 'border-border bg-card',
'flex w-[85%] items-center overflow-hidden rounded-md bg-card p-2.5 ring-1',
isLastPair && closed ? 'ring-primary' : 'ring-foreground/10',
live && [
isLastPair && !isComplete && '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} />
<div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie ? (
<div className="flex items-center gap-1.5">
<MovieCard movie={pair.movie} compact />
{movieTarget && <TargetBadge target={movieTarget} />}
</div>
<MovieCard movie={pair.movie} isTarget={movieTarget} compact />
) : (
live && <PendingSlot loading={isValidating && status === 'playing'} />
)}
@@ -187,10 +213,7 @@ function ChainRows({
{/* Even pairs: [Movie | dot | Actor] (reversed) */}
<div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie ? (
<div className="flex items-center gap-1.5">
{movieTarget && <TargetBadge target={movieTarget} />}
<MovieCard movie={pair.movie} compact />
</div>
<MovieCard movie={pair.movie} isTarget={movieTarget} compact />
) : (
live && <PendingSlot loading={isValidating && status === 'playing'} />
)}
@@ -221,32 +244,32 @@ function ChainRows({
>
{/* Vertical continuation + horizontal arm for validating row */}
<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}%` }}
/>
<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={{
left: `${pairs.length % 2 === 0 ? 7.5 : 85}%`,
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">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Validating...
</span>
<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" aria-hidden="true" />
<span className="text-sm text-muted-foreground">Checking…</span>
</div>
</div>
</div>
)}
{status === 'completed' && (
<div className="flex justify-center py-3">
<div className="rounded-full bg-green-500/10 px-4 py-1.5 text-sm font-medium text-green-600">
Loop Complete!
</div>
</div>
{closed && (
<LoopReturn
containerRef={wrapperRef}
side={lastRowIsRightAligned ? 'right' : 'left'}
animate={live}
delayMs={loopReturnDelayMs(pairs.length)}
measureKey={chain.length}
/>
)}
</div>
);
@@ -257,38 +280,26 @@ function Dot({ muted }: { muted?: boolean }) {
<div
className={cn(
'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 }) {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="flex items-center gap-2.5 text-muted-foreground">
<div className="flex h-18 w-12 shrink-0 items-center justify-center rounded-sm border border-dashed border-connector">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
<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="text-xs">
{loading ? 'Validating...' : '?'}
<span className="font-display text-lg font-semibold" aria-hidden="true">
?
</span>
)}
</div>
<span className="text-xs">{loading ? 'Checking…' : 'Next movie'}</span>
</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
* "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
* conventions.
*/
@@ -16,6 +16,14 @@ const navigateMock = vi.fn();
vi.mock('@/lib/sounds', () => ({
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) => {
const actual = await importOriginal<typeof import('react-router')>();
return { ...actual, useNavigate: () => navigateMock };
@@ -24,6 +32,7 @@ vi.mock('react-router', async (importOriginal) => {
const { useGameStore } = await import('@/stores/game-store');
const { MemoryRouter } = await import('react-router');
const { default: GameCompletionModal } = await import('./GameCompletionModal');
const { loopCloseDurationMs } = await import('@/lib/motion');
const MOVIE_A: MovieChainLink = {
type: 'movie',
@@ -92,8 +101,8 @@ describe('GameCompletionModal', () => {
validationError: null,
});
renderModal();
expect(screen.getByRole('dialog', { name: 'Congratulations!' })).toBeInTheDocument();
expect(screen.getByText('Calculating score...')).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Loop closed.' })).toBeInTheDocument();
expect(screen.getByText('Calculating score…')).toBeInTheDocument();
});
it('shows a scoring error instead of a spinner when scoring failed', () => {
@@ -107,9 +116,9 @@ describe('GameCompletionModal', () => {
});
renderModal();
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.
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', () => {
@@ -123,11 +132,11 @@ describe('GameCompletionModal', () => {
});
renderModal();
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('Completed in 2:05')).toBeInTheDocument();
expect(screen.queryByText(/hints? used/)).toBeNull();
expect(screen.getByText('2,400')).toBeInTheDocument();
expect(screen.getByText('2:05, no hints')).toBeInTheDocument();
expect(screen.queryByText(/\d hints?$/)).toBeNull();
expect(
screen.getByRole('button', { name: /Copy Result/ }),
screen.getByRole('button', { name: /Copy result/ }),
).toBeInTheDocument();
});
@@ -141,7 +150,7 @@ describe('GameCompletionModal', () => {
hintsUsed: 1,
});
const { rerender } = renderModal();
expect(screen.getByText('1 hint used')).toBeInTheDocument();
expect(screen.getByText('2:05, 1 hint')).toBeInTheDocument();
useGameStore.setState({ hintsUsed: 2 });
rerender(
@@ -149,7 +158,7 @@ describe('GameCompletionModal', () => {
<GameCompletionModal />
</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', () => {
@@ -221,7 +230,7 @@ describe('GameCompletionModal', () => {
renderModal();
act(() => {
screen.getByRole('button', { name: 'View Chain' }).click();
screen.getByRole('button', { name: 'See the loop' }).click();
});
expect(screen.queryByRole('dialog')).toBeNull();
@@ -247,7 +256,7 @@ describe('GameCompletionModal', () => {
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({
status: 'completed',
movieA: MOVIE_A,
@@ -258,7 +267,7 @@ describe('GameCompletionModal', () => {
renderModal();
act(() => {
screen.getByRole('button', { name: 'Play Again' }).click();
screen.getByRole('button', { name: 'Play again' }).click();
});
expect(useGameStore.getState().status).toBe('idle');
@@ -275,7 +284,7 @@ describe('GameCompletionModal', () => {
score: SCORE,
});
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();
act(() => {
@@ -289,3 +298,46 @@ describe('GameCompletionModal', () => {
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();
});
});
+33 -33
View File
@@ -7,14 +7,16 @@ import {
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import LogoMark from '@/components/layout/LogoMark';
import { useGameStore } from '@/stores/game-store';
import { getLinkCount } from '@/types';
import ScoreDisplay from './ScoreDisplay';
import ShareableResult from './ShareableResult';
import type { GameMode } from './ShareableResult';
import CelebrationOverlay from './CelebrationOverlay';
import { playSound } from '@/lib/sounds';
import { storage } from '@/lib/storage';
import { formatTime } from '@/lib/format';
import { useLoopReveal } from '@/hooks/use-loop-reveal';
import { useNavigate } from 'react-router';
import { Loader2 } from 'lucide-react';
@@ -34,14 +36,17 @@ export default function GameCompletionModal() {
const [dismissed, setDismissed] = useState(false);
const [prevStatus, setPrevStatus] = useState(status);
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
// dismissal reset as soon as the game leaves the completed state.
if (status !== prevStatus) {
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
? { type: 'daily', date: dailyChallengeDate }
@@ -75,46 +80,50 @@ export default function GameCompletionModal() {
};
return (
<>
{status === 'completed' && <CelebrationOverlay />}
<Dialog open={showModal} onOpenChange={(open) => !open && setDismissed(true)}>
<DialogContent
className="sm:max-w-md"
showCloseButton={false}
>
<DialogHeader>
<DialogTitle className="text-center text-2xl">
Congratulations!
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader className="items-center text-center">
<DialogTitle className="flex items-center justify-center gap-2 font-display text-2xl font-semibold">
<LogoMark size={24} className="text-primary" />
Loop closed.
</DialogTitle>
{movieA && movieB && (
<DialogDescription className="text-center">
You completed the movie loop!
{movieA.title} to {movieB.title} and back in {links} link{links === 1 ? '' : 's'}.
</DialogDescription>
)}
</DialogHeader>
<div className="py-4">
<div className="py-2">
{!score && !validationError && (
<div className="flex flex-col items-center gap-2 py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Calculating score...</span>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
<span className="text-sm text-muted-foreground">Calculating score…</span>
</div>
)}
{validationError && (
<div className="text-center text-sm text-destructive py-4">
<div className="py-4 text-center text-sm text-destructive">
{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>
<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-3">
<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>
{score && movieA && movieB && (
<ShareableResult
score={score}
@@ -126,17 +135,8 @@ export default function GameCompletionModal() {
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>
</DialogContent>
</Dialog>
</>
);
}
+22 -18
View File
@@ -39,43 +39,47 @@ describe('GameHeader', () => {
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] });
render(<GameHeader />);
expect(screen.getByRole('img', { name: 'Movie A' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Movie B' })).toBeInTheDocument();
expect(screen.getByText('Movie A')).toBeInTheDocument();
expect(screen.getByText('Movie B')).toBeInTheDocument();
// 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 });
const { rerender } = render(<GameHeader />);
expect(screen.getByText('Custom Game')).toBeInTheDocument();
expect(screen.getByText('Custom')).toBeInTheDocument();
useGameStore.setState({ isCustomGame: false });
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 });
render(<GameHeader />);
// 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();
expect(screen.getByRole('status')).toHaveTextContent('Next: reach Movie B');
});
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 });
render(<GameHeader />);
expect(screen.getByRole('status')).toHaveTextContent('Next: back to Movie A');
});
expect(screen.getByText('Movie B reached')).toBeInTheDocument();
expect(screen.queryByText('Movie B not yet reached')).toBeNull();
it('reports the loop closed once the game is complete', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: true, status: 'completed' });
render(<GameHeader />);
expect(screen.getByRole('status')).toHaveTextContent('Loop closed');
});
});
+38 -37
View File
@@ -1,56 +1,57 @@
import { useGameStore } from '@/stores/game-store';
import { getLinkCount } from '@/types';
import MovieCard from './MovieCard';
import Timer from './Timer';
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() {
const movieA = useGameStore((s) => s.movieA);
const movieB = useGameStore((s) => s.movieB);
const chain = useGameStore((s) => s.chain);
const status = useGameStore((s) => s.status);
const passedMovieB = useGameStore((s) => s.passedMovieB);
const isCustomGame = useGameStore((s) => s.isCustomGame);
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 (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<MovieCard movie={movieA} isTarget="A" />
<MovieCard movie={movieB} isTarget="B" />
</div>
<Separator />
<div className="flex flex-wrap items-center gap-3">
{isCustomGame && (
<Badge variant="secondary">Custom Game</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">
<div className="space-y-2 px-3">
<p className="font-display text-xl font-semibold leading-tight">
<span className="text-movie-a">{movieA.title}</span>
<span className="text-muted-foreground"> to </span>
<span className="text-movie-b">{movieB.title}</span>
<span className="text-muted-foreground"> and back.</span>
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm">
<Timer />
</div>
<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 && (
<Badge variant="outline" className="ml-auto">
Custom
</Badge>
)}
</div>
</div>
);
+3 -3
View File
@@ -102,7 +102,7 @@ function baseScreen(overrides: Partial<ReadyGameScreen> = {}): ReadyGameScreen {
validateAndAddActor: vi.fn(),
validateAndAddMovie: vi.fn(),
searchLabel: 'Who was in "Movie A"?',
searchPlaceholder: 'Search for an actor...',
searchPlaceholder: 'Search for an actor…',
showLoopHint: false,
movieA,
movieB,
@@ -116,7 +116,7 @@ describe('GameSearchPanel', () => {
expect(screen.getByText('Who was in "Movie A"?')).toBeInTheDocument();
expect(screen.getByText('mode:actor')).toBeInTheDocument();
expect(
screen.getByText('placeholder:Search for an actor...'),
screen.getByText('placeholder:Search for an actor…'),
).toBeInTheDocument();
expect(screen.getByText('disabled:false')).toBeInTheDocument();
});
@@ -207,7 +207,7 @@ describe('GameSearchPanel', () => {
rerender(<GameSearchPanel screen={baseScreen({ showLoopHint: true })} />);
expect(
screen.getByText('Select "Movie A" to close the loop!'),
screen.getByText('Pick Movie A to close the loop.'),
).toBeInTheDocument();
});
+6 -11
View File
@@ -3,7 +3,6 @@ import SearchAutocomplete from './SearchAutocomplete';
import ValidationFeedback from './ValidationFeedback';
import HintButton from './HintButton';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Undo2 } from 'lucide-react';
import type { ReadyGameScreen } from '@/hooks/use-game-screen';
@@ -33,12 +32,9 @@ export default function GameSearchPanel({ screen, children }: GameSearchPanelPro
} = screen;
return (
<div className="space-y-3">
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{searchLabel}</label>
<div className="space-y-3 border-t border-border px-3 pt-5">
<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="flex items-center gap-2">
<HintButton />
<Button
@@ -47,7 +43,7 @@ export default function GameSearchPanel({ screen, children }: GameSearchPanelPro
onClick={undoLastLink}
disabled={chain.length <= 1 || isValidating}
>
<Undo2 className="mr-1 h-3.5 w-3.5" />
<Undo2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
Undo
</Button>
{children}
@@ -65,11 +61,10 @@ export default function GameSearchPanel({ screen, children }: GameSearchPanelPro
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm text-green-600">
Select &quot;{movieA.title}&quot; to close the loop!
<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();
// 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.
expect(screen.getByText('-200 pts')).toBeInTheDocument();
expect(screen.getByText('−200')).toBeInTheDocument();
});
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" />
)}
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>
{hints.length > 0 && (
@@ -50,7 +50,7 @@ export default function HintButton() {
<button
key={member.id}
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)}
>
<img
@@ -61,7 +61,7 @@ export default function HintButton() {
alt={member.name}
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>
))}
</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 type { MovieChainLink } from '@/types';
import MovieCard from './MovieCard';
@@ -46,10 +46,27 @@ describe('MovieCard', () => {
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 />);
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.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 { posterUrl, releaseYear } from '@/lib/tmdb';
import { useImageFallback } from '@/hooks/use-image-fallback';
import MovieMarker from './MovieMarker';
import type { MovieChainLink } from '@/types';
interface MovieCardProps {
movie: MovieChainLink;
highlight?: boolean;
isTarget?: 'A' | 'B';
/** The in-row size used inside chain links; the default is the anchor card. */
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({
movie,
highlight,
isTarget,
compact,
}: 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) {
return (
<div className="flex items-center gap-2 min-w-0">
<div className="flex min-w-0 items-center gap-2.5">
<img
src={imgSrc || '/placeholder-movie.svg'}
src={img.src}
onError={img.onError}
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">
<p className="text-sm font-medium leading-tight break-words">{movie.title}</p>
<p className="text-[11px] text-muted-foreground">
{releaseYear(movie.releaseDate)}
<p className="flex items-center gap-1.5 text-sm font-medium leading-tight">
<span className="min-w-0 break-words">{movie.title}</span>
{isTarget && <MovieMarker target={isTarget} />}
</p>
<p className="text-xs text-muted-foreground">{releaseYear(movie.releaseDate)}</p>
</div>
</div>
);
}
return (
<div
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',
)}
>
<div className="flex items-center gap-3 rounded-lg bg-card p-2.5 ring-1 ring-foreground/10">
<img
src={imgSrc || '/placeholder-movie.svg'}
src={img.src}
onError={img.onError}
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">
<p className="truncate text-sm font-medium">{movie.title}</p>
<p className="text-xs text-muted-foreground">
<p className="flex items-center gap-2 font-display text-lg font-semibold leading-tight">
<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)}
</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>
);
+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', () => {
render(<ScoreDisplay score={SCORE} />);
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('2,400')).toBeInTheDocument();
expect(screen.getByText('points')).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('+500')).toBeInTheDocument();
expect(screen.getByText('+100')).toBeInTheDocument();
@@ -51,7 +51,7 @@ describe('ScoreDisplay', () => {
);
expect(screen.getByText('Duplicate 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', () => {
+21 -12
View File
@@ -5,13 +5,22 @@ interface ScoreDisplayProps {
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) {
return (
<div className="space-y-3 text-center">
<p className="text-4xl font-bold">{score.totalScore}</p>
<p className="text-sm text-muted-foreground">points</p>
<div className="space-y-4">
<div className="text-center">
<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={`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
/>
)}
</div>
</dl>
</div>
);
}
@@ -55,18 +64,18 @@ function ScoreLine({
penalty?: boolean;
}) {
return (
<div className="flex justify-between">
<span className="text-muted-foreground">{label}</span>
<span
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{label}</dt>
<dd
className={cn(
'font-medium',
bonus && value > 0 && 'text-green-600',
penalty && 'text-red-500',
bonus && value > 0 && 'text-primary-text',
penalty && 'text-destructive',
)}
>
{value > 0 && bonus ? '+' : ''}
{value}
</span>
{value < 0 ? `−${Math.abs(value).toLocaleString()}` : value.toLocaleString()}
</dd>
</div>
);
}
@@ -174,14 +174,14 @@ describe('SearchAutocomplete', () => {
expect(screen.getByText('The Matrix')).toBeInTheDocument();
}, 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();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'zz');
await screen.findByText('No results found');
await screen.findByText('No matches. Try another spelling.');
}, 10000);
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 screen.findByLabelText('Searching...');
await screen.findByLabelText('Searching…');
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);
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}
onChange={(e) => setQuery(e.target.value)}
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}
onFocus={() => {
if (results.length > 0) {
@@ -212,7 +213,7 @@ export default function SearchAutocomplete({
aria-label={mode === 'actor' ? 'Search for an actor' : 'Search for a movie'}
/>
{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>
@@ -221,7 +222,7 @@ export default function SearchAutocomplete({
id={listId}
role="listbox"
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' &&
personResults.map((person, idx) => (
@@ -275,7 +276,7 @@ export default function SearchAutocomplete({
<img
src={posterUrl(movie.poster_path, 'w92') || '/placeholder-movie.svg'}
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">
<p className="truncate text-sm font-medium">{movie.title}</p>
@@ -300,7 +301,7 @@ export default function SearchAutocomplete({
results.length === 0 && (
<li role="option" aria-selected={false}>
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
No results found
No matches. Try another spelling.
</p>
</li>
)}
@@ -27,4 +27,12 @@ describe('SerpentineConnector', () => {
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 {
/** X% where the vertical line runs */
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
* with the card's vertical center.
*/
export default function SerpentineConnector({ lineX }: SerpentineConnectorProps) {
export default function SerpentineConnector({
lineX,
closed = false,
index = 0,
}: SerpentineConnectorProps) {
return (
<div className="relative h-8">
<div
className="absolute top-0 bottom-0 w-0 border-l-2 border-muted-foreground/30"
style={{ left: `${lineX}%` }}
data-closed={closed || undefined}
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>
);
+13 -13
View File
@@ -65,7 +65,7 @@ describe('ShareableResult', () => {
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
screen.getByRole('button', { name: /Copy result|Copied/ }),
);
expect(writeText).toHaveBeenCalledTimes(1);
@@ -80,17 +80,17 @@ describe('ShareableResult', () => {
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
screen.getByRole('button', { name: /Copy result|Copied/ }),
);
expect(
screen.getByRole('button', { name: /Copied!/ }),
screen.getByRole('button', { name: /^Copied$/ }),
).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(
screen.getByRole('button', { name: /Copy Result/ }),
screen.getByRole('button', { name: /Copy result/ }),
).toBeInTheDocument();
});
@@ -99,9 +99,9 @@ describe('ShareableResult', () => {
<ShareableResult {...baseProps()} mode={{ type: 'daily', date: '2024-01-01' }} />,
);
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');
rerender(
@@ -111,16 +111,16 @@ describe('ShareableResult', () => {
/>,
);
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');
});
it('links to plain versus play when no match id is given', async () => {
render(<ShareableResult {...baseProps()} mode={{ type: 'versus' }} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
screen.getByRole('button', { name: /Copy result|Copied/ }),
);
expect(writeText.mock.calls[0]?.[0]).toMatch(/\/versus$/);
});
@@ -135,7 +135,7 @@ describe('ShareableResult', () => {
/>,
);
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;
expect(text).toContain('Movie A <-> Movie B');
@@ -147,11 +147,11 @@ describe('ShareableResult', () => {
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
screen.getByRole('button', { name: /Copy result|Copied/ }),
);
expect(
screen.getByRole('button', { name: /Copy Result/ }),
screen.getByRole('button', { name: /Copy result/ }),
).toBeInTheDocument();
});
@@ -160,7 +160,7 @@ describe('ShareableResult', () => {
const { unmount } = render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
screen.getByRole('button', { name: /Copy result|Copied/ }),
);
unmount();
+8 -8
View File
@@ -29,9 +29,9 @@ function getModeLabel(mode?: GameMode): string {
if (!mode) return '';
switch (mode.type) {
case 'daily':
return 'Daily Challenge';
return 'Daily challenge';
case 'versus':
return 'Async Versus';
return 'Versus';
default:
return '';
}
@@ -82,7 +82,7 @@ function generateShareText(props: ShareableResultProps): string {
.map((link) => (link.type === 'movie' ? '\u{1F3AC}' : '\u{1F9D1}'))
.join(' \u2192 '),
'',
`Think you can do better? ${url}`,
`Movie Loop. Think you can do better? ${url}`,
];
return lines.join('\n');
@@ -131,19 +131,19 @@ export default function ShareableResult(props: ShareableResultProps) {
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? (
<>
<Check className="mr-1.5 h-3.5 w-3.5" />
Copied!
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Copied
</>
) : (
<>
<Copy className="mr-1.5 h-3.5 w-3.5" />
Copy Result
<Copy className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Copy result
</>
)}
</Button>
{typeof navigator.share === 'function' && (
<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
</Button>
)}
+3 -3
View File
@@ -14,15 +14,15 @@ export default function SoundToggle() {
return (
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={handleToggle}
className="h-8 w-8"
className="text-muted-foreground hover:text-foreground"
aria-label={enabled ? 'Mute sounds' : 'Enable sounds'}
>
{enabled ? (
<Volume2 className="h-4 w-4" />
) : (
<VolumeX className="h-4 w-4 text-muted-foreground" />
<VolumeX className="h-4 w-4" />
)}
</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 { 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> = {
high: 'text-green-600',
medium: 'text-yellow-600',
low: 'text-orange-500',
none: 'text-red-500',
high: 'text-primary-text',
medium: 'text-foreground',
low: 'text-muted-foreground',
none: 'text-muted-foreground',
};
const tierLabels: Record<BonusTier, string> = {
@@ -27,9 +29,9 @@ export default function Timer() {
return (
<div className={cn('flex items-center gap-1.5 text-sm', tierColors[bonusTier])}>
<TimerIcon className="h-4 w-4" />
<span className="font-mono font-medium">{formattedTime}</span>
<span className="text-xs opacity-75">({tierLabels[bonusTier]})</span>
<TimerIcon className="h-4 w-4" aria-hidden="true" />
<span className="font-display text-base font-semibold tabular-nums">{formattedTime}</span>
<span className="text-xs">({tierLabels[bonusTier]})</span>
</div>
);
}
@@ -33,7 +33,7 @@ describe('ValidationFeedback', () => {
useGameStore.setState({ isValidating: true });
render(<ValidationFeedback />);
expect(screen.getByRole('status')).toHaveTextContent(
'Validating connection...',
'Checking the link…',
);
});
@@ -49,7 +49,7 @@ describe('ValidationFeedback', () => {
useGameStore.setState({ isValidating: true, validationError: 'stale' });
render(<ValidationFeedback />);
expect(screen.getByRole('status')).toHaveTextContent(
'Validating connection...',
'Checking the link…',
);
expect(screen.queryByRole('alert')).toBeNull();
});
@@ -57,28 +57,28 @@ describe('ValidationFeedback', () => {
it('flashes a success check when the chain grows, then clears it', () => {
useGameStore.setState({ chain: [movieA] });
render(<ValidationFeedback />);
expect(screen.queryByText('Connected!')).toBeNull();
expect(screen.queryByText('Linked')).toBeNull();
act(() => {
useGameStore.setState({ chain: [movieA, movieA] });
});
expect(screen.getByRole('status')).toHaveTextContent('Connected!');
expect(screen.getByRole('status')).toHaveTextContent('Linked');
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.queryByText('Connected!')).toBeNull();
expect(screen.queryByText('Linked')).toBeNull();
});
it('does not re-flash when the chain length is unchanged', () => {
useGameStore.setState({ chain: [movieA, movieA] });
const { rerender } = render(<ValidationFeedback />);
expect(screen.getByText('Connected!')).toBeInTheDocument();
expect(screen.getByText('Linked')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(600);
});
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 [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
// value; doing it in an effect costs an extra committed render each link.
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"
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Validating connection...
Checking the link…
</div>
);
}
@@ -52,10 +52,10 @@ export default function ValidationFeedback() {
return (
<div
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" />
Connected!
Linked
</div>
);
}
@@ -14,10 +14,10 @@ describe('HowToPlayDialog', () => {
await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect(
screen.getByRole('dialog', { name: 'How to Play' }),
screen.getByRole('dialog', { name: 'How to play' }),
).toBeInTheDocument();
expect(
screen.getByText(/Build a chain of actor-movie connections/),
screen.getByText(/Build a chain of actor-movie connections and bring it back/),
).toBeInTheDocument();
});
@@ -25,12 +25,12 @@ describe('HowToPlayDialog', () => {
render(<HowToPlayDialog />);
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(
screen.getByRole('heading', { name: 'How Connections Work' }),
screen.getByRole('heading', { name: 'How connections work' }),
).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();
});
+39 -30
View File
@@ -6,6 +6,7 @@ import {
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import MovieMarker from '@/components/game/MovieMarker';
import { HelpCircle } from 'lucide-react';
export default function HowToPlayDialog() {
@@ -16,75 +17,83 @@ export default function HowToPlayDialog() {
<button
type="button"
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"
>
<HelpCircle className="h-4 w-4" />
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>How to Play</DialogTitle>
<DialogTitle className="font-display text-xl font-semibold">How to play</DialogTitle>
<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>
</DialogHeader>
<div className="space-y-4 py-2 text-sm text-foreground">
<div className="space-y-5 py-2 text-sm text-foreground">
<section>
<h3 className="mb-1 font-semibold">The Goal</h3>
<h3 className="mb-1 font-semibold">The goal</h3>
<p className="text-muted-foreground">
You're given two movies (<span className="font-medium text-blue-500">Movie A</span>{' '}
and <span className="font-medium text-green-500">Movie B</span>). Build a chain of
connections starting from Movie A, passing through Movie B, and looping back to
Movie A.
You're given two movies,{' '}
<MovieMarker target="A" className="align-middle" />{' '}
and{' '}
<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>
</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">
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.
</p>
<div 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
</div>
<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
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">Scoring</h3>
<ul className="list-inside list-disc space-y-1 text-muted-foreground">
<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>
<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>
<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>
<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>
<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>
</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">
<li>
<span className="font-medium text-foreground">Free Play</span> — pick any two
movies and build your chain
<span className="font-medium text-foreground">Play now</span> hands you a
random pair. Custom lets you pick both movies yourself.
</li>
<li>
<span className="font-medium text-foreground">Daily Challenge</span> — everyone
gets the same movie pair each day
<span className="font-medium text-foreground">Today's challenge</span> gives
everyone the same pairs each day.
</li>
<li>
<span className="font-medium text-foreground">Async Versus</span> — create or
join a challenge and compare scores
<span className="font-medium text-foreground">Versus</span> and{' '}
<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>
</ul>
</section>
@@ -92,9 +101,9 @@ export default function HowToPlayDialog() {
<section>
<h3 className="mb-1 font-semibold">Tips</h3>
<ul className="list-inside list-disc space-y-1 text-muted-foreground">
<li>Think about actors who appear in many genres</li>
<li>You can undo your last pick if you hit a dead end</li>
<li>All connections are verified against real movie data from TMDB</li>
<li>Think of actors who work across many genres.</li>
<li>Undo your last pick if you hit a dead end.</li>
<li>Every link is checked against real movie data from TMDB.</li>
</ul>
</section>
</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 () => {
renderBell();
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 () => {
+5 -5
View File
@@ -94,7 +94,7 @@ export default function NotificationBell() {
ref={triggerRef}
type="button"
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-haspopup="dialog"
aria-expanded={open}
@@ -104,7 +104,7 @@ export default function NotificationBell() {
{unreadCount > 0 && (
<span
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}
</span>
@@ -116,7 +116,7 @@ export default function NotificationBell() {
id={panelId}
role="dialog"
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">
<span className="text-sm font-semibold">Notifications</span>
@@ -146,7 +146,7 @@ export default function NotificationBell() {
<div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No notifications
No notifications yet.
</p>
) : (
<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">
{n.message}
</span>
<span className="mt-1 block text-[10px] text-muted-foreground">
<span className="mt-1 block text-xs text-muted-foreground">
{timeAgo(n.createdAt)}
</span>
</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', () => {
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();
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('button', { name: /Notifications/ })).toBeNull();
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
@@ -81,7 +81,7 @@ describe('PageLayout - signed out', () => {
expect(dialog).toBeInTheDocument();
// No admin or sign-out controls without a signed-in user.
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 () => {
@@ -170,7 +170,7 @@ describe('PageLayout - signed in', () => {
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();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog');
@@ -179,7 +179,7 @@ describe('PageLayout - signed in', () => {
.find((el) => dialog.contains(el));
expect(adminLinkInDialog).toBeDefined();
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);
});
@@ -202,7 +202,7 @@ describe('PageLayout - signed in', () => {
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog');
const mobileSignOut = screen
.getAllByRole('button', { name: 'Sign Out' })
.getAllByRole('button', { name: 'Sign out' })
.find((b) => dialog.contains(b));
expect(mobileSignOut).toBeDefined();
if (mobileSignOut) await userEvent.click(mobileSignOut);
+54 -27
View File
@@ -13,17 +13,23 @@ import {
SheetTitle,
SheetTrigger,
} 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 LogoMark from '@/components/layout/LogoMark';
const NAV_LINKS = [
{ path: '/', label: 'Home' },
{ path: '/daily', label: 'Daily' },
{ path: '/versus', label: 'Versus' },
{ path: '/leaderboard', label: 'Ranks' },
{ path: '/leaderboard', label: 'Leaderboard' },
{ path: '/friends', label: 'Friends' },
] 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({
children,
noPadding = false,
@@ -39,7 +45,7 @@ export default function PageLayout({
return (
<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",
)}>
{/* 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}
>
<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">
<LogoMark size={20} className="text-primary" />
<span className="font-display text-lg font-bold tracking-tight font-stretch-semi-condensed">
Movie Loop
</span>
</Link>
<div className="flex items-center gap-4">
{/* Desktop nav */}
<nav className="hidden gap-4 md:flex" aria-label="Main navigation">
{NAV_LINKS.map(({ path, label }) => (
<div className="flex items-center gap-3 sm:gap-5">
{/* Desktop nav. The active page carries a gold rule under it; the
text colour alone was too quiet to find at a glance. */}
<nav className="hidden gap-5 md:flex" aria-label="Main navigation">
{NAV_LINKS.map(({ path, label }) => {
const active =
location.pathname === path ||
(path === '/friends' && location.pathname.startsWith('/friends'));
return (
<Link
key={path}
to={path}
className={cn(
'relative text-sm font-medium transition-colors hover:text-foreground',
location.pathname === path || (path === '/friends' && location.pathname.startsWith('/friends'))
? 'text-foreground'
: 'text-muted-foreground',
)}
aria-current={active ? 'page' : undefined}
className={cn(NAV_LINK_CLASS, active ? NAV_ACTIVE : NAV_INACTIVE)}
>
{label}
{label === 'Friends' && pendingCount > 0 && (
<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">
<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">
{pendingCount}
</span>
)}
</Link>
))}
);
})}
{user?.isAdmin && (
<Link
to="/admin"
aria-current={location.pathname === '/admin' ? 'page' : undefined}
className={cn(
'flex items-center gap-1 text-sm font-medium transition-colors hover:text-foreground',
location.pathname === '/admin'
? 'text-foreground'
: 'text-muted-foreground',
NAV_LINK_CLASS,
'gap-1',
location.pathname === '/admin' ? NAV_ACTIVE : NAV_INACTIVE,
)}
>
<Shield className="h-3.5 w-3.5" />
<Shield className="h-3.5 w-3.5" aria-hidden="true" />
Admin
</Link>
)}
</nav>
{/* Utility cluster: every control at the same quiet weight. */}
<div className="flex items-center gap-0.5">
<HowToPlayDialog />
<SoundToggle />
<ThemeToggle />
{user && <NotificationBell />}
</div>
{user ? (
<Link
to="/profile"
aria-current={location.pathname === '/profile' ? 'page' : undefined}
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'
? 'text-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>
</Link>
) : (
@@ -120,7 +140,7 @@ export default function PageLayout({
to="/login"
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Sign In
Sign in
</Link>
)}
{/* Desktop sign out */}
@@ -128,9 +148,10 @@ export default function PageLayout({
<button
type="button"
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>
)}
{/* Mobile hamburger */}
@@ -156,6 +177,12 @@ export default function PageLayout({
key={path}
to={path}
onClick={() => setOpen(false)}
aria-current={
location.pathname === path ||
(path === '/friends' && location.pathname.startsWith('/friends'))
? 'page'
: undefined
}
className={cn(
'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'))
@@ -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"
>
<LogOut className="h-4 w-4" />
Sign Out
Sign out
</button>
)}
</nav>
+2 -1
View File
@@ -19,7 +19,8 @@ export default function ThemeToggle() {
return (
<Button
variant="ghost"
size="icon"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
+14 -2
View File
@@ -1,5 +1,5 @@
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({
difficulty,
@@ -11,15 +11,27 @@ export function DifficultyBadge({
const level = (difficulty as Difficulty) in DIFFICULTY_STYLES
? (difficulty as Difficulty)
: 'medium';
const filled = DIFFICULTY_PIPS[level];
return (
<span
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],
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}
</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';
/**
* 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> = {
easy: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
hard: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
easy: 'bg-muted text-foreground',
medium: 'bg-muted text-foreground',
hard: 'bg-muted text-foreground',
};
const DIFFICULTY_BUTTON_ACTIVE: Record<Difficulty, string> = {
easy: 'border-emerald-500 bg-emerald-100 text-emerald-800 dark:border-emerald-500 dark:bg-emerald-900/40 dark:text-emerald-300',
medium: 'border-amber-500 bg-amber-100 text-amber-800 dark:border-amber-500 dark:bg-amber-900/40 dark:text-amber-300',
hard: 'border-rose-500 bg-rose-100 text-rose-800 dark:border-rose-500 dark:bg-rose-900/40 dark:text-rose-300',
/** How many of the three pips are filled. */
export const DIFFICULTY_PIPS: Record<Difficulty, 1 | 2 | 3> = {
easy: 1,
medium: 2,
hard: 3,
};
const DIFFICULTY_BUTTON_ACTIVE = 'border-foreground bg-foreground text-background hover:bg-foreground hover:text-background';
export function getDifficultyButtonClass(
difficulty: Difficulty,
_difficulty: Difficulty,
isActive: boolean,
): string {
if (!isActive) return '';
return DIFFICULTY_BUTTON_ACTIVE[difficulty];
return DIFFICULTY_BUTTON_ACTIVE;
}
@@ -1,7 +1,14 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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 type { ComponentProps } from 'react';
import type { ScoreBreakdown } from '@/types';
@@ -63,8 +70,8 @@ describe('AsyncCompletionModal', () => {
describe('creator mode', () => {
it('shows the submitted score and link count', () => {
renderModal({ mode: 'creator', myScore: score(1234), myChainLength: 7 });
expect(screen.getByRole('heading', { name: /Score Submitted!/ })).toBeInTheDocument();
expect(screen.getByText('1234')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Score submitted/ })).toBeInTheDocument();
expect(screen.getByText('1,234')).toBeInTheDocument();
expect(screen.getByText(/7 links/)).toBeInTheDocument();
});
@@ -76,7 +83,7 @@ describe('AsyncCompletionModal', () => {
it('navigates to the match leaderboard', async () => {
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();
});
});
@@ -88,7 +95,7 @@ describe('AsyncCompletionModal', () => {
myScore: score(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', () => {
@@ -97,7 +104,7 @@ describe('AsyncCompletionModal', () => {
myScore: score(1500),
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', () => {
@@ -106,19 +113,19 @@ describe('AsyncCompletionModal', () => {
myScore: score(500),
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', () => {
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');
expect(zeros.length).toBeGreaterThanOrEqual(2);
});
it('beats a creator whose score defaults to 0', () => {
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', () => {
@@ -165,7 +172,7 @@ describe('AsyncCompletionModal', () => {
it('links to the full leaderboard', async () => {
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();
});
});
+21 -15
View File
@@ -7,7 +7,9 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
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 {
open: boolean;
@@ -31,8 +33,12 @@ export default function AsyncCompletionModal({
rank,
}: AsyncCompletionModalProps) {
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;
@@ -42,13 +48,13 @@ export default function AsyncCompletionModal({
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
Score Submitted!
<Trophy className="h-5 w-5 text-primary" />
Score submitted
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<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">
Your score &middot; {myChainLength} links
</p>
@@ -68,7 +74,7 @@ export default function AsyncCompletionModal({
className="flex-1"
onClick={() => navigate(`/versus/async/${matchId}/leaderboard`)}
>
View Leaderboard
View leaderboard
</Button>
</div>
</div>
@@ -87,31 +93,31 @@ export default function AsyncCompletionModal({
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
Challenge Complete!
<Trophy className="h-5 w-5 text-primary" />
Challenge complete
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-center">
{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 ? (
<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 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">
<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>
</div>
<span className="text-lg font-bold text-muted-foreground">vs</span>
<div className="text-center">
<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">
{creatorChainLength ?? '?'} links
</p>
@@ -136,7 +142,7 @@ export default function AsyncCompletionModal({
className="flex-1"
onClick={() => navigate(`/versus/async/${matchId}/leaderboard`)}
>
Full Leaderboard
Full leaderboard
</Button>
</div>
</div>
@@ -30,7 +30,17 @@ describe('OpponentProgress', () => {
render(<OpponentProgress />);
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', () => {
@@ -52,6 +62,6 @@ describe('OpponentProgress', () => {
expect(
screen.getByText('Your opponent has finished — hurry up!'),
).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;
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 gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{opponent.username}</span>
</div>
{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" />
Finished!
</span>
) : (
<span className="text-xs text-muted-foreground">
Chain: {rawToLinkCount(opponentChainLength)} links
{rawToLinkCount(opponentChainLength)} links
</span>
)}
</div>
{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!
</p>
)}
@@ -1,7 +1,14 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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 { useVersusStore } from '@/stores/versus-store';
import VersusCompletionModal from './VersusCompletionModal';
@@ -57,9 +64,9 @@ describe('VersusCompletionModal', () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'playing' });
renderModal();
expect(screen.getByRole('dialog', { name: 'You finished!' })).toBeInTheDocument();
expect(screen.getByText('1500')).toBeInTheDocument();
expect(screen.getByText('Waiting for opponent to finish...')).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'You finished' })).toBeInTheDocument();
expect(screen.getByText('1,500')).toBeInTheDocument();
expect(screen.getByText('Waiting for opponent to finish…')).toBeInTheDocument();
});
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 });
renderModal();
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', () => {
@@ -103,8 +110,8 @@ describe('VersusCompletionModal', () => {
});
renderModal();
expect(screen.getByRole('heading', { name: /Match Complete!/ })).toBeInTheDocument();
expect(screen.getByText("It's a tie!")).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Match complete/ })).toBeInTheDocument();
expect(screen.getByText('A tie')).toBeInTheDocument();
expect(screen.getByText('Me')).toBeInTheDocument();
expect(screen.getByText('Rival')).toBeInTheDocument();
});
@@ -124,7 +131,7 @@ describe('VersusCompletionModal', () => {
});
renderModal();
expect(screen.getByText('You win!')).toBeInTheDocument();
expect(screen.getByText('You won')).toBeInTheDocument();
});
it('declares a loss when the winner is the other player', () => {
@@ -142,7 +149,7 @@ describe('VersusCompletionModal', () => {
});
renderModal();
expect(screen.getByText('You lose!')).toBeInTheDocument();
expect(screen.getByText('You lost')).toBeInTheDocument();
});
it('defaults a missing player score to 0', () => {
+19 -12
View File
@@ -1,5 +1,8 @@
import { useNavigate } from 'react-router';
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 {
Dialog,
@@ -17,9 +20,13 @@ export default function VersusCompletionModal() {
const matchResult = useVersusStore((s) => s.matchResult);
const myScore = useVersusStore((s) => s.myScore);
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 isOpen = iAmDone;
const isOpen = iAmDone && revealed;
if (!isOpen) return null;
const waitingForOpponent = iAmDone && lobbyState !== 'finished';
@@ -36,14 +43,14 @@ export default function VersusCompletionModal() {
{waitingForOpponent ? (
<>
<DialogHeader>
<DialogTitle>You finished!</DialogTitle>
<DialogTitle>You finished</DialogTitle>
</DialogHeader>
<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>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Waiting for opponent to finish...</span>
<span>Waiting for opponent to finish…</span>
</div>
{/* An opponent who never finishes must not strand this player in
a modal with no controls. */}
@@ -56,8 +63,8 @@ export default function VersusCompletionModal() {
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
Match Complete!
<Trophy className="h-5 w-5 text-primary" />
Match complete
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
@@ -93,23 +100,23 @@ function MatchResultDisplay({
<div className="space-y-4">
<div className="text-center">
{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 ? (
<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 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">
<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>
<span className="text-lg font-bold text-muted-foreground">vs</span>
<div className="text-center">
<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>
+2 -2
View File
@@ -76,7 +76,7 @@ describe('useGameScreen', () => {
});
const { result } = renderHook(() => useGameScreen());
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', () => {
@@ -89,7 +89,7 @@ describe('useGameScreen', () => {
});
const { result } = renderHook(() => useGameScreen());
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', () => {
+2 -2
View File
@@ -59,10 +59,10 @@ export function useGameScreen(): GameScreen {
if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
searchLabel = `Who was in "${lastLink.title}"?`;
searchPlaceholder = 'Search for an actor...';
searchPlaceholder = 'Search for an actor…';
} else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
searchLabel = `What movie was ${lastLink.name} also in?`;
searchPlaceholder = 'Search for a movie...';
searchPlaceholder = 'Search for a movie…';
}
// 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 "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@import "@fontsource-variable/bricolage-grotesque/standard.css";
@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 {
--background: oklch(0.975 0.005 260);
--foreground: oklch(0.175 0.015 260);
--card: oklch(0.995 0.002 260);
--card-foreground: oklch(0.175 0.015 260);
--popover: oklch(0.995 0.002 260);
--popover-foreground: oklch(0.175 0.015 260);
--primary: oklch(0.51 0.17 265);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.945 0.01 260);
--secondary-foreground: oklch(0.25 0.02 260);
--muted: oklch(0.945 0.01 260);
--muted-foreground: oklch(0.52 0.015 260);
--accent: oklch(0.935 0.015 260);
--accent-foreground: oklch(0.25 0.02 260);
--background: oklch(0.972 0.006 265);
--foreground: oklch(0.20 0.025 268);
--card: oklch(0.995 0.002 265);
--card-foreground: oklch(0.20 0.025 268);
--popover: oklch(0.995 0.002 265);
--popover-foreground: oklch(0.20 0.025 268);
--primary: oklch(0.64 0.13 72);
--primary-foreground: oklch(0.20 0.025 268);
--primary-text: oklch(0.50 0.11 65);
--secondary: oklch(0.94 0.01 265);
--secondary-foreground: oklch(0.25 0.02 265);
--muted: oklch(0.94 0.01 265);
--muted-foreground: oklch(0.50 0.02 265);
--accent: oklch(0.93 0.015 265);
--accent-foreground: oklch(0.25 0.02 265);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.905 0.01 260);
--input: oklch(0.905 0.01 260);
--ring: oklch(0.51 0.17 265);
--chart-1: oklch(0.65 0.18 265);
--chart-2: oklch(0.60 0.16 340);
--chart-3: oklch(0.70 0.14 150);
--chart-4: oklch(0.65 0.15 45);
--chart-5: oklch(0.55 0.20 265);
--success: oklch(0.55 0.12 160);
--movie-a: oklch(0.48 0.17 268);
--movie-b: oklch(0.53 0.17 340);
--connector: oklch(0.78 0.015 265);
--connector-closed: var(--primary);
--border: oklch(0.89 0.012 265);
--input: oklch(0.89 0.012 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;
--sidebar: oklch(0.97 0.005 260);
--sidebar-foreground: oklch(0.175 0.015 260);
--sidebar-primary: oklch(0.51 0.17 265);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.94 0.01 260);
--sidebar-accent-foreground: oklch(0.25 0.02 260);
--sidebar-border: oklch(0.905 0.01 260);
--sidebar-ring: oklch(0.51 0.17 265);
--sidebar: var(--background);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
}
/* Dark theme — deep slate with warm amber accent */
/* Dark - "House lights down" */
.dark {
--background: oklch(0.155 0.015 265);
--background: oklch(0.20 0.03 272);
--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);
--popover: oklch(0.195 0.015 265);
--popover: oklch(0.24 0.03 272);
--popover-foreground: oklch(0.935 0.01 260);
--primary: oklch(0.72 0.15 75);
--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);
--muted: oklch(0.24 0.015 265);
--muted-foreground: oklch(0.62 0.01 260);
--accent: oklch(0.28 0.015 265);
--muted: oklch(0.28 0.03 272);
--muted-foreground: oklch(0.68 0.015 265);
--accent: oklch(0.32 0.03 272);
--accent-foreground: oklch(0.93 0.01 260);
--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%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.72 0.15 75);
--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);
--ring: var(--primary);
}
@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-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-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -95,6 +150,11 @@
--color-input: var(--input);
--color-border: var(--border);
--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: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@@ -102,6 +162,7 @@
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-text: var(--primary-text);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
@@ -118,23 +179,18 @@
--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 {
--animate-confetti: confetti-fall 2.5s ease-in forwards;
--animate-slide-in: slide-in-from-bottom 0.3s ease-out;
--animate-shake: shake 0.4s ease-in-out;
--animate-check-flash: check-flash 0.6s ease-out;
--animate-glow-pulse: glow-pulse 1.5s ease-in-out infinite;
}
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
--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 slide-in-from-bottom {
@@ -162,9 +218,22 @@
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 {
0%, 100% { box-shadow: 0 0 4px oklch(0.72 0.15 75 / 0.3); }
50% { box-shadow: 0 0 16px oklch(0.72 0.15 75 / 0.5); }
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 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 {
@@ -177,6 +246,9 @@
html {
@apply font-sans;
}
h1, h2, h3 {
text-wrap: balance;
}
button,
[role="button"],
a,
@@ -194,16 +266,18 @@
* transition in the app collapses to effectively instant - Tailwind's own
* utilities, the custom keyframes above, and anything added later. A single
* rule here is more reliable than remembering a motion-reduce: variant on each
* animated element. The confetti overlay additionally does not render at all,
* since a still frame of it has no meaning.
* animated element. The loop-draw keyframe ends fully drawn, so under this
* rule the closed loop simply appears.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
transition-delay: 0ms !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 { useNavigate } from 'react-router';
import { Badge } from '@/components/ui/badge';
import PageHeader from '@/components/layout/PageHeader';
import { Skeleton } from '@/components/ui/skeleton';
import PageLayout from '@/components/layout/PageLayout';
import { useAuthStore } from '@/stores/auth-store';
@@ -93,17 +93,12 @@ export default function Achievements() {
return (
<PageLayout>
<div className="space-y-6 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<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>
<PageHeader
title="Achievements"
description={`${unlocked.length} / ${achievements.length} unlocked`}
/>
<div className="space-y-8">
{loading && (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 8 }).map((_, i) => (
@@ -114,33 +109,33 @@ export default function Achievements() {
{!loading &&
Object.entries(grouped).map(([category, items]) => (
<div key={category}>
<h2 className="mb-3 text-sm font-semibold uppercase text-muted-foreground">
<section key={category} className="border-t border-border pt-6">
<h2 className="mb-4 font-display text-xl font-semibold">
{CATEGORY_LABELS[category] ?? category}
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<ul className="grid gap-3 sm:grid-cols-2">
{items.map((a) => (
<div
<li
key={a.id}
className={cn(
'flex items-start gap-3 rounded-lg border p-4 transition-colors',
a.unlocked
? 'border-green-500/30 bg-green-500/5'
: 'border-border bg-card opacity-60',
'flex items-start gap-3 rounded-lg bg-card p-4 ring-1',
a.unlocked ? 'ring-primary/40' : 'ring-foreground/10',
)}
>
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
a.unlocked
? 'bg-green-500/20 text-green-600'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground',
)}
>
{ICON_MAP[a.icon] ?? <Trophy className="h-5 w-5" />}
</div>
<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">
{a.description}
</p>
@@ -154,22 +149,22 @@ export default function Achievements() {
}}
/>
</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}
</p>
</div>
)}
{a.unlocked && a.unlockedAt && (
<p className="mt-1 text-xs text-green-600">
<p className="mt-1 text-xs text-primary-text">
Unlocked{' '}
{new Date(a.unlockedAt).toLocaleDateString()}
</p>
)}
</div>
</div>
</li>
))}
</div>
</div>
</ul>
</section>
))}
</div>
</PageLayout>
+5 -5
View File
@@ -210,7 +210,7 @@ describe('result status variants', () => {
await typeAndWait(user, 'sea');
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');
expect(within(card).queryByRole('button')).toBeNull();
});
@@ -228,7 +228,7 @@ describe('sending a request', () => {
searchUsers.mockResolvedValue([
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' });
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Friend request sent!'));
@@ -250,7 +250,7 @@ describe('sending a request', () => {
resolveSend = resolve;
}),
);
const addButton = screen.getByRole('button', { name: 'Add Friend' });
const addButton = screen.getByRole('button', { name: 'Add friend' });
await user.click(addButton);
expect(addButton).toBeDisabled();
@@ -268,9 +268,9 @@ describe('sending a request', () => {
sendFriendRequest.mockRejectedValue({
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'));
expect(screen.getByRole('button', { name: 'Add Friend' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Add friend' })).toBeEnabled();
});
});
+11 -18
View File
@@ -1,14 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { Link, useNavigate } from 'react-router';
import { useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth-store';
import { useFriendsStore } from '@/stores/friends-store';
import { getErrorMessage } from '@/lib/error';
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() {
const navigate = useNavigate();
@@ -58,14 +58,7 @@ export default function AddFriend() {
return (
<PageLayout>
<div className="space-y-6">
<div className="flex items-center gap-3">
<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>
<PageHeader title="Add a friend" back={{ to: '/friends', label: 'Back to friends' }} />
<div className="relative">
<Search
@@ -75,21 +68,21 @@ export default function AddFriend() {
<Input
type="search"
aria-label="Search by username"
placeholder="Search by username..."
placeholder="Search by username…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-2">
<div>
{query.trim() && searchResults.length === 0 && (
<p className="py-4 text-center text-muted-foreground">No users found</p>
)}
<ul className="divide-y divide-border">
{searchResults.map((result) => (
<Card key={result.id}>
<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 h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
{(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" />
)}
Add Friend
Add friend
</Button>
)}
</div>
</CardContent>
</Card>
</li>
))}
</ul>
</div>
</div>
</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', () => {
useAuthStore.setState({ user: null, isLoading: true });
renderAdmin();
expect(screen.queryByRole('heading', { name: 'Admin Panel' })).toBeNull();
expect(screen.queryByRole('heading', { name: 'Admin' })).toBeNull();
expect(screen.queryByText('Login Page')).toBeNull();
});
@@ -160,7 +160,7 @@ describe('access gate', () => {
useAuthStore.setState({ user: ADMIN_USER, isLoading: false });
renderAdmin();
expect(
await screen.findByRole('heading', { name: 'Admin Panel' }),
await screen.findByRole('heading', { name: 'Admin' }),
).toBeInTheDocument();
});
});
@@ -192,7 +192,7 @@ describe('platform stats', () => {
// isAbortError guard the request-cancellation tests below rely on.
statsSpy.mockImplementation(() => new Promise(() => undefined));
renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' });
await screen.findByRole('heading', { name: 'Admin' });
await userEvent.click(screen.getByRole('link', { name: 'Home' }));
@@ -425,7 +425,7 @@ describe('users table', () => {
() => new Promise((resolve) => { resolveUsers = resolve; }),
);
renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' });
await screen.findByRole('heading', { name: 'Admin' });
await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
@@ -442,7 +442,7 @@ describe('users table', () => {
() => new Promise((_resolve, reject) => { rejectUsers = reject; }),
);
renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' });
await screen.findByRole('heading', { name: 'Admin' });
await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
+4 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
@@ -26,7 +27,6 @@ import {
type BackfillUsernamesResult,
} from '@/api/admin';
import {
Shield,
BarChart3,
Users,
RefreshCw,
@@ -61,10 +61,7 @@ export default function Admin() {
return (
<PageLayout>
<div className="space-y-8 py-6">
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">Admin Panel</h1>
</div>
<PageHeader title="Admin" />
<StatsSection />
<Separator />
@@ -327,7 +324,7 @@ function GenerateChallengeSection() {
<div>
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
<CalendarPlus className="h-5 w-5" />
Generate Daily Challenge
Generate daily challenge
</h2>
<div className="flex flex-wrap items-end gap-3">
<div>
@@ -439,7 +436,7 @@ function UsersSection() {
) : users.length > 0 ? (
<>
<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>Joined</span>
<span className="text-right">Games</span>
+7 -7
View File
@@ -116,7 +116,7 @@ describe('a loaded leaderboard', () => {
getAsyncLeaderboard.mockResolvedValue(response());
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 B \(2002\)/)).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.
const matches = await screen.findAllByText('Expired');
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 () => {
@@ -188,8 +188,8 @@ describe('a loaded leaderboard', () => {
expect(await screen.findAllByText('#1')).toHaveLength(2);
expect(screen.getByTitle('Creator')).toBeInTheDocument();
expect(screen.getByText('YOU')).toBeInTheDocument();
expect(screen.getByText('1000')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('1,000')).toBeInTheDocument();
expect(screen.getByText('2,400')).toBeInTheDocument();
expect(screen.getByText('2 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 () => {
getAsyncLeaderboard.mockResolvedValue(response());
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();
});
});
@@ -250,7 +250,7 @@ describe('errors', () => {
}),
);
renderPage();
await screen.findByText('Async Challenge');
await screen.findByText('Async challenge');
const pairText = screen.getByText(/Movie A.*Movie B/);
expect(pairText.textContent).not.toContain('(');
});
+17 -26
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DifficultyBadge } from '@/components/ui/difficulty-badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthStore } from '@/stores/auth-store';
@@ -10,7 +12,7 @@ import {
type AsyncLeaderboardResponse,
type LeaderboardEntry,
} from '@/api/versus';
import { Trophy, Crown, Clock, ArrowLeft } from 'lucide-react';
import { Crown, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { rawToLinkCount } from '@/types';
import { releaseYear } from '@/lib/tmdb';
@@ -70,7 +72,7 @@ export default function AsyncMatchLeaderboard() {
return (
<PageLayout>
<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>
</div>
</PageLayout>
@@ -82,17 +84,11 @@ export default function AsyncMatchLeaderboard() {
return (
<PageLayout>
<div className="space-y-6 py-6">
<div className="flex items-center gap-2">
<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>
<div className="space-y-6">
<PageHeader title="Async challenge" back={{ to: '/versus', label: 'Back' }} />
{/* 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>
<p className="font-semibold">
@@ -126,27 +122,22 @@ export default function AsyncMatchLeaderboard() {
</span>
)}
{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">
Expired
</span>
<Badge variant="outline">Expired</Badge>
)}
</div>
</div>
{/* Leaderboard */}
<div className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-yellow-500" />
<h2 className="font-semibold">Leaderboard</h2>
<div className="border-t border-border pt-6">
<div className="mb-4 flex items-center gap-2">
<h2 className="font-display text-xl font-semibold">Leaderboard</h2>
<span className="text-sm text-muted-foreground">
({leaderboard.length} player{leaderboard.length !== 1 ? 's' : ''})
</span>
</div>
</div>
{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
</p>
) : (
@@ -178,9 +169,9 @@ function LeaderboardRow({
return (
<div
className={cn(
'flex items-center gap-3 px-4 py-3',
'flex items-center gap-3 py-3',
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">
@@ -192,17 +183,17 @@ function LeaderboardRow({
{entry.username}
</span>
{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 && (
<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
</span>
)}
</div>
</div>
<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">
{entry.chainLength != null ? rawToLinkCount(entry.chainLength) : '?'} links
</p>
+16 -9
View File
@@ -2,6 +2,13 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Link, MemoryRouter, Route, Routes } from 'react-router';
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 {
ActorChainLink,
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 -
* it renders exactly once, as soon as the game is ready to play. */
async function waitForReady() {
await screen.findByText('Chain: 0 links');
await screen.findByText('0 links');
}
beforeEach(() => {
@@ -364,7 +371,7 @@ describe('abort on unmount (WP cleanup)', () => {
// Switch to a different match before match-1's slow session finishes.
await userEvent.click(screen.getByText('go to match 2'));
await flush();
expect(await screen.findByText('Chain: 0 links')).toBeInTheDocument();
expect(await screen.findByText('0 links')).toBeInTheDocument();
expect(useGameStore.getState().movieA?.title).toBe('Third');
// 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).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Score Submitted!')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument();
expect(await screen.findByText('Score submitted')).toBeInTheDocument();
expect(screen.getByText('2,400')).toBeInTheDocument();
});
it('navigates from the completion modal to the leaderboard', async () => {
@@ -400,9 +407,9 @@ describe('creator mode', () => {
renderPage();
await waitForReady();
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();
});
@@ -440,8 +447,8 @@ describe('challenger mode', () => {
expect(submitAsyncAttempt).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' });
expect(submitAsyncAttempt).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Challenge Complete!')).toBeInTheDocument();
expect(screen.getByText('You beat the creator!')).toBeInTheDocument();
expect(await screen.findByText('Challenge complete')).toBeInTheDocument();
expect(screen.getByText('You beat the creator')).toBeInTheDocument();
expect(screen.getByText('Your rank:')).toBeInTheDocument();
expect(screen.getByText('#1')).toBeInTheDocument();
});
@@ -464,7 +471,7 @@ describe('challenger mode', () => {
closeTheLoop();
await flush();
expect(await screen.findByText('Challenge Complete!')).toBeInTheDocument();
expect(await screen.findByText('Challenge complete')).toBeInTheDocument();
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 AsyncCompletionModal from '@/components/versus/AsyncCompletionModal';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { Target, Trophy } from 'lucide-react';
import {
@@ -201,7 +200,7 @@ export default function AsyncVersusGame() {
return (
<PageLayout>
<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>
</div>
</PageLayout>
@@ -214,22 +213,22 @@ export default function AsyncVersusGame() {
return (
<PageLayout>
<div className="space-y-4">
<div className="space-y-6">
<GameHeader />
{/* 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">
{mode === 'creator' ? (
<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">
Your score will be the benchmark!
</span>
</div>
) : (
<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">
Target: {targetChainLength != null ? rawToLinkCount(targetChainLength) : '?'} links
</span>
@@ -238,8 +237,6 @@ export default function AsyncVersusGame() {
</div>
</div>
<Separator />
<ChainDisplay />
{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 () => {
renderChat();
await screen.findByText('Friendly Name');
expect(screen.queryByText('typing...')).toBeNull();
expect(screen.queryByText('typing…')).toBeNull();
useChatStore.setState({ typingFriendIds: new Set(['friend-1']) });
expect(await screen.findByText('typing...')).toBeInTheDocument();
expect(await screen.findByText('typing…')).toBeInTheDocument();
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>
<p className="text-sm font-medium">{friend?.displayName || friend?.username || 'Unknown'}</p>
{typingFriendIds.has(friendId!) && (
<p className="text-xs text-muted-foreground">typing...</p>
<p className="text-xs text-muted-foreground">typing…</p>
)}
</div>
</div>
@@ -208,7 +208,7 @@ export default function Chat() {
}`}
>
<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' })}
</p>
</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">
<Input
aria-label="Message"
placeholder="Type a message..."
placeholder="Type a message…"
value={input}
onChange={(e) => {
setInput(e.target.value);
+26 -26
View File
@@ -138,40 +138,40 @@ beforeEach(() => {
createSessionSpy.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
});
it('navigates home from the Back button', async () => {
it('navigates home from the Back link', async () => {
renderPage();
await userEvent.click(screen.getByRole('button', { name: /Back/ }));
await userEvent.click(screen.getByRole('link', { name: /Back/ }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
});
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();
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(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(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 () => {
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 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');
const [changeA, changeB] = screen.getAllByRole('button', { name: 'Change' });
if (!changeA || !changeB) throw new Error('expected two Change buttons');
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();
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();
});
@@ -181,14 +181,14 @@ describe('picking movies', () => {
// interface, and this pins that CustomGame's placeholder for it is inert.
renderPage();
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(
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 B...' })).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: 'Play' })).toBeDisabled();
});
@@ -196,23 +196,23 @@ describe('picking movies', () => {
renderPage();
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();
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();
});
it('warns and keeps Play disabled when the same movie is picked twice', async () => {
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 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(
await screen.findByText('Please select two different movies.'),
await screen.findByText('Pick two different movies.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
});
@@ -221,8 +221,8 @@ describe('picking movies', () => {
describe('starting the game', () => {
it('starts a custom game and navigates to /game', async () => {
renderPage();
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 A…' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument();
@@ -241,11 +241,11 @@ describe('starting the game', () => {
() => new Promise((resolve) => { release = () => resolve(movieDetails(1)); }),
);
renderPage();
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 A…' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
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();
release();
@@ -258,8 +258,8 @@ describe('starting the game', () => {
// with the message never shown.
movieDetailsSpy.mockRejectedValue(new Error('network'));
renderPage();
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 A…' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
+69 -93
View File
@@ -1,15 +1,17 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription } from '@/components/ui/alert';
import PageLayout from '@/components/layout/PageLayout';
import SearchAutocomplete from '@/components/game/SearchAutocomplete';
import { useGameStore } from '@/stores/game-store';
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 { cn } from '@/lib/utils';
interface SelectedMovie {
id: number;
@@ -73,98 +75,24 @@ export default function CustomGame() {
return (
<PageLayout>
<div className="mx-auto max-w-lg space-y-6 py-8">
<div className="space-y-2">
<Button variant="ghost" size="sm" onClick={() => navigate('/')}>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
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>
<PageHeader
title="Custom"
description="Pick any two movies and build a loop between them."
back={{ to: '/', label: 'Back' }}
/>
<div className="space-y-4">
<div className="space-y-2" role="group" aria-labelledby="custom-game-movie-a-label">
<span id="custom-game-movie-a-label" className="text-sm font-medium">Movie A</span>
{movieA ? (
<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>
<div className="max-w-lg space-y-6">
<div className="space-y-5">
<MovieSlot target="A" movie={movieA} onSelect={handleSelectMovieA} onClear={() => setMovieA(null)} />
<MovieSlot target="B" movie={movieB} onSelect={handleSelectMovieB} onClear={() => setMovieB(null)} />
{movieA && movieB && movieA.id === movieB.id && (
<p className="text-sm text-destructive">
Please select two different movies.
Pick two different movies.
</p>
)}
</div>
<Separator />
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
@@ -172,23 +100,71 @@ export default function CustomGame() {
)}
<Button
size="lg"
className="w-full text-base"
className="h-11 px-6 text-base"
disabled={!canPlay}
onClick={handlePlay}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
<Loader2 className="animate-spin" aria-hidden="true" />
Starting…
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Play
</>
'Play'
)}
</Button>
</div>
</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(screen.getByText('medium')).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 () => {
renderPage();
expect(
await screen.findByText('Saturday, June 15, 2024'),
await screen.findByText(/Saturday, June 15, 2024/),
).toBeInTheDocument();
});
it('shows no date subheading when there are no challenges to derive one from', async () => {
challengesSpy.mockResolvedValue([]);
renderPage();
await screen.findByRole('heading', { name: 'Daily Challenges' });
await screen.findByRole('heading', { name: "Today's challenge" });
expect(screen.queryByText(/2024/)).toBeNull();
});
@@ -145,7 +145,7 @@ describe('loading and listing', () => {
});
renderPage();
expect(
await screen.findByText(/haven't been generated yet/),
await screen.findByText(/aren't ready yet/),
).toBeInTheDocument();
expect(screen.queryByText(/Failed to load/)).toBeNull();
});
@@ -162,7 +162,7 @@ describe('completion state', () => {
localStorage.setItem('daily-completed-2024-06-15-medium', 'true');
renderPage();
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 () => {
@@ -170,7 +170,7 @@ describe('completion state', () => {
completionSpy.mockResolvedValue({ easy: true, medium: false, hard: false });
renderPage();
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 () => {
@@ -178,7 +178,7 @@ describe('completion state', () => {
completionSpy.mockRejectedValue(new Error('500'));
localStorage.setItem('daily-completed-2024-06-15-hard', 'true');
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.
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');
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[0]).toBeDisabled();
+79 -73
View File
@@ -3,12 +3,15 @@ import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button';
import { DifficultyBadge } from '@/components/ui/difficulty-badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useGameStore } from '@/stores/game-store';
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 { checkDailyCompletion, type DailyCompletionStatus } from '@/api/leaderboards';
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 { getErrorStatus } from '@/lib/error';
import { releaseYear } from '@/lib/tmdb';
@@ -19,6 +22,9 @@ export default function DailyChallenge() {
const [challenges, setChallenges] = useState<DailyChallengeType[]>([]);
const [loading, setLoading] = useState(true);
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>>({
easy: false,
medium: false,
@@ -34,6 +40,10 @@ export default function DailyChallenge() {
useEffect(() => {
getTodaysChallenges()
.then((list) => {
// A refetch (auth resolving after a direct load) replaces whatever
// the previous attempt concluded.
setError(null);
setNotReady(false);
setChallenges(list);
// 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 -
// 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
// read as one.
setError(
getErrorStatus(err) === 404
? "Today's challenges haven't been generated yet. They are created at 00:00 UTC - check back then."
: 'Failed to load today\'s challenges.',
),
)
if (getErrorStatus(err) === 404) {
setError(null);
setNotReady(true);
} else {
setNotReady(false);
setError("Failed to load today's challenges.");
}
})
.finally(() => setLoading(false));
}, [userId]);
@@ -118,109 +130,103 @@ export default function DailyChallenge() {
};
const firstChallenge = challenges[0];
const resetNote = `New puzzles at 00:00 UTC, which is ${getLocalResetTime()} for you.`;
return (
<PageLayout>
<div className="flex flex-col items-center gap-8 py-12">
<div className="space-y-2 text-center">
<div className="flex items-center justify-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<h1 className="text-2xl font-bold">Daily Challenges</h1>
</div>
{firstChallenge && (
<p className="text-sm text-muted-foreground">
{formatDate(firstChallenge.date)}
</p>
)}
</div>
<PageHeader
title="Today's challenge"
description={
firstChallenge
? `${formatDate(firstChallenge.date)}. Three puzzles, the same for everyone. ${resetNote}`
: `Three puzzles a day, the same for everyone. ${resetNote}`
}
/>
{loading && (
<div className="w-full max-w-md space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
<div className="space-y-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
)}
{notReady && (
<Alert>
<AlertDescription>
Today's puzzles aren't ready yet. They're made at 00:00 UTC ({getLocalResetTime()});
check back then.
</AlertDescription>
</Alert>
)}
{error && (
<p role="alert" className="text-destructive">{error}</p>
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{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) => {
const diff = challenge.difficulty as Difficulty;
const isCompleted = completed[diff];
return (
<div
<li
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} />
<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 className="col-span-2 flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1 sm:col-span-1">
<span className="inline-flex items-center gap-1.5 font-medium">
<MovieMarker target="A" />
<span className="text-movie-a">{challenge.movieATitle}</span>
{challenge.movieAReleaseDate && (
<span className="text-xs text-muted-foreground">
{releaseYear(challenge.movieAReleaseDate)}
</span>
)}
</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 className="mt-4 space-y-3">
<div className="rounded-md bg-muted/50 p-3 text-center">
<p className="text-xs font-medium uppercase text-muted-foreground">
Movie A
</p>
<p className="text-lg font-semibold">{challenge.movieATitle}</p>
{challenge.movieAReleaseDate && (
<p className="text-xs text-muted-foreground">
{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>
<span className="text-sm text-muted-foreground tabular-nums">
Par {challenge.par}
</span>
<Button
className="mt-6 w-full"
size="lg"
className="justify-self-end sm:min-w-24"
variant={isCompleted ? 'outline' : 'default'}
onClick={() => handlePlay(challenge)}
disabled={isValidating || isCompleted}
>
{isCompleted ? (
'Completed'
'Played'
) : isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
<Loader2 className="animate-spin" aria-hidden="true" />
Starting…
</>
) : (
'Play'
)}
</Button>
</div>
</li>
);
})}
<div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>New challenges at midnight UTC ({getLocalResetTime()} local)</span>
</div>
</div>
</ul>
)}
</div>
</PageLayout>
);
}
+14 -14
View File
@@ -91,14 +91,14 @@ afterEach(() => {
});
describe('filters panel', () => {
it('is hidden until "Show Filters" is toggled', async () => {
it('is hidden until "Show filters" is toggled', async () => {
renderPage();
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('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();
});
});
@@ -107,10 +107,10 @@ describe('starting a game', () => {
it('uses a preset pair for the chosen decade', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
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.getByRole('button', { name: 'Start Practice Game' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument();
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 () => {
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('1970s'));
// Reset both filters back to unfiltered before starting.
await userEvent.click(screen.getAllByText('Any')[0] 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(discoverSpy).toHaveBeenCalledTimes(1);
@@ -144,11 +144,11 @@ describe('starting a game', () => {
);
vi.spyOn(Math, 'random').mockReturnValue(0);
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.getByRole('button', { name: 'Start Practice Game' }));
const loadingButton = await screen.findByRole('button', { name: 'Loading...' });
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
const loadingButton = await screen.findByRole('button', { name: 'Finding a pair…' });
expect(loadingButton).toBeDisabled();
release();
@@ -162,11 +162,11 @@ describe('starting a game', () => {
discoverSpy.mockRejectedValue(new Error('Network Error'));
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(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 () => {
@@ -178,7 +178,7 @@ describe('starting a game', () => {
});
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/);
});
@@ -187,7 +187,7 @@ describe('starting a game', () => {
movieDetailsSpy.mockRejectedValue(new Error('network'));
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(
'Failed to load movie details. Please try again.',
+16 -21
View File
@@ -9,7 +9,8 @@ import { discoverMovies } from '@/api/movies';
import { pickDistinctPair } from '@/lib/movie-pair';
import { getErrorMessage } from '@/lib/error';
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() {
const startGame = useGameStore((s) => s.startGame);
@@ -66,26 +67,21 @@ export default function Endless() {
return (
<PageLayout>
<div className="flex flex-col items-center gap-8 py-12">
<div className="space-y-2 text-center">
<div className="flex items-center justify-center gap-2">
<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>
<PageHeader
title="Endless"
description="Random pairs, no clock, no leaderboard. Narrow the pairs by genre or decade if you like."
/>
<div className="w-full max-w-md space-y-4">
<div className="rounded-lg border border-border bg-card p-6">
<div className="max-w-md space-y-5">
<div>
<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)}
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" />
{showFilters ? 'Hide filters' : 'Show filters'}
</button>
{showFilters && (
@@ -146,21 +142,20 @@ export default function Endless() {
)}
<Button
size="lg"
className="w-full text-base"
className="h-11 px-6 text-base"
onClick={handleStart}
disabled={starting || isValidating}
>
{starting || isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
<Loader2 className="animate-spin" aria-hidden="true" />
Finding a pair…
</>
) : (
'Start Practice Game'
'Play'
)}
</Button>
</div>
</div>
</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,
* 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
* seam `game-store.test.ts` and `Versus.test.tsx` use. The presence socket
* lives on the (real) notification store, seeded directly rather than
@@ -173,8 +173,12 @@ describe('authentication gate', () => {
describe('friends list', () => {
it('shows an empty state with a link to find friends', async () => {
renderFriends();
expect(await screen.findByText('No friends yet')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Find Friends' })).toHaveAttribute(
expect(
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',
'/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 () => {
getFriends.mockResolvedValue([friendEntry()]);
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');
expect(within(card).queryByText('Online')).toBeNull();
useFriendsStore.getState().setOnlineFriends(['friend-1']);
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 () => {
@@ -258,7 +262,7 @@ describe('removing a friend', () => {
it('has a working remove control, and removes the friend from the list', async () => {
getFriends.mockResolvedValueOnce([friendEntry()]);
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');
const removeButton = within(card).getByRole('button', {
@@ -308,7 +312,7 @@ describe('presence', () => {
useNotificationStore.setState({ socket: socket as unknown as ReturnType<typeof useNotificationStore.getState>['socket'] });
getFriends.mockResolvedValue([friendEntry()]);
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');
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'] });
getFriends.mockResolvedValue([friendEntry()]);
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');
socket.trigger('friends-online', { onlineIds: ['friend-1'] });
@@ -336,7 +340,7 @@ describe('challenge friend dialog', () => {
getFriends.mockResolvedValue([friendEntry()]);
renderFriends();
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 () => {
@@ -353,7 +357,7 @@ describe('challenge friend dialog', () => {
const dialog = await openDialog();
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({
friendId: 'friend-1',
@@ -369,7 +373,7 @@ describe('challenge friend dialog', () => {
const dialog = await openDialog();
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!'));
expect(screen.queryByRole('dialog')).toBeNull();
@@ -382,10 +386,10 @@ describe('challenge friend dialog', () => {
});
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'));
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 () => {
+32 -32
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
@@ -115,15 +115,17 @@ export default function Friends() {
return (
<PageLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Friends</h1>
<PageHeader
title="Friends"
actions={
<Link to="/friends/add">
<Button variant="outline" size="sm">
<UserPlus className="mr-2 h-4 w-4" />
Add Friend
Add friend
</Button>
</Link>
</div>
}
/>
{/* Tabs */}
<div className="flex gap-2 border-b border-border">
@@ -157,21 +159,21 @@ export default function Friends() {
{/* Friends List */}
{tab === 'friends' && !loading && (
<div className="space-y-2">
<div>
{friends.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center py-8 text-center">
<div className="flex flex-col items-center py-8 text-center">
<Users className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground">No friends yet</p>
<p className="text-muted-foreground">
You haven&apos;t added anyone yet. Find a friend by username to challenge them.
</p>
<Link to="/friends/add" className="mt-2">
<Button variant="outline" size="sm">Find Friends</Button>
<Button variant="outline" size="sm">Find friends</Button>
</Link>
</CardContent>
</Card>
</div>
) : (
friends.map(({ friendshipId, friend }) => (
<Card key={friendshipId}>
<CardContent className="flex items-center justify-between py-3 px-4">
<ul className="divide-y divide-border">
{friends.map(({ friendshipId, friend }) => (
<li key={friendshipId} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className="relative">
<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
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>
</>
@@ -222,26 +224,24 @@ export default function Friends() {
/>
</Button>
</div>
</CardContent>
</Card>
))
</li>
))}
</ul>
)}
</div>
)}
{/* Pending Requests */}
{tab === 'pending' && !loading && (
<div className="space-y-2">
<div>
{pendingRequests.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<p className="py-8 text-center text-muted-foreground">
No pending requests
</CardContent>
</Card>
</p>
) : (
pendingRequests.map(({ friendshipId, from }) => (
<Card key={friendshipId}>
<CardContent className="flex items-center justify-between py-3 px-4">
<ul className="divide-y divide-border">
{pendingRequests.map(({ friendshipId, from }) => (
<li key={friendshipId} className="flex items-center justify-between py-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">
{(from.displayName || from.username).charAt(0).toUpperCase()}
@@ -274,9 +274,9 @@ export default function Friends() {
Decline
</Button>
</div>
</CardContent>
</Card>
))
</li>
))}
</ul>
)}
</div>
)}
@@ -292,7 +292,7 @@ export default function Friends() {
>
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Challenge Friend</DialogTitle>
<DialogTitle>Challenge friend</DialogTitle>
<DialogDescription>
Pick a difficulty and how the match is played.
</DialogDescription>
@@ -356,7 +356,7 @@ export default function Friends() {
) : (
<Swords className="mr-2 h-4 w-4" aria-hidden="true" />
)}
Send Challenge
Send challenge
</Button>
</DialogFooter>
</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}`);
}
// 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', () => {
const call = (method: string) => async (url: string) => unrouted(method, url);
return {
@@ -106,7 +112,7 @@ describe('a game in progress', () => {
expect(
screen.getByText('Who was in "Heat"?'),
).toBeInTheDocument();
expect(screen.queryByText('Congratulations!')).toBeNull();
expect(screen.queryByText('Loop closed.')).toBeNull();
});
it('does not redirect while a game is playing', async () => {
@@ -129,8 +135,8 @@ describe('a completed game', () => {
});
renderPage();
expect(await screen.findByText('Congratulations!')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument();
expect(await screen.findByText('Loop closed.')).toBeInTheDocument();
expect(screen.getByText('2,400')).toBeInTheDocument();
});
it('shows a calculating spinner when the score has not arrived yet', async () => {
@@ -146,7 +152,7 @@ describe('a completed game', () => {
});
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 () => {
@@ -163,6 +169,6 @@ describe('a completed game', () => {
renderPage();
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 GameSearchPanel from '@/components/game/GameSearchPanel';
import GameCompletionModal from '@/components/game/GameCompletionModal';
import { Separator } from '@/components/ui/separator';
export default function Game() {
const screen = useGameScreen();
@@ -25,11 +24,9 @@ export default function Game() {
return (
<PageLayout>
<div className="space-y-4">
<div className="space-y-6">
<GameHeader />
<Separator />
<ChainDisplay />
{status === 'playing' && <GameSearchPanel screen={screen} />}
+37 -37
View File
@@ -187,13 +187,13 @@ describe('logged out', () => {
expect(
await screen.findByText('You need to be logged in to play Game Night.'),
).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();
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();
});
});
@@ -201,7 +201,7 @@ describe('logged out', () => {
it('sends Back to the home route regardless of auth', async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole('button', { name: /Back/ }));
await user.click(screen.getByRole('link', { name: /Back/ }));
expect(await screen.findByText('home page')).toBeInTheDocument();
});
@@ -215,11 +215,11 @@ describe('browse rooms', () => {
renderPage();
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([]);
expect(
await screen.findByText('No open rooms. Create one!'),
await screen.findByText('No rooms open. Host one and send friends the link.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Refresh/ })).toBeEnabled();
});
@@ -228,7 +228,7 @@ describe('browse rooms', () => {
loginUser();
const user = userEvent.setup();
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);
getOpenRoomsApi.mockResolvedValue([room({ roomName: 'Second Fetch' })]);
@@ -384,19 +384,19 @@ describe('create room', () => {
async function openCreateTab() {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Create Room' }));
await user.click(await screen.findByRole('button', { name: 'Create room' }));
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
* the first of the two in document order.
*/
function submitButton() {
const buttons = screen.getAllByRole('button', { name: /Create Room/ });
const buttons = screen.getAllByRole('button', { name: /Create room/ });
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;
}
@@ -405,22 +405,22 @@ describe('create room', () => {
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();
expect(submitButton()).toBeDisabled();
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();
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();
});
it('shows a poster once a movie is picked, and clears it via Change', async () => {
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' });
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' }));
expect(
screen.getByRole('button', { name: 'Search for Movie A...' }),
screen.getByRole('button', { name: 'Search for Movie A…' }),
).toBeInTheDocument();
});
it('omits the poster image when the movie has none', async () => {
const user = await openCreateTab();
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' });
expect(within(group).queryByRole('img')).toBeNull();
@@ -446,32 +446,32 @@ describe('create room', () => {
it('clears movie B via its own Change button', async () => {
const user = await openCreateTab();
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' });
expect(within(group).getByRole('button', { name: 'Change' })).toBeInTheDocument();
await user.click(within(group).getByRole('button', { name: 'Change' }));
expect(
screen.getByRole('button', { name: 'Search for Movie B...' }),
screen.getByRole('button', { name: 'Search for Movie B…' }),
).toBeInTheDocument();
});
it('returns to the browse tab from the create tab', async () => {
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 () => {
const user = await openCreateTab();
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');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
await user.click(screen.getByRole('button', { name: 'Search for Movie B…' }));
expect(
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 () => {
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.
const { fireEvent } = await import('@testing-library/react');
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 () => {
const user = await openCreateTab();
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');
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.click(screen.getByRole('switch', { name: 'I want to play' })); // -> spectating
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 () => {
const user = await openCreateTab();
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');
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' }));
await user.click(submitButton());
@@ -549,18 +549,18 @@ describe('create room', () => {
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();
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');
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>();
createRoomApi.mockReturnValue(gate.promise);
await user.click(submitButton());
const button = screen.getByRole('button', { name: /Creating.../ });
const button = screen.getByRole('button', { name: /Creating…/ });
expect(button).toBeDisabled();
gate.resolve(room({ id: 'new-room' }));
@@ -570,9 +570,9 @@ describe('create room', () => {
it('re-enables the form when room creation fails', async () => {
const user = await openCreateTab();
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');
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'));
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 { Separator } from '@/components/ui/separator';
import PageLayout from '@/components/layout/PageLayout';
import PageHeader from '@/components/layout/PageHeader';
import SearchAutocomplete from '@/components/game/SearchAutocomplete';
import { useAuthStore } from '@/stores/auth-store';
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 { TmdbMovieResult } from '@/types';
import {
ArrowLeft,
Loader2,
Lock,
Plus,
@@ -146,20 +146,15 @@ export default function GameNight() {
return (
<PageLayout>
<div className="mx-auto max-w-2xl space-y-6 py-8">
<div className="space-y-2">
<Button variant="ghost" size="sm" onClick={() => navigate('/')}>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
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>
<div className="space-y-6">
<PageHeader
title="Game night"
description="Compete with friends on the same movie pair. 2-16 players."
back={{ to: '/', label: 'Back' }}
/>
{!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">
You need to be logged in to play Game Night.
</p>
@@ -169,7 +164,7 @@ export default function GameNight() {
className="mt-2"
onClick={() => navigate('/login')}
>
Log In
Sign in
</Button>
</div>
)}
@@ -182,7 +177,7 @@ export default function GameNight() {
size="sm"
onClick={() => setTab('browse')}
>
Browse Rooms
Browse rooms
</Button>
<Button
variant={tab === 'create' ? 'default' : 'outline'}
@@ -190,16 +185,14 @@ export default function GameNight() {
onClick={() => setTab('create')}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Create Room
Create room
</Button>
</div>
<Separator />
{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">
<h2 className="text-lg font-semibold">Open Rooms</h2>
<h2 className="font-display text-xl font-semibold">Open rooms</h2>
<Button
variant="ghost"
size="sm"
@@ -215,7 +208,7 @@ export default function GameNight() {
{openRooms.length === 0 && !loadingRooms && (
<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>
)}
@@ -225,11 +218,11 @@ export default function GameNight() {
</div>
)}
<div className="space-y-3">
<div className="divide-y divide-border">
{openRooms.map((room) => (
<div
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="flex items-center gap-2">
@@ -279,7 +272,7 @@ export default function GameNight() {
{/* Password prompt dialog */}
{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">
This room requires a password
</p>
@@ -292,7 +285,7 @@ export default function GameNight() {
value={joinPassword}
onChange={(e) => setJoinPassword(e.target.value)}
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">
<Button size="sm" onClick={handleJoinWithPassword}>
@@ -312,13 +305,13 @@ export default function GameNight() {
)}
{tab === 'create' && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Create Room</h2>
<div className="space-y-4 border-t border-border pt-6">
<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">
<span id="game-night-movie-a-label" className="text-sm font-medium">Movie A</span>
{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 && (
<img
src={posterUrl(movieA.posterPath, 'w92')!}
@@ -345,7 +338,7 @@ export default function GameNight() {
mode="movie"
onSelectActor={() => {}}
onSelectMovie={handleSelectMovieA}
placeholder="Search for Movie A..."
placeholder="Search for Movie A…"
/>
)}
</div>
@@ -353,7 +346,7 @@ export default function GameNight() {
<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>
{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 && (
<img
src={posterUrl(movieB.posterPath, 'w92')!}
@@ -380,7 +373,7 @@ export default function GameNight() {
mode="movie"
onSelectActor={() => {}}
onSelectMovie={handleSelectMovieB}
placeholder="Search for Movie B..."
placeholder="Search for Movie B…"
/>
)}
</div>
@@ -395,7 +388,7 @@ export default function GameNight() {
<div className="space-y-2">
<label htmlFor="game-night-room-name" className="text-sm font-medium">
Room Name (optional)
Room name (optional)
</label>
<input
id="game-night-room-name"
@@ -448,7 +441,7 @@ export default function GameNight() {
<div className="space-y-2">
<label htmlFor="game-night-max-players" className="text-sm font-medium">
Max Players: {maxPlayers}
Max players: {maxPlayers}
</label>
<input
id="game-night-max-players"
@@ -462,7 +455,7 @@ export default function GameNight() {
</div>
<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">
{[null, 5, 10, 15, 30].map((mins) => (
<Button
@@ -490,10 +483,10 @@ export default function GameNight() {
{creating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
Creating…
</>
) : (
'Create Room'
'Create room'
)}
</Button>
</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 () => {
renderLobby();
expect(createSocketMock).toHaveBeenCalledWith('/game-night');
expect(screen.getByText('Connecting...')).toBeInTheDocument();
expect(screen.getByText('Connecting…')).toBeInTheDocument();
act(() => socket().fire('connect'));
@@ -218,8 +218,8 @@ describe('lobby - non-host', () => {
expect(await screen.findByText('Fun Room')).toBeInTheDocument();
expect(screen.getByText('2/4')).toBeInTheDocument();
expect(screen.getByText('15m limit')).toBeInTheDocument();
expect(screen.getByText('Waiting for host to start...')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Start Game/ })).toBeNull();
expect(screen.getByText('Waiting for host to start…')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Start game/ })).toBeNull();
await user.click(screen.getByRole('button', { name: 'Ready' }));
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(await screen.findByText('game night home')).toBeInTheDocument();
@@ -260,11 +260,11 @@ describe('lobby - non-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();
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(
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(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();
renderLobby();
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();
await user.click(startButton);
@@ -325,7 +325,7 @@ describe('lobby - host', () => {
renderLobby();
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(await screen.findByText('game night home')).toBeInTheDocument();
@@ -338,15 +338,15 @@ describe('countdown', () => {
it('announces the countdown assertively and hides the lobby roster', async () => {
renderLobby();
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 }));
const timer = screen.getByRole('timer');
expect(timer).toHaveAttribute('aria-live', 'assertive');
expect(timer).toHaveTextContent('3');
expect(screen.getByText('Get ready...')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Start Game/ })).toBeNull();
expect(screen.getByText('Get ready…')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Start game/ })).toBeNull();
});
});
@@ -430,7 +430,7 @@ describe('unmount', () => {
it('unregisters its socket listeners', async () => {
const { unmount } = renderLobby();
connectAndJoin();
await screen.findByText(/Waiting for host|Start Game/);
await screen.findByText(/Waiting for host|Start game/);
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
announced assertively; without this a screen-reader user was
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}
</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>
)}
@@ -177,11 +180,11 @@ export default function GameNightLobby() {
disabled={!canStart}
>
<Play className="mr-2 h-4 w-4" />
Start Game
Start game
</Button>
) : (
<p className="text-muted-foreground">
Waiting for host to start...
Waiting for host to start…
</p>
)}
@@ -195,7 +198,7 @@ export default function GameNightLobby() {
<Button variant="ghost" size="sm" onClick={handleLeave}>
<X className="mr-1.5 h-3.5 w-3.5" />
{isHost ? 'Cancel Room' : 'Leave Room'}
{isHost ? 'Cancel room' : 'Leave room'}
</Button>
</div>
</div>
@@ -206,7 +209,7 @@ export default function GameNightLobby() {
lobbyState !== 'countdown' && (
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-muted-foreground">Connecting...</span>
<span className="text-muted-foreground">Connecting…</span>
</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 { MemoryRouter, Routes, Route } from 'react-router';
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';
/**
@@ -127,7 +134,7 @@ describe('while playing', () => {
useGameNightStore.setState({ isSpectator: false });
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', () => {
@@ -135,16 +142,16 @@ describe('while playing', () => {
useGameNightStore.setState({ isSpectator: true });
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();
useGameNightStore.setState({ isSpectator: false });
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole('button', { name: /I Give Up/ }));
await user.click(screen.getByRole('button', { name: /Give up/ }));
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