diff --git a/CHANGELOG.md b/CHANGELOG.md
index 212ce56..fa024f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 8f968df..011bb62 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/index.html b/index.html
index 9a1f892..57aba01 100644
--- a/index.html
+++ b/index.html
@@ -2,12 +2,13 @@
+
You Know Who Else Was In That Movie?
-
+
diff --git a/package-lock.json b/package-lock.json
index a129887..50dfaf7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index d9fcddf..3a83c36 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..0969865
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/public/manifest.json b/public/manifest.json
index 0017758..b42c581 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -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",
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 8d3e218..3cdb3e7 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -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();
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();
- 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();
});
});
diff --git a/src/components/game-night/GameNightCompletionModal.test.tsx b/src/components/game-night/GameNightCompletionModal.test.tsx
index 1f336cf..f9f4269 100644
--- a/src/components/game-night/GameNightCompletionModal.test.tsx
+++ b/src/components/game-night/GameNightCompletionModal.test.tsx
@@ -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()),
+ 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();
});
diff --git a/src/components/game-night/GameNightCompletionModal.tsx b/src/components/game-night/GameNightCompletionModal.tsx
index 2d43337..f2bb6d4 100644
--- a/src/components/game-night/GameNightCompletionModal.tsx
+++ b/src/components/game-night/GameNightCompletionModal.tsx
@@ -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() {
- {lobbyState === 'finished' ? 'Game Over!' : 'Chain Complete!'}
+ {lobbyState === 'finished' ? 'Game over' : 'Chain complete'}
{score && (
-
-
{score.totalScore}
-
Your Score
+
+
{score.totalScore.toLocaleString()}
+
Your score
)}
@@ -67,24 +72,24 @@ export default function GameNightCompletionModal() {
Waiting for {waitingCount} player
- {waitingCount !== 1 ? 's' : ''}...
+ {waitingCount !== 1 ? 's' : ''}…
);
}
diff --git a/src/components/game/CelebrationOverlay.test.tsx b/src/components/game/CelebrationOverlay.test.tsx
deleted file mode 100644
index 1e5b9ba..0000000
--- a/src/components/game/CelebrationOverlay.test.tsx
+++ /dev/null
@@ -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();
- 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();
- expect(container.firstElementChild?.children).toHaveLength(20);
- });
-
- it('renders nothing under prefers-reduced-motion', () => {
- stubMatchMedia(true);
- const { container } = render();
- expect(container).toBeEmptyDOMElement();
- });
-
- it('disappears on its own after three seconds', () => {
- stubMatchMedia(false);
- const { container } = render();
- expect(container).not.toBeEmptyDOMElement();
-
- act(() => {
- vi.advanceTimersByTime(3000);
- });
- expect(container).toBeEmptyDOMElement();
- });
-});
diff --git a/src/components/game/CelebrationOverlay.tsx b/src/components/game/CelebrationOverlay.tsx
deleted file mode 100644
index 3d2e77c..0000000
--- a/src/components/game/CelebrationOverlay.tsx
+++ /dev/null
@@ -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 (
-
- {pieces.map((piece, i) => (
-
- ))}
-
- );
-}
diff --git a/src/components/game/ChainDisplay.test.tsx b/src/components/game/ChainDisplay.test.tsx
index ce8cee8..1d8c7e2 100644
--- a/src/components/game/ChainDisplay.test.tsx
+++ b/src/components/game/ChainDisplay.test.tsx
@@ -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();
- // 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();
- 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();
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();
// 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();
- 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();
- 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();
- 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();
- expect(screen.queryByText('A')).toBeNull();
+ // Only the anchor card's own marker.
+ expect(screen.getAllByText('A')).toHaveLength(1);
});
});
diff --git a/src/components/game/ChainDisplay.tsx b/src/components/game/ChainDisplay.tsx
index 32dafef..2a06151 100644
--- a/src/components/game/ChainDisplay.tsx
+++ b/src/components/game/ChainDisplay.tsx
@@ -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(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 (
-
+
{/* Starting movie (Movie A) — full width */}
{startMovie && (
-
+