diff --git a/CHANGELOG.md b/CHANGELOG.md index 212ce56..adf3a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.4.0] - 2026-09-12 + +### Added + +- The app icon: Safari, the iOS home screen, Android install and push + notifications now show the Movie Loop mark on the stage colour instead of + the old film-reel artwork, with a maskable variant so Android no longer + letterboxes it. `npm run icons` re-renders every PNG from the SVG sources + with Inkscape. + +## [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..49f157a 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ npm test # vitest + testing-library, jsdom npm run test:cov # what CI runs: fails below 90% lines/branches/functions/statements npm run build # tsc -b && vite build -> dist/ npm run preview # serve dist/ locally +npm run icons # re-render public/*.png from the SVG mark (needs Inkscape) ``` Coverage thresholds live in `vite.config.ts`; every source file is above them individually. The suite mocks the axios client and fails loudly on any request it did not route, and mocks the socket factory with a controllable fake, so no test can reach a real backend. Pages are rendered with real Zustand stores seeded via `setState`, so the stores' selectors and actions are covered by the page tests as well as their own. One toolchain note: `vi.useFakeTimers()` combined with `@testing-library/user-event` deadlocks under this vitest/jsdom combination — tests that need both use real timers and `waitFor`. @@ -76,23 +77,37 @@ 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), and the PNG icons rendered from it by scripts/make-icons.mjs └── 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. + +The mark lives once as `public/favicon.svg`. The PNGs Safari, iOS, Android and push notifications need are rendered from it and from `scripts/icons/app-icon*.svg` (the mark on the stage colour, inside the maskable safe zone) by `npm run icons`, which drives Inkscape - on PATH, at `INKSCAPE`, or in its default install folder. Edit the SVGs, run it, commit the PNGs. + ### 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 +124,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..3d0c742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "movie-loop-frontend", - "version": "2.2.3", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "movie-loop-frontend", - "version": "2.2.3", + "version": "2.4.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..5570ad8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "movie-loop-frontend", "private": true, - "version": "2.2.3", + "version": "2.4.0", "type": "module", "license": "MIT", "author": "Kevin Riehl", @@ -13,6 +13,7 @@ "test:watch": "vitest", "test:cov": "vitest run --coverage", "preview": "vite preview", + "icons": "node scripts/make-icons.mjs", "release": "commit-and-tag-version", "release:minor": "commit-and-tag-version --release-as minor", "release:major": "commit-and-tag-version --release-as major", @@ -20,6 +21,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/apple-touch-icon.png b/public/apple-touch-icon.png index f052974..ef8dde1 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-32.png b/public/favicon-32.png index 0bd1330..30c5d99 100644 Binary files a/public/favicon-32.png and b/public/favicon-32.png differ 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/icon-192.png b/public/icon-192.png index d120159..cb34605 100644 Binary files a/public/icon-192.png and b/public/icon-192.png differ diff --git a/public/icon-512-maskable.png b/public/icon-512-maskable.png new file mode 100644 index 0000000..402b509 Binary files /dev/null and b/public/icon-512-maskable.png differ diff --git a/public/icon-512.png b/public/icon-512.png index a45d875..c3f2938 100644 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json index 0017758..f14a870 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", @@ -15,6 +15,12 @@ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/scripts/icons/app-icon-maskable.svg b/scripts/icons/app-icon-maskable.svg new file mode 100644 index 0000000..7b0daed --- /dev/null +++ b/scripts/icons/app-icon-maskable.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/icons/app-icon.svg b/scripts/icons/app-icon.svg new file mode 100644 index 0000000..45b7a13 --- /dev/null +++ b/scripts/icons/app-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs new file mode 100644 index 0000000..83b429b --- /dev/null +++ b/scripts/make-icons.mjs @@ -0,0 +1,79 @@ +// Renders the raster icons in public/ from their SVG sources with Inkscape. +// +// The browser tab uses public/favicon.svg directly, but Safari, the iOS home +// screen, Android's PWA install and push notifications all want PNGs, and +// those can't be transparent (iOS fills a transparent icon with black) or +// edge-to-edge (Android crops adaptive icons to a circle or squircle). So +// there are two sources: the bare mark for the 32px favicon, and the mark on +// the app's stage colour for everything else, sized to sit inside the +// maskable safe zone. +// +// Inkscape is used because it is already a fixture of the design workflow; +// rendering with it means no image library in node_modules. Its installer +// does not put it on PATH on Windows, so the standard install folders are +// tried after PATH and an explicit INKSCAPE environment variable. +// +// Usage: node scripts/make-icons.mjs +// INKSCAPE="C:\path\to\inkscape.com" node scripts/make-icons.mjs + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Every PNG the app ships, with the SVG it is rendered from. */ +export const ICONS = [ + { source: 'public/favicon.svg', out: 'public/favicon-32.png', size: 32 }, + { source: 'scripts/icons/app-icon.svg', out: 'public/apple-touch-icon.png', size: 180 }, + { source: 'scripts/icons/app-icon.svg', out: 'public/icon-192.png', size: 192 }, + { source: 'scripts/icons/app-icon.svg', out: 'public/icon-512.png', size: 512 }, + { source: 'scripts/icons/app-icon-maskable.svg', out: 'public/icon-512-maskable.png', size: 512 }, +]; + +/** Where Inkscape's installer puts the console binary when it is not on PATH. */ +export const INKSCAPE_CANDIDATES = [ + 'C:\\Program Files\\Inkscape\\bin\\inkscape.com', + 'C:\\Program Files (x86)\\Inkscape\\bin\\inkscape.com', + '/Applications/Inkscape.app/Contents/MacOS/inkscape', + '/usr/bin/inkscape', + '/usr/local/bin/inkscape', +]; + +/** + * The Inkscape binary to run: the INKSCAPE variable if set, otherwise the + * first standard location that exists, otherwise the bare name and PATH. + */ +export function findInkscape(env = process.env, exists = existsSync) { + if (env.INKSCAPE) return env.INKSCAPE; + const found = INKSCAPE_CANDIDATES.find((p) => exists(p)); + return found ?? 'inkscape'; +} + +/** The Inkscape 1.x command line for one PNG. */ +export function exportArgs({ source, out, size }) { + return [ + source, + '--export-type=png', + `--export-filename=${out}`, + `--export-width=${size}`, + `--export-height=${size}`, + ]; +} + +export function makeIcons(root, run = execFileSync) { + const inkscape = findInkscape(); + for (const icon of ICONS) { + const args = exportArgs({ + source: resolve(root, icon.source), + out: resolve(root, icon.out), + size: icon.size, + }); + run(inkscape, args, { stdio: 'inherit' }); + console.log(`[icons] ${icon.out} (${icon.size}px) <- ${icon.source}`); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + makeIcons(root); +} diff --git a/scripts/make-icons.test.ts b/scripts/make-icons.test.ts new file mode 100644 index 0000000..6da36ca --- /dev/null +++ b/scripts/make-icons.test.ts @@ -0,0 +1,96 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { + ICONS, + INKSCAPE_CANDIDATES, + exportArgs, + findInkscape, + makeIcons, +} from './make-icons.mjs'; + +/** + * The PNG icons are rendered from SVG by Inkscape rather than checked in by + * hand, so the manifest, the service worker and the HTML can keep pointing at + * the same filenames while the mark changes. These tests pin the plan (which + * file comes from which source, at what size) and the Inkscape invocation; + * they never run Inkscape. + */ + +const ROOT = resolve(__dirname, '..'); + +describe('ICONS', () => { + it('covers every raster icon the app references, from a source that exists', () => { + const outs = ICONS.map((i) => i.out); + expect(outs).toEqual( + expect.arrayContaining([ + 'public/favicon-32.png', + 'public/apple-touch-icon.png', + 'public/icon-192.png', + 'public/icon-512.png', + 'public/icon-512-maskable.png', + ]), + ); + for (const icon of ICONS) { + expect(existsSync(resolve(ROOT, icon.source)), icon.source).toBe(true); + } + }); + + it('renders the tab favicon from the bare mark and the app icons from the opaque source', () => { + const favicon = ICONS.find((i) => i.out === 'public/favicon-32.png'); + expect(favicon?.source).toBe('public/favicon.svg'); + expect(favicon?.size).toBe(32); + for (const out of ['public/apple-touch-icon.png', 'public/icon-192.png', 'public/icon-512.png']) { + expect(ICONS.find((i) => i.out === out)?.source).toBe('scripts/icons/app-icon.svg'); + } + expect(ICONS.find((i) => i.out === 'public/icon-512-maskable.png')?.source).toBe( + 'scripts/icons/app-icon-maskable.svg', + ); + }); +}); + +describe('findInkscape', () => { + it('prefers an explicit INKSCAPE variable', () => { + expect(findInkscape({ INKSCAPE: 'D:\\tools\\inkscape.com' }, () => true)).toBe( + 'D:\\tools\\inkscape.com', + ); + }); + + it('falls back to the first standard install location that exists', () => { + const second = INKSCAPE_CANDIDATES[1]!; + expect(findInkscape({}, (p: string) => p === second)).toBe(second); + }); + + it('falls back to PATH lookup when nothing is installed in a standard place', () => { + expect(findInkscape({}, () => false)).toBe('inkscape'); + }); +}); + +describe('exportArgs', () => { + it('builds an Inkscape 1.x PNG export at the requested square size', () => { + expect(exportArgs({ source: 'in.svg', out: 'out.png', size: 180 })).toEqual([ + 'in.svg', + '--export-type=png', + '--export-filename=out.png', + '--export-width=180', + '--export-height=180', + ]); + }); +}); + +describe('makeIcons', () => { + it('runs one export per icon, with paths resolved against the project root', () => { + const run = vi.fn(); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + makeIcons(ROOT, run); + } finally { + log.mockRestore(); + } + expect(run).toHaveBeenCalledTimes(ICONS.length); + const [, args, opts] = run.mock.calls[0]!; + expect(args[0]).toBe(resolve(ROOT, ICONS[0]!.source)); + expect(args).toContain(`--export-filename=${resolve(ROOT, ICONS[0]!.out)}`); + expect(opts).toEqual({ stdio: 'inherit' }); + }); +}); 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' : ''}…
-
+
{activePlayers.map((player) => { const isFinished = finishedPlayers.has(player.userId); const isForfeited = forfeitedPlayers.has(player.userId); return (
{player.user.username} {isForfeited ? ( ) : isFinished ? ( - + ) : ( )} @@ -106,7 +111,7 @@ export default function GameNightCompletionModal() { {lobbyState === 'finished' && ( )}
diff --git a/src/components/game-night/PlayerCard.test.tsx b/src/components/game-night/PlayerCard.test.tsx index 00abe4d..a731bb4 100644 --- a/src/components/game-night/PlayerCard.test.tsx +++ b/src/components/game-night/PlayerCard.test.tsx @@ -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(); const { container: theirs } = render(); - 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'); }); }); diff --git a/src/components/game-night/PlayerCard.tsx b/src/components/game-night/PlayerCard.tsx index 5933036..2099392 100644 --- a/src/components/game-night/PlayerCard.tsx +++ b/src/components/game-night/PlayerCard.tsx @@ -16,15 +16,15 @@ export default function PlayerCard({ }: PlayerCardProps) { return (

{player.user.username}

{player.isHost && ( - + )}
{player.isSpectator ? ( @@ -33,7 +33,7 @@ export default function PlayerCard({ Spectator ) : player.isReady ? ( - + ) : ( )} diff --git a/src/components/game-night/PlayersProgress.tsx b/src/components/game-night/PlayersProgress.tsx index 31559b7..e81de55 100644 --- a/src/components/game-night/PlayersProgress.tsx +++ b/src/components/game-night/PlayersProgress.tsx @@ -15,7 +15,7 @@ export default function PlayersProgress() {

Players

{/* Polite, so a player finishing or forfeiting is announced without interrupting what the user is doing. */} -
+
{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 (
{isForfeited ? (