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:
1 parent
da32e49b0b
commit
8d0b7f39ee
122 files changed
+2872
-1832
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
Generated
+12
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 "{movieA.title}" to close the loop!
|
||||
<p className="text-sm font-medium text-primary-text">
|
||||
Pick {movieA.title} to close the loop.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 · {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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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('(');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">⇵</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
@@ -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
@@ -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
@@ -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
@@ -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'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
@@ -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
@@ -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} />}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user