From eaf8430d5d5862696f2da409d452d68780874225 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 16:50:59 +0000 Subject: [PATCH 01/24] test(web): define Together design preview Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../[lang]/together/design-preview/page.test.tsx | 16 ++++++++++++++++ apps/web/vitest.preview.config.ts | 9 +++++++++ 2 files changed, 25 insertions(+) create mode 100644 apps/web/src/app/[lang]/together/design-preview/page.test.tsx create mode 100644 apps/web/vitest.preview.config.ts diff --git a/apps/web/src/app/[lang]/together/design-preview/page.test.tsx b/apps/web/src/app/[lang]/together/design-preview/page.test.tsx new file mode 100644 index 00000000..793736b5 --- /dev/null +++ b/apps/web/src/app/[lang]/together/design-preview/page.test.tsx @@ -0,0 +1,16 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import DesignPreviewPage from './page' + +describe('Together design preview', () => { + afterEach(() => cleanup()) + + it('shows the three visual directions and recommendation', () => { + render() + + expect(screen.getByText('Cópia literal')).toBeTruthy() + expect(screen.getByText('Sistema cinematográfico')).toBeTruthy() + expect(screen.getByText('Adaptação leve')).toBeTruthy() + expect(screen.getByText('Recomendado')).toBeTruthy() + }) +}) diff --git a/apps/web/vitest.preview.config.ts b/apps/web/vitest.preview.config.ts new file mode 100644 index 00000000..b8cc6b6b --- /dev/null +++ b/apps/web/vitest.preview.config.ts @@ -0,0 +1,9 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + }, +}) From ad973c2e3d73a460ea1edef6f3614a56236f1991 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 16:51:42 +0000 Subject: [PATCH 02/24] feat(web): add Together UI direction preview Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../[lang]/together/design-preview/page.tsx | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 apps/web/src/app/[lang]/together/design-preview/page.tsx diff --git a/apps/web/src/app/[lang]/together/design-preview/page.tsx b/apps/web/src/app/[lang]/together/design-preview/page.tsx new file mode 100644 index 00000000..fbd09bac --- /dev/null +++ b/apps/web/src/app/[lang]/together/design-preview/page.tsx @@ -0,0 +1,198 @@ +import type { ReactNode } from 'react' + +type DirectionCardProps = { + label: string + title: string + description: string + recommended?: boolean + children: ReactNode +} + +function MiniPoster({ tone }: { tone: 'coral' | 'violet' | 'butter' }) { + const background = { + coral: 'from-[#562d29]', + violet: 'from-[#40325e]', + butter: 'from-[#5d522d]', + }[tone] + + return ( +
+ +
+ ) +} + +function PreviewHeader({ light = false }: { light?: boolean }) { + return ( +
+
+ + +
+ + + + +
+ ) +} + +function DirectionCard({ + label, + title, + description, + recommended, + children, +}: DirectionCardProps) { + return ( +
+ {children} +
+

{label}

+

{title}

+

{description}

+ {recommended && ( + + Recomendado + + )} +
+
+ ) +} + +function PosterGrid() { + return ( +
+ + + +
+ ) +} + +export default function DesignPreviewPage() { + return ( +
+
+

+ Plotwist · exploração visual +

+

+ Até onde o Together deve definir o novo Plotwist? +

+

+ As três direções usam a mesma personalidade cinematográfica em + intensidades diferentes. +

+
+ +
+ +
+ +

+ Sua noite de cinema +

+

+ O que você +
+ vai assistir? +

+

+ Toda a experiência sempre escura e imersiva. +

+ +
+ Continue descobrindo + + Explorar + +
+
+
+ + +
+ +

+ Feito para você +

+

+ Histórias que +
+ ficam com você. +

+

+ Imersão onde importa; clareza para organizar. +

+
+ +
+
+ + Sua coleção, com personalidade + + + Ver lista + +
+
+
+ + +
+ +

Descubra agora

+

+ Seu universo +
+ de histórias. +

+

+ A estrutura conhecida com uma nova camada visual. +

+ +
+ Novidades para você + + Abrir + +
+
+
+
+
+ ) +} From 54d5788ac6277174808f106e2d1853d12f69cabe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 16:52:17 +0000 Subject: [PATCH 03/24] style(web): format design preview Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/src/app/[lang]/together/design-preview/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/app/[lang]/together/design-preview/page.tsx b/apps/web/src/app/[lang]/together/design-preview/page.tsx index fbd09bac..bf18304e 100644 --- a/apps/web/src/app/[lang]/together/design-preview/page.tsx +++ b/apps/web/src/app/[lang]/together/design-preview/page.tsx @@ -142,9 +142,7 @@ export default function DesignPreviewPage() { >
-

- Feito para você -

+

Feito para você

Histórias que
From 4d4d888a121744b7e3900c85e5852e63375b3449 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:01:19 +0000 Subject: [PATCH 04/24] docs: design cinematic movie details rollout Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- ...26-09-06-cinematic-movie-details-design.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-06-cinematic-movie-details-design.md diff --git a/docs/superpowers/specs/2026-09-06-cinematic-movie-details-design.md b/docs/superpowers/specs/2026-09-06-cinematic-movie-details-design.md new file mode 100644 index 00000000..d44657ba --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-cinematic-movie-details-design.md @@ -0,0 +1,155 @@ +# Cinematic Movie Details — Design + +## Objective + +Introduce the Together-inspired cinematic design system gradually without replacing the existing Plotwist web interface all at once. The first migrated surface is the movie detail page. + +Every visitor can opt into the experimental interface from **Settings → Appearance → New interface**. The classic interface remains the default and can be restored at any time. + +## Scope + +This first release includes: + +- a global `classic | cinematic` UI preference; +- a public experimental toggle in the existing desktop account menu and mobile navigation drawer; +- cookie-based persistence for authenticated and guest visitors; +- an alternative cinematic renderer for movie details; +- the existing movie data, actions, SEO output, and subordinate content; +- localized labels for every supported language; +- a temporary design-preview route while the experiment is under review. + +It does not migrate home, TV details, profiles, lists, catalog pages, onboarding, or iOS. It does not change the existing light/dark/system theme preference. + +## Architecture + +UI version and color theme are independent preferences: + +```text +theme: light | dark | system +uiVersion: classic | cinematic +``` + +The non-sensitive `uiVersion` preference is persisted in a first-party cookie with `Path=/`, `SameSite=Lax`, and a one-year lifetime. Its accepted values are validated centrally; missing or invalid values resolve to `classic`. + +The movie route loads data once. After data loading, a server-side boundary reads the cookie and selects one of two presentational renderers: + +```text +Movie page + ├── load movie data once + ├── emit shared structured data once + ├── read validated uiVersion cookie + ├── classic → existing MovieDetails presentation + └── cinematic → CinematicMovieDetails presentation +``` + +The renderers receive the same movie object and language. They do not fetch duplicate movie data or own SEO metadata. Existing interactive actions remain shared components wherever their current styling can be safely adapted. + +## Preference Control + +The existing `HeaderNavigationDrawerConfigs` component is shared by the desktop account dropdown and mobile drawer. It gains an Appearance row containing: + +- a localized “New interface” label; +- a localized “Experimental” badge; +- an accessible switch; +- classic as the initial default. + +Changing the switch updates the cookie and refreshes the current route so server components render the selected version immediately. The control is available to guests and authenticated users. No account database field is introduced in this phase. + +An optional `?ui=classic|cinematic` override may be retained for shared QA links, but users never need to edit the URL. When present and valid, it updates the cookie and redirects to the clean URL. + +## Cinematic Movie Detail UI + +The cinematic renderer uses Together's visual DNA without copying its narrow voting layout: + +- Instrument Sans typography; +- near-black immersive surfaces; +- warm off-white foregrounds; +- coral as the primary accent; +- rounded, tactile controls; +- restrained gradients and motion; +- clear typography with compact metadata. + +### Desktop + +1. A full-width backdrop hero uses a dark readability gradient. +2. The poster overlaps the lower hero boundary. +3. Title, release date, genres, TMDB rating, and overview form one clear information block. +4. Add to list, review, status, and share remain grouped as the primary action cluster. +5. Collection and tabs continue below in a constrained reading container. + +### Mobile + +1. The backdrop remains compact enough to keep the title visible near the fold. +2. The poster partially overlaps the backdrop. +3. Title and metadata wrap without horizontal scrolling. +4. All actions are touch targets and require no hover interaction. +5. Overview, genres, rating, collection, and tabs retain their existing order and functionality. + +The classic renderer must remain visually and behaviorally unchanged. + +## Components and Boundaries + +- `ui-version`: validates and exposes the cookie value on the server. +- `UiVersionControl`: owns the client-side switch interaction and refresh. +- `MovieDetails`: remains the data-owning server component. +- `ClassicMovieDetails`: contains the current presentation. +- `CinematicMovieDetails`: contains the new presentation. +- Existing movie actions, structured data, collection, and tabs remain shared. + +The new renderer may add cinematic-specific presentation components, but must not fork data fetching or business behavior. + +## Data and Interaction Flow + +```text +Visitor toggles New interface + → browser persists uiVersion cookie + → router refreshes + → server validates cookie + → movie data is loaded through the existing TMDB service + → selected renderer receives the same data +``` + +Switching back follows the same flow and does not affect account data, lists, reviews, ratings, or the color-theme preference. + +## Error Handling + +- Invalid cookie values fall back to `classic`. +- Cookie write failure leaves the current interface active and announces a localized error. +- Movie-fetch and not-found behavior continues through the existing route handling. +- Missing backdrop or poster images use the existing fallback behavior. +- A renderer must not swallow errors from shared interactive actions. + +## Accessibility + +- The preference uses a labelled switch with its state announced to assistive technology. +- Coral foreground/background pairs must meet WCAG AA contrast. +- Focus indicators remain visible against cinematic surfaces. +- Action meaning cannot depend only on color or icons. +- Motion respects `prefers-reduced-motion`. +- Heading order and structured data stay equivalent between renderers. + +## Testing + +Automated coverage includes: + +- UI-version validation for valid, missing, and invalid cookie values; +- preference control state, cookie update, and route refresh; +- classic renderer selection by default; +- cinematic renderer selection from the cookie; +- movie data fetched only once; +- cinematic renderer's title, overview, rating, genres, and actions; +- missing-image fallbacks; +- localized toggle labels; +- focused accessibility assertions where supported. + +Verification includes web unit tests, TypeScript type checking, Biome checks, and a production build. The preview is manually checked at mobile and desktop widths in both UI versions. + +## Rollout + +1. Ship the public experimental preference with classic as default. +2. Enable cinematic movie details behind the preference. +3. Collect qualitative feedback and compare behavior without removing classic. +4. Fix issues in the shared cinematic foundations. +5. Migrate later surfaces through separate designs and implementation plans. + +Removing the classic renderer or making cinematic the default requires a separate decision after validation. From fefa2fab624fb5e52e55398cf296cf98f59c2ef2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:03:32 +0000 Subject: [PATCH 05/24] docs: plan cinematic movie details implementation Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../2026-09-06-cinematic-movie-details.md | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-cinematic-movie-details.md diff --git a/docs/superpowers/plans/2026-09-06-cinematic-movie-details.md b/docs/superpowers/plans/2026-09-06-cinematic-movie-details.md new file mode 100644 index 00000000..7ea896e8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-cinematic-movie-details.md @@ -0,0 +1,437 @@ +# Cinematic Movie Details Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a public, cookie-persisted experimental UI switch and use it to select a Together-inspired cinematic renderer for movie detail pages. + +**Architecture:** Keep the existing movie fetch and structured data in one server component, then select a classic or cinematic presentation from a validated cookie. Pass the server-read preference into the existing header configuration tree so the client control hydrates with the correct state, writes the non-sensitive cookie, and refreshes the route. + +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Tailwind CSS, Radix Switch, Vitest, Testing Library, Biome. + +## Global Constraints + +- The UI preference is exactly `classic | cinematic`. +- Classic remains the default for missing or invalid values. +- The UI preference remains independent from `light | dark | system`. +- The control is available to guests and authenticated users. +- The first and only migrated product surface in this plan is movie details. +- Movie data, metadata, structured data, actions, collection, and tabs are not duplicated. +- All seven supported web languages receive localized control copy. +- No new dependency is added. + +--- + +### Task 1: UI-version preference model + +**Files:** +- Create: `apps/web/src/lib/ui-version.ts` +- Create: `apps/web/src/lib/ui-version.test.ts` +- Modify: `apps/web/vitest.preview.config.ts` + +**Interfaces:** +- Produces: `type UiVersion = 'classic' | 'cinematic'` +- Produces: `UI_VERSION_COOKIE_NAME` +- Produces: `parseUiVersion(value: string | null | undefined): UiVersion` +- Produces: `readUiVersionCookie(cookieHeader: string): UiVersion` +- Produces: `serializeUiVersionCookie(value: UiVersion): string` + +- [ ] **Step 1: Expand the focused Vitest config** + +Add the app alias so preference and component tests resolve production imports: + +```ts +import { resolve } from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react()], + test: { environment: 'jsdom' }, + resolve: { + alias: { + '@': resolve(__dirname, './src'), + '@/': resolve(__dirname, './src'), + }, + }, +}) +``` + +- [ ] **Step 2: Write failing preference tests** + +```ts +import { describe, expect, it } from 'vitest' +import { + parseUiVersion, + readUiVersionCookie, + serializeUiVersionCookie, +} from './ui-version' + +describe('ui version preference', () => { + it.each([ + [undefined, 'classic'], + [null, 'classic'], + ['', 'classic'], + ['unexpected', 'classic'], + ['classic', 'classic'], + ['cinematic', 'cinematic'], + ] as const)('parses %s as %s', (value, expected) => { + expect(parseUiVersion(value)).toBe(expected) + }) + + it('reads the preference from a cookie header', () => { + expect(readUiVersionCookie('theme=dark; plotwist-ui=cinematic')).toBe( + 'cinematic' + ) + }) + + it('serializes a persistent first-party cookie', () => { + expect(serializeUiVersionCookie('cinematic')).toBe( + 'plotwist-ui=cinematic; Path=/; Max-Age=31536000; SameSite=Lax' + ) + }) +}) +``` + +- [ ] **Step 3: Run the test and verify RED** + +Run: + +```bash +pnpm --filter web exec vitest run src/lib/ui-version.test.ts --config vitest.preview.config.ts +``` + +Expected: FAIL because `ui-version.ts` does not exist. + +- [ ] **Step 4: Implement the preference model** + +```ts +export const UI_VERSION_COOKIE_NAME = 'plotwist-ui' +export type UiVersion = 'classic' | 'cinematic' + +export function parseUiVersion(value: string | null | undefined): UiVersion { + return value === 'cinematic' ? 'cinematic' : 'classic' +} + +export function readUiVersionCookie(cookieHeader: string): UiVersion { + const value = cookieHeader + .split(';') + .map(part => part.trim()) + .find(part => part.startsWith(`${UI_VERSION_COOKIE_NAME}=`)) + ?.split('=')[1] + + return parseUiVersion(value) +} + +export function serializeUiVersionCookie(value: UiVersion): string { + return `${UI_VERSION_COOKIE_NAME}=${value}; Path=/; Max-Age=31536000; SameSite=Lax` +} +``` + +- [ ] **Step 5: Run the test and verify GREEN** + +Run the command from Step 3. Expected: all preference tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/vitest.preview.config.ts apps/web/src/lib/ui-version.ts apps/web/src/lib/ui-version.test.ts +git commit -m "feat(web): add UI version preference model" +``` + +### Task 2: Public experimental appearance control + +**Files:** +- Create: `apps/web/src/components/header/ui-version-control.tsx` +- Create: `apps/web/src/components/header/ui-version-control.test.tsx` +- Modify: `apps/web/src/components/header/header-navigation-drawer-configs.tsx` +- Modify: `apps/web/src/components/header/header.tsx` +- Modify: `apps/web/src/components/header/header-account.tsx` +- Modify: `apps/web/src/components/header/header-navigation-drawer.tsx` +- Modify: `apps/web/src/app/[lang]/layout.tsx` +- Modify: `apps/web/public/dictionaries/en-US.json` +- Modify: `apps/web/public/dictionaries/pt-BR.json` +- Modify: `apps/web/public/dictionaries/es-ES.json` +- Modify: `apps/web/public/dictionaries/fr-FR.json` +- Modify: `apps/web/public/dictionaries/de-DE.json` +- Modify: `apps/web/public/dictionaries/it-IT.json` +- Modify: `apps/web/public/dictionaries/ja-JP.json` + +**Interfaces:** +- Consumes: `UiVersion`, `parseUiVersion`, and `serializeUiVersionCookie` +- Produces: `UiVersionControl({ initialVersion, label, experimentalLabel, errorLabel })` +- Changes: `Header`, `HeaderAccount`, `HeaderNavigationDrawer`, and `HeaderNavigationDrawerConfigs` receive `uiVersion: UiVersion` + +- [ ] **Step 1: Add dictionary keys in every locale** + +Add adjacent top-level keys with natural translations: + +```json +"appearance": "Appearance", +"new_interface": "New interface", +"experimental": "Experimental", +"ui_preference_error": "Could not update the interface preference." +``` + +Use `Aparência`, `Nova interface`, `Experimental`, and `Não foi possível atualizar a preferência da interface.` for `pt-BR`; equivalent native translations are required for the other five locales. + +- [ ] **Step 2: Write the failing control test** + +Mock `next/navigation` with a `refresh` spy, render the control as classic, click the switch, and assert: + +```ts +expect(document.cookie).toContain('plotwist-ui=cinematic') +expect(refresh).toHaveBeenCalledOnce() +``` + +Also rerender with `initialVersion="cinematic"` and assert the switch has `data-state="checked"`. + +- [ ] **Step 3: Run the test and verify RED** + +Run: + +```bash +pnpm --filter web exec vitest run src/components/header/ui-version-control.test.tsx --config vitest.preview.config.ts +``` + +Expected: FAIL because `UiVersionControl` does not exist. + +- [ ] **Step 4: Implement the control** + +Implement a labelled row using `@plotwist/ui/components/ui/switch` and `Badge`. On change: + +```ts +const nextVersion = checked ? 'cinematic' : 'classic' +document.cookie = serializeUiVersionCookie(nextVersion) +setVersion(nextVersion) +router.refresh() +``` + +Wrap the cookie assignment in `try/catch`; restore the previous state and show `errorLabel` with `role="alert"` on failure. The switch receives an explicit accessible label. + +- [ ] **Step 5: Pass the server preference into both menus** + +In `apps/web/src/app/[lang]/layout.tsx`, read: + +```ts +const cookieStore = await cookies() +const uiVersion = parseUiVersion(cookieStore.get(UI_VERSION_COOKIE_NAME)?.value) +``` + +Pass `uiVersion` through: + +```text +RootLayout + → Header + → HeaderAccount → HeaderNavigationDrawerConfigs + → HeaderNavigationDrawer → HeaderNavigationDrawerConfigs +``` + +Render `UiVersionControl` below the current theme row under an Appearance label. Because the same configs component is used in desktop and mobile, one integration covers both surfaces. + +- [ ] **Step 6: Run tests and typecheck** + +Run: + +```bash +pnpm --filter web exec vitest run src/lib/ui-version.test.ts src/components/header/ui-version-control.test.tsx --config vitest.preview.config.ts +pnpm --filter web run typecheck +``` + +Expected: all focused tests pass and TypeScript exits 0. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/components/header apps/web/src/app/[lang]/layout.tsx apps/web/public/dictionaries +git commit -m "feat(web): add experimental UI appearance control" +``` + +### Task 3: Shared movie presentation primitives + +**Files:** +- Create: `apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.tsx` +- Create: `apps/web/src/app/[lang]/movies/[id]/_components/movie-rating.tsx` +- Modify: `apps/web/src/app/[lang]/movies/[id]/_components/movie-infos.tsx` + +**Interfaces:** +- Produces: `MovieActions({ movie, language, className? })` +- Produces: `MovieRating({ movie, className? })` +- Consumed later by: `CinematicMovieDetails` + +- [ ] **Step 1: Write a focused rendering test** + +Create `movie-actions.test.tsx` with lightweight mocks for the four existing interactive children. Assert that `MovieActions` passes movie ID `MOVIE` and the localized share path, and that `MovieRating` renders the formatted score. + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +pnpm --filter web exec vitest run 'src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx' --config vitest.preview.config.ts +``` + +Expected: FAIL because the shared action and rating components do not exist. + +- [ ] **Step 3: Extract existing behavior without changing the classic UI** + +Move the current action cluster from `MovieInfos` into `MovieActions`: + +```tsx + + + + +``` + +Move the existing TMDB badge and tooltip into `MovieRating`. Keep the same variants, labels, values, and ordering. Replace the old inline blocks in `MovieInfos` with these components. + +- [ ] **Step 4: Verify classic behavior** + +Run the focused test, existing web tests, and typecheck. Expected: all pass; the classic markup retains the same controls and score. + +- [ ] **Step 5: Commit** + +```bash +git add 'apps/web/src/app/[lang]/movies/[id]/_components' +git commit -m "refactor(web): share movie detail actions" +``` + +### Task 4: Dual movie-detail renderers + +**Files:** +- Create: `apps/web/src/app/[lang]/movies/[id]/_components/classic-movie-details.tsx` +- Create: `apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx` +- Create: `apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx` +- Modify: `apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx` + +**Interfaces:** +- Consumes: `UiVersion`, `MovieActions`, and `MovieRating` +- Produces: `ClassicMovieDetails({ movie, language, backdropUrl, posterUrl })` +- Produces: `CinematicMovieDetails({ movie, language, backdropUrl, posterUrl })` +- Keeps: `MovieDetails({ id, language })` as the single data-owning server component + +- [ ] **Step 1: Write failing renderer-selection tests** + +Mock the TMDB details call, Next cookies, structured-data children, collection, and tabs. Assert: + +```ts +expect(tmdb.movies.details).toHaveBeenCalledTimes(1) +expect(screen.getByTestId('classic-movie-details')).toBeTruthy() +``` + +for no cookie, and: + +```ts +expect(screen.getByTestId('cinematic-movie-details')).toBeTruthy() +expect(tmdb.movies.details).toHaveBeenCalledTimes(1) +``` + +for `plotwist-ui=cinematic`. + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +pnpm --filter web exec vitest run 'src/app/[lang]/movies/[id]/_components/movie-details.test.tsx' --config vitest.preview.config.ts +``` + +Expected: FAIL because renderer selection and both renderer components do not exist. + +- [ ] **Step 3: Extract the classic renderer** + +Move the existing `Banner`, constrained section, `MovieInfos`, optional `MovieCollection`, and `MovieTabs` markup into `ClassicMovieDetails`. Add only `data-testid="classic-movie-details"`; do not change its classes or ordering. + +- [ ] **Step 4: Keep data and SEO at the boundary** + +In `MovieDetails`, keep exactly one `tmdb.movies.details(id, language)` call and one set of `BreadcrumbJsonLd`/`MovieJsonLd` nodes. Read the cookie with Next `cookies()`, validate it with `parseUiVersion`, and render the selected presentational component below the structured data. + +- [ ] **Step 5: Build the cinematic renderer** + +Use `Instrument_Sans` locally through `next/font/google`. Render: + +- a full-bleed backdrop with dark gradient and existing poster fallback; +- overlapping poster using the existing `Poster`; +- release date, title, `MovieGenres`, `MovieRating`, and overview; +- `MovieActions` with wrapped touch-friendly spacing; +- the existing optional `MovieCollection`; +- the existing `MovieTabs`; +- `data-testid="cinematic-movie-details"`. + +Scope Together-inspired colors to the cinematic root with CSS custom properties or Tailwind arbitrary colors. Do not change global theme tokens or the classic renderer. + +- [ ] **Step 6: Verify both renderers** + +Run: + +```bash +pnpm --filter web exec vitest run 'src/app/[lang]/movies/[id]/_components/movie-details.test.tsx' --config vitest.preview.config.ts +pnpm --filter web run typecheck +pnpm exec biome check 'apps/web/src/app/[lang]/movies/[id]/_components' +``` + +Expected: renderer tests pass, one fetch per render, typecheck exits 0, and Biome reports no errors. + +- [ ] **Step 7: Commit** + +```bash +git add 'apps/web/src/app/[lang]/movies/[id]/_components' +git commit -m "feat(web): add cinematic movie detail renderer" +``` + +### Task 5: Final verification and PR handoff + +**Files:** +- Modify only files required by failures found in this task. + +**Interfaces:** +- Consumes all deliverables from Tasks 1–4. +- Produces a deployable preview with classic default and opt-in cinematic movie details. + +- [ ] **Step 1: Run focused tests** + +```bash +pnpm --filter web exec vitest run \ + src/lib/ui-version.test.ts \ + src/components/header/ui-version-control.test.tsx \ + 'src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx' \ + 'src/app/[lang]/movies/[id]/_components/movie-details.test.tsx' \ + 'src/app/[lang]/together/design-preview/page.test.tsx' \ + --config vitest.preview.config.ts +``` + +Expected: all files and tests pass. + +- [ ] **Step 2: Run static verification** + +```bash +pnpm --filter web run typecheck +pnpm exec biome check apps/web/src apps/web/public/dictionaries +pnpm --filter web run build +``` + +Expected: each command exits 0. Restore the tracked `apps/web/tsconfig.tsbuildinfo` if typecheck changes only generated metadata. + +- [ ] **Step 3: Manually verify the preview** + +At desktop and mobile widths: + +1. Open a movie detail with classic as default. +2. Open the account/mobile menu and enable **New interface**. +3. Confirm the same route refreshes into the cinematic renderer. +4. Confirm list, review, status, share, collection, and all tabs remain usable. +5. Reload and confirm the preference persists. +6. Disable the preference and confirm the classic renderer returns. +7. Switch light/dark/system and confirm it does not change `uiVersion`. + +- [ ] **Step 4: Commit any verification fixes** + +If verification required code changes, stage only those files and create a descriptive commit. Do not amend earlier commits. + +- [ ] **Step 5: Push and update the existing PR** + +```bash +git push -u origin cursor/together-ui-preview-08b6 +``` + +Update PR #517 to describe the implemented preference control, dual renderer, tests, and preview instructions. From da28e255e277267a7a399a642153ab91d1ab563a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:05:23 +0000 Subject: [PATCH 06/24] feat(web): add UI version preference model Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/src/lib/ui-version.test.ts | 31 +++++++++++++++++++++++++++++ apps/web/src/lib/ui-version.ts | 20 +++++++++++++++++++ apps/web/vitest.config.ts | 2 +- apps/web/vitest.preview.config.ts | 9 +++++++-- 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/ui-version.test.ts create mode 100644 apps/web/src/lib/ui-version.ts diff --git a/apps/web/src/lib/ui-version.test.ts b/apps/web/src/lib/ui-version.test.ts new file mode 100644 index 00000000..fbe8c649 --- /dev/null +++ b/apps/web/src/lib/ui-version.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + parseUiVersion, + readUiVersionCookie, + serializeUiVersionCookie, +} from './ui-version' + +describe('ui version preference', () => { + it.each([ + [undefined, 'classic'], + [null, 'classic'], + ['', 'classic'], + ['unexpected', 'classic'], + ['classic', 'classic'], + ['cinematic', 'cinematic'], + ] as const)('parses %s as %s', (value, expected) => { + expect(parseUiVersion(value)).toBe(expected) + }) + + it('reads the preference from a cookie header', () => { + expect(readUiVersionCookie('theme=dark; plotwist-ui=cinematic')).toBe( + 'cinematic' + ) + }) + + it('serializes a persistent first-party cookie', () => { + expect(serializeUiVersionCookie('cinematic')).toBe( + 'plotwist-ui=cinematic; Path=/; Max-Age=31536000; SameSite=Lax' + ) + }) +}) diff --git a/apps/web/src/lib/ui-version.ts b/apps/web/src/lib/ui-version.ts new file mode 100644 index 00000000..6c5f2cd5 --- /dev/null +++ b/apps/web/src/lib/ui-version.ts @@ -0,0 +1,20 @@ +export const UI_VERSION_COOKIE_NAME = 'plotwist-ui' +export type UiVersion = 'classic' | 'cinematic' + +export function parseUiVersion(value: string | null | undefined): UiVersion { + return value === 'cinematic' ? 'cinematic' : 'classic' +} + +export function readUiVersionCookie(cookieHeader: string): UiVersion { + const value = cookieHeader + .split(';') + .map(part => part.trim()) + .find(part => part.startsWith(`${UI_VERSION_COOKIE_NAME}=`)) + ?.split('=')[1] + + return parseUiVersion(value) +} + +export function serializeUiVersionCookie(value: UiVersion): string { + return `${UI_VERSION_COOKIE_NAME}=${value}; Path=/; Max-Age=31536000; SameSite=Lax` +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index a2b19724..80dc477d 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ include: ['src/**/'], reporter: ['html'], }, - setupFiles: ['./test/setup.ts', './test/mocks.ts'], + setupFiles: ['./test/mocks.ts'], }, resolve: { alias: { diff --git a/apps/web/vitest.preview.config.ts b/apps/web/vitest.preview.config.ts index b8cc6b6b..02dbfc8b 100644 --- a/apps/web/vitest.preview.config.ts +++ b/apps/web/vitest.preview.config.ts @@ -1,9 +1,14 @@ +import { resolve } from 'node:path' import react from '@vitejs/plugin-react' import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [react()], - test: { - environment: 'jsdom', + test: { environment: 'jsdom' }, + resolve: { + alias: { + '@': resolve(__dirname, './src'), + '@/': resolve(__dirname, './src'), + }, }, }) From 077f89a2353ed05e3617d4570d29fb66712283d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:10:53 +0000 Subject: [PATCH 07/24] docs: include Vitest setup restoration Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../plans/2026-09-06-cinematic-movie-details.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-06-cinematic-movie-details.md b/docs/superpowers/plans/2026-09-06-cinematic-movie-details.md index 7ea896e8..da34931f 100644 --- a/docs/superpowers/plans/2026-09-06-cinematic-movie-details.md +++ b/docs/superpowers/plans/2026-09-06-cinematic-movie-details.md @@ -26,6 +26,7 @@ **Files:** - Create: `apps/web/src/lib/ui-version.ts` - Create: `apps/web/src/lib/ui-version.test.ts` +- Create: `apps/web/test/setup.ts` - Modify: `apps/web/vitest.preview.config.ts` **Interfaces:** @@ -35,7 +36,13 @@ - Produces: `readUiVersionCookie(cookieHeader: string): UiVersion` - Produces: `serializeUiVersionCookie(value: UiVersion): string` -- [ ] **Step 1: Expand the focused Vitest config** +- [ ] **Step 1: Restore the referenced Vitest setup and expand the focused config** + +Create the setup module already referenced by the main Vitest configuration: + +```ts +export {} +``` Add the app alias so preference and component tests resolve production imports: From e5cf50768563e337349ad8f87cb0d34d65e4104f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:11:30 +0000 Subject: [PATCH 08/24] feat(web): add UI version preference model Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/test/setup.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/web/test/setup.ts diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts new file mode 100644 index 00000000..336ce12b --- /dev/null +++ b/apps/web/test/setup.ts @@ -0,0 +1 @@ +export {} From 4069cc4f1cc30bf2018195bae394663a98fd85e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:12:04 +0000 Subject: [PATCH 09/24] feat(web): add UI version preference model Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 80dc477d..a2b19724 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ include: ['src/**/'], reporter: ['html'], }, - setupFiles: ['./test/mocks.ts'], + setupFiles: ['./test/setup.ts', './test/mocks.ts'], }, resolve: { alias: { From 25a01411dafd5e050dab583accf49383575e21f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:13:54 +0000 Subject: [PATCH 10/24] fix(web): preserve complete UI cookie value Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/src/lib/ui-version.test.ts | 4 ++++ apps/web/src/lib/ui-version.ts | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/ui-version.test.ts b/apps/web/src/lib/ui-version.test.ts index fbe8c649..9d6d2d43 100644 --- a/apps/web/src/lib/ui-version.test.ts +++ b/apps/web/src/lib/ui-version.test.ts @@ -23,6 +23,10 @@ describe('ui version preference', () => { ) }) + it('rejects a preference with trailing cookie value data', () => { + expect(readUiVersionCookie('plotwist-ui=cinematic=invalid')).toBe('classic') + }) + it('serializes a persistent first-party cookie', () => { expect(serializeUiVersionCookie('cinematic')).toBe( 'plotwist-ui=cinematic; Path=/; Max-Age=31536000; SameSite=Lax' diff --git a/apps/web/src/lib/ui-version.ts b/apps/web/src/lib/ui-version.ts index 6c5f2cd5..e5688e50 100644 --- a/apps/web/src/lib/ui-version.ts +++ b/apps/web/src/lib/ui-version.ts @@ -6,11 +6,12 @@ export function parseUiVersion(value: string | null | undefined): UiVersion { } export function readUiVersionCookie(cookieHeader: string): UiVersion { + const prefix = `${UI_VERSION_COOKIE_NAME}=` const value = cookieHeader .split(';') .map(part => part.trim()) - .find(part => part.startsWith(`${UI_VERSION_COOKIE_NAME}=`)) - ?.split('=')[1] + .find(part => part.startsWith(prefix)) + ?.slice(prefix.length) return parseUiVersion(value) } From 830019e2e1494df8a80ca12c6cf21649b8c2829f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:16:39 +0000 Subject: [PATCH 11/24] feat(web): add experimental UI appearance control Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/public/dictionaries/de-DE.json | 4 ++ apps/web/public/dictionaries/en-US.json | 4 ++ apps/web/public/dictionaries/es-ES.json | 4 ++ apps/web/public/dictionaries/fr-FR.json | 4 ++ apps/web/public/dictionaries/it-IT.json | 4 ++ apps/web/public/dictionaries/ja-JP.json | 4 ++ apps/web/public/dictionaries/pt-BR.json | 4 ++ apps/web/src/app/[lang]/layout.tsx | 11 ++- .../src/components/header/header-account.tsx | 9 ++- .../header-navigation-drawer-configs.tsx | 21 +++++- .../header/header-navigation-drawer.tsx | 11 ++- apps/web/src/components/header/header.tsx | 12 +++- .../header/ui-version-control.test.tsx | 43 ++++++++++++ .../components/header/ui-version-control.tsx | 68 +++++++++++++++++++ 14 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/header/ui-version-control.test.tsx create mode 100644 apps/web/src/components/header/ui-version-control.tsx diff --git a/apps/web/public/dictionaries/de-DE.json b/apps/web/public/dictionaries/de-DE.json index 1e5004d1..d3796841 100644 --- a/apps/web/public/dictionaries/de-DE.json +++ b/apps/web/public/dictionaries/de-DE.json @@ -633,7 +633,11 @@ "or": "oder", "sign_up": "Registrieren", "login": "Anmelden", + "appearance": "Erscheinungsbild", "theme": "Thema", + "new_interface": "Neue Benutzeroberfläche", + "experimental": "Experimentell", + "ui_preference_error": "Die Einstellung für die Benutzeroberfläche konnte nicht aktualisiert werden.", "language": "Sprache", "continue": "Weiter", "do_not_have_an_account": "Haben Sie kein Konto?", diff --git a/apps/web/public/dictionaries/en-US.json b/apps/web/public/dictionaries/en-US.json index b1a0c69d..398f4c2b 100644 --- a/apps/web/public/dictionaries/en-US.json +++ b/apps/web/public/dictionaries/en-US.json @@ -634,7 +634,11 @@ "or": "or", "sign_up": "Sign Up", "login": "Log In", + "appearance": "Appearance", "theme": "Theme", + "new_interface": "New interface", + "experimental": "Experimental", + "ui_preference_error": "Could not update the interface preference.", "language": "Language", "continue": "Continue", "do_not_have_an_account": "Don't have an account?", diff --git a/apps/web/public/dictionaries/es-ES.json b/apps/web/public/dictionaries/es-ES.json index 58c47623..70dae2c9 100644 --- a/apps/web/public/dictionaries/es-ES.json +++ b/apps/web/public/dictionaries/es-ES.json @@ -636,7 +636,11 @@ "or": "o", "sign_up": "Registrarse", "login": "Iniciar sesión", + "appearance": "Apariencia", "theme": "Tema", + "new_interface": "Nueva interfaz", + "experimental": "Experimental", + "ui_preference_error": "No se pudo actualizar la preferencia de interfaz.", "language": "Idioma", "continue": "Continuar", "do_not_have_an_account": "¿No tienes una cuenta?", diff --git a/apps/web/public/dictionaries/fr-FR.json b/apps/web/public/dictionaries/fr-FR.json index da8fa149..46b5eb73 100644 --- a/apps/web/public/dictionaries/fr-FR.json +++ b/apps/web/public/dictionaries/fr-FR.json @@ -639,7 +639,11 @@ "or": "ou", "sign_up": "S'inscrire", "login": "Se connecter", + "appearance": "Apparence", "theme": "Thème", + "new_interface": "Nouvelle interface", + "experimental": "Expérimental", + "ui_preference_error": "Impossible de mettre à jour la préférence d’interface.", "language": "Langue", "continue": "Continuer", "do_not_have_an_account": "¿No tienes una cuenta?", diff --git a/apps/web/public/dictionaries/it-IT.json b/apps/web/public/dictionaries/it-IT.json index 88110b40..1c1f8b98 100644 --- a/apps/web/public/dictionaries/it-IT.json +++ b/apps/web/public/dictionaries/it-IT.json @@ -637,7 +637,11 @@ "or": "o", "sign_up": "Registrati", "login": "Accedi", + "appearance": "Aspetto", "theme": "Tema", + "new_interface": "Nuova interfaccia", + "experimental": "Sperimentale", + "ui_preference_error": "Impossibile aggiornare la preferenza dell'interfaccia.", "language": "Lingua", "continue": "Continua", "do_not_have_an_account": "Non hai un account?", diff --git a/apps/web/public/dictionaries/ja-JP.json b/apps/web/public/dictionaries/ja-JP.json index 7546fd92..16a53a70 100644 --- a/apps/web/public/dictionaries/ja-JP.json +++ b/apps/web/public/dictionaries/ja-JP.json @@ -637,7 +637,11 @@ "or": "または", "sign_up": "サインアップ", "login": "ログイン", + "appearance": "外観", "theme": "テーマ", + "new_interface": "新しいインターフェース", + "experimental": "実験的", + "ui_preference_error": "インターフェースの設定を更新できませんでした。", "language": "言語", "continue": "続ける", "do_not_have_an_account": "アカウントをお持ちではありませんか?", diff --git a/apps/web/public/dictionaries/pt-BR.json b/apps/web/public/dictionaries/pt-BR.json index a983cbf1..9c3cd684 100644 --- a/apps/web/public/dictionaries/pt-BR.json +++ b/apps/web/public/dictionaries/pt-BR.json @@ -635,7 +635,11 @@ "or": "ou", "sign_up": "Cadastrar-se", "login": "Entrar", + "appearance": "Aparência", "theme": "Tema", + "new_interface": "Nova interface", + "experimental": "Experimental", + "ui_preference_error": "Não foi possível atualizar a preferência da interface.", "language": "Idioma", "continue": "Continuar", "do_not_have_an_account": "Não tem uma conta?", diff --git a/apps/web/src/app/[lang]/layout.tsx b/apps/web/src/app/[lang]/layout.tsx index 5606bc8a..54b80431 100644 --- a/apps/web/src/app/[lang]/layout.tsx +++ b/apps/web/src/app/[lang]/layout.tsx @@ -1,3 +1,4 @@ +import { cookies } from 'next/headers' import { Link } from 'next-view-transitions' import type { GetUserPreferences200 } from '@/api/endpoints.schemas' import { getUserPreferences } from '@/api/users' @@ -11,6 +12,10 @@ import { LanguageContextProvider } from '@/context/language' import { ListsContextProvider } from '@/context/lists' import { SessionContextProvider } from '@/context/session' import { UserPreferencesContextProvider } from '@/context/user-preferences' +import { + parseUiVersion, + UI_VERSION_COOKIE_NAME, +} from '@/lib/ui-version' import type { Language } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' import { SUPPORTED_LANGUAGES } from '../../../languages' @@ -34,6 +39,10 @@ export default async function RootLayout({ const dictionary = await getDictionary(lang) const session = await verifySession() + const cookieStore = await cookies() + const uiVersion = parseUiVersion( + cookieStore.get(UI_VERSION_COOKIE_NAME)?.value + ) let userPreferences: GetUserPreferences200['userPreferences'] = null @@ -56,7 +65,7 @@ export default async function RootLayout({ } + header={

} footer={
} proBadge={ session?.user.subscriptionType !== 'PRO' ? ( diff --git a/apps/web/src/components/header/header-account.tsx b/apps/web/src/components/header/header-account.tsx index 7d21abe0..a8edac42 100644 --- a/apps/web/src/components/header/header-account.tsx +++ b/apps/web/src/components/header/header-account.tsx @@ -17,6 +17,7 @@ import { Link } from 'next-view-transitions' import { logout } from '@/actions/auth/logout' import { useLanguage } from '@/context/language' import { useSession } from '@/context/session' +import type { UiVersion } from '@/lib/ui-version' import type { User } from '@/types/user' import { HeaderNavigationDrawerConfigs } from './header-navigation-drawer-configs' @@ -46,7 +47,11 @@ const AvatarContent = ({ user }: AvatarContentProps) => { return {user.username?.at(0)} } -export const HeaderAccount = () => { +type HeaderAccountProps = { + uiVersion: UiVersion +} + +export const HeaderAccount = ({ uiVersion }: HeaderAccountProps) => { const { user } = useSession() const { language, dictionary } = useLanguage() @@ -75,7 +80,7 @@ export const HeaderAccount = () => { )} - + {user ? ( diff --git a/apps/web/src/components/header/header-navigation-drawer-configs.tsx b/apps/web/src/components/header/header-navigation-drawer-configs.tsx index 9ef9deae..bdb124ec 100644 --- a/apps/web/src/components/header/header-navigation-drawer-configs.tsx +++ b/apps/web/src/components/header/header-navigation-drawer-configs.tsx @@ -13,9 +13,17 @@ import { usePathname, useRouter } from 'next/navigation' import { useTheme } from 'next-themes' import ReactCountryFlag from 'react-country-flag' import { useLanguage } from '@/context/language' +import type { UiVersion } from '@/lib/ui-version' import type { Language } from '@/services/tmdb' +import { UiVersionControl } from './ui-version-control' -export const HeaderNavigationDrawerConfigs = () => { +type HeaderNavigationDrawerConfigsProps = { + uiVersion: UiVersion +} + +export const HeaderNavigationDrawerConfigs = ({ + uiVersion, +}: HeaderNavigationDrawerConfigsProps) => { const { setTheme, theme, resolvedTheme } = useTheme() const { replace } = useRouter() const { language, dictionary } = useLanguage() @@ -40,6 +48,10 @@ export const HeaderNavigationDrawerConfigs = () => { return (
+

+ {dictionary.appearance} +

+
{dictionary.theme} @@ -80,6 +92,13 @@ export const HeaderNavigationDrawerConfigs = () => {
+ +
{dictionary.language} diff --git a/apps/web/src/components/header/header-navigation-drawer.tsx b/apps/web/src/components/header/header-navigation-drawer.tsx index 207de8cb..539d1ab1 100644 --- a/apps/web/src/components/header/header-navigation-drawer.tsx +++ b/apps/web/src/components/header/header-navigation-drawer.tsx @@ -14,12 +14,19 @@ import { Link } from 'next-view-transitions' import { useEffect, useState } from 'react' import { useLanguage } from '@/context/language' import { useSession } from '@/context/session' +import type { UiVersion } from '@/lib/ui-version' import { buildLanguageNavigation } from './header-navigation-data' import { HeaderNavigationDrawerConfigs } from './header-navigation-drawer-configs' import { HeaderNavigationDrawerItem } from './header-navigation-drawer-item' import { HeaderNavigationDrawerUser } from './header-navigation-drawer-user' -export const HeaderNavigationDrawer = () => { +type HeaderNavigationDrawerProps = { + uiVersion: UiVersion +} + +export const HeaderNavigationDrawer = ({ + uiVersion, +}: HeaderNavigationDrawerProps) => { const { user } = useSession() const [open, setOpen] = useState(false) const pathname = usePathname() @@ -58,7 +65,7 @@ export const HeaderNavigationDrawer = () => {
)} - +
diff --git a/apps/web/src/components/header/header.tsx b/apps/web/src/components/header/header.tsx index f72cff53..c773fac9 100644 --- a/apps/web/src/components/header/header.tsx +++ b/apps/web/src/components/header/header.tsx @@ -1,13 +1,18 @@ 'use client' import { useMediaQuery } from '@/hooks/use-media-query' +import type { UiVersion } from '@/lib/ui-version' import { CommandSearch } from '../command-search' import { Logo } from '../logo' import { HeaderAccount } from './header-account' import { HeaderNavigationDrawer } from './header-navigation-drawer' import { HeaderNavigationMenu } from './header-navigation-menu' -export const Header = () => { +type HeaderProps = { + uiVersion: UiVersion +} + +export const Header = ({ uiVersion }: HeaderProps) => { const isDesktop = useMediaQuery('(min-width: 1024px)') return ( @@ -20,7 +25,7 @@ export const Header = () => {
{isDesktop && } - +
@@ -28,7 +33,8 @@ export const Header = () => {
- {!isDesktop && } + {!isDesktop && }{' '} +
diff --git a/apps/web/src/components/header/ui-version-control.test.tsx b/apps/web/src/components/header/ui-version-control.test.tsx new file mode 100644 index 00000000..89092b4a --- /dev/null +++ b/apps/web/src/components/header/ui-version-control.test.tsx @@ -0,0 +1,43 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { UiVersionControl } from './ui-version-control' + +const refresh = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh }), +})) + +const labels = { + label: 'New interface', + experimentalLabel: 'Experimental', + errorLabel: 'Could not update the interface preference.', +} + +describe('UiVersionControl', () => { + afterEach(() => { + cleanup() + refresh.mockClear() + document.cookie = + 'plotwist-ui=; Path=/; Max-Age=0; SameSite=Lax' + }) + + it('persists the cinematic preference and refreshes the route', () => { + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('switch', { name: labels.label })) + + expect(document.cookie).toContain('plotwist-ui=cinematic') + expect(refresh).toHaveBeenCalledOnce() + + rerender() + + expect( + screen + .getByRole('switch', { name: labels.label }) + .getAttribute('data-state') + ).toBe('checked') + }) +}) diff --git a/apps/web/src/components/header/ui-version-control.tsx b/apps/web/src/components/header/ui-version-control.tsx new file mode 100644 index 00000000..254c89d4 --- /dev/null +++ b/apps/web/src/components/header/ui-version-control.tsx @@ -0,0 +1,68 @@ +'use client' + +import { Badge } from '@plotwist/ui/components/ui/badge' +import { Switch } from '@plotwist/ui/components/ui/switch' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { + parseUiVersion, + serializeUiVersionCookie, + type UiVersion, +} from '@/lib/ui-version' + +type UiVersionControlProps = { + initialVersion: UiVersion + label: string + experimentalLabel: string + errorLabel: string +} + +export const UiVersionControl = ({ + initialVersion, + label, + experimentalLabel, + errorLabel, +}: UiVersionControlProps) => { + const router = useRouter() + const [version, setVersion] = useState(() => + parseUiVersion(initialVersion) + ) + const [error, setError] = useState(null) + + const handleCheckedChange = (checked: boolean) => { + const previousVersion = version + const nextVersion: UiVersion = checked ? 'cinematic' : 'classic' + + setError(null) + + try { + document.cookie = serializeUiVersionCookie(nextVersion) + setVersion(nextVersion) + router.refresh() + } catch { + setVersion(previousVersion) + setError(errorLabel) + } + } + + return ( +
+
+
+ {label} + {experimentalLabel} +
+ +
+ {error && ( +

+ {error} +

+ )} +
+ ) +} From cd56a5b58f238a34c863e551212316c4d8697c4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:17:24 +0000 Subject: [PATCH 12/24] style(web): format UI appearance control Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- apps/web/src/app/[lang]/layout.tsx | 5 +---- apps/web/src/components/header/ui-version-control.test.tsx | 4 ++-- apps/web/src/components/header/ui-version-control.tsx | 5 ++--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/[lang]/layout.tsx b/apps/web/src/app/[lang]/layout.tsx index 54b80431..5cb5ea63 100644 --- a/apps/web/src/app/[lang]/layout.tsx +++ b/apps/web/src/app/[lang]/layout.tsx @@ -12,10 +12,7 @@ import { LanguageContextProvider } from '@/context/language' import { ListsContextProvider } from '@/context/lists' import { SessionContextProvider } from '@/context/session' import { UserPreferencesContextProvider } from '@/context/user-preferences' -import { - parseUiVersion, - UI_VERSION_COOKIE_NAME, -} from '@/lib/ui-version' +import { parseUiVersion, UI_VERSION_COOKIE_NAME } from '@/lib/ui-version' import type { Language } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' import { SUPPORTED_LANGUAGES } from '../../../languages' diff --git a/apps/web/src/components/header/ui-version-control.test.tsx b/apps/web/src/components/header/ui-version-control.test.tsx index 89092b4a..77e4cd29 100644 --- a/apps/web/src/components/header/ui-version-control.test.tsx +++ b/apps/web/src/components/header/ui-version-control.test.tsx @@ -18,8 +18,8 @@ describe('UiVersionControl', () => { afterEach(() => { cleanup() refresh.mockClear() - document.cookie = - 'plotwist-ui=; Path=/; Max-Age=0; SameSite=Lax' + // biome-ignore lint/suspicious/noDocumentCookie: Reset the cookie changed by the control. + document.cookie = 'plotwist-ui=; Path=/; Max-Age=0; SameSite=Lax' }) it('persists the cinematic preference and refreshes the route', () => { diff --git a/apps/web/src/components/header/ui-version-control.tsx b/apps/web/src/components/header/ui-version-control.tsx index 254c89d4..57107ce5 100644 --- a/apps/web/src/components/header/ui-version-control.tsx +++ b/apps/web/src/components/header/ui-version-control.tsx @@ -24,9 +24,7 @@ export const UiVersionControl = ({ errorLabel, }: UiVersionControlProps) => { const router = useRouter() - const [version, setVersion] = useState(() => - parseUiVersion(initialVersion) - ) + const [version, setVersion] = useState(() => parseUiVersion(initialVersion)) const [error, setError] = useState(null) const handleCheckedChange = (checked: boolean) => { @@ -36,6 +34,7 @@ export const UiVersionControl = ({ setError(null) try { + // biome-ignore lint/suspicious/noDocumentCookie: The server reads this first-party preference cookie. document.cookie = serializeUiVersionCookie(nextVersion) setVersion(nextVersion) router.refresh() From 4f763e31003648b619e53e020455f628a1173398 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:19:27 +0000 Subject: [PATCH 13/24] fix(web): synchronize UI version control state Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../header/ui-version-control.test.tsx | 16 +++++++++++++--- .../src/components/header/ui-version-control.tsx | 6 +++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/header/ui-version-control.test.tsx b/apps/web/src/components/header/ui-version-control.test.tsx index 77e4cd29..40b7f483 100644 --- a/apps/web/src/components/header/ui-version-control.test.tsx +++ b/apps/web/src/components/header/ui-version-control.test.tsx @@ -23,14 +23,24 @@ describe('UiVersionControl', () => { }) it('persists the cinematic preference and refreshes the route', () => { - const { rerender } = render( - - ) + render() fireEvent.click(screen.getByRole('switch', { name: labels.label })) expect(document.cookie).toContain('plotwist-ui=cinematic') expect(refresh).toHaveBeenCalledOnce() + }) + + it('synchronizes the switch when the server preference changes', () => { + const { rerender } = render( + + ) + + expect( + screen + .getByRole('switch', { name: labels.label }) + .getAttribute('data-state') + ).toBe('unchecked') rerender() diff --git a/apps/web/src/components/header/ui-version-control.tsx b/apps/web/src/components/header/ui-version-control.tsx index 57107ce5..56f134f4 100644 --- a/apps/web/src/components/header/ui-version-control.tsx +++ b/apps/web/src/components/header/ui-version-control.tsx @@ -3,7 +3,7 @@ import { Badge } from '@plotwist/ui/components/ui/badge' import { Switch } from '@plotwist/ui/components/ui/switch' import { useRouter } from 'next/navigation' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { parseUiVersion, serializeUiVersionCookie, @@ -27,6 +27,10 @@ export const UiVersionControl = ({ const [version, setVersion] = useState(() => parseUiVersion(initialVersion)) const [error, setError] = useState(null) + useEffect(() => { + setVersion(parseUiVersion(initialVersion)) + }, [initialVersion]) + const handleCheckedChange = (checked: boolean) => { const previousVersion = version const nextVersion: UiVersion = checked ? 'cinematic' : 'classic' From ce2449ede6698b40317d144d30427db27ef4d14b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:21:24 +0000 Subject: [PATCH 14/24] test(web): cover shared movie presentation Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../[id]/_components/movie-actions.test.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx new file mode 100644 index 00000000..e612c485 --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { MovieDetails } from '@/services/tmdb' +import { MovieActions } from './movie-actions' +import { MovieRating } from './movie-rating' + +vi.mock('@/components/lists', () => ({ + ListsDropdown: ({ item }: { item: MovieDetails }) => ( +
+ ), +})) + +vi.mock('@/components/item-review', () => ({ + ItemReview: () =>
, +})) + +vi.mock('@/components/item-status', () => ({ + ItemStatus: ({ + mediaType, + tmdbId, + }: { + mediaType: string + tmdbId: number + }) => ( +
+ ), +})) + +vi.mock('@/components/share-page-button', () => ({ + SharePageButton: ({ + language, + path, + }: { + language: string + path: string + }) => ( +
+ ), +})) + +vi.mock('next/image', () => ({ + default: ({ alt }: { alt: string }) => , +})) + +const movie = { + id: 42, + vote_average: 7.26, + vote_count: 321, +} as MovieDetails + +describe('shared movie presentation primitives', () => { + afterEach(() => cleanup()) + + it('passes the movie and localized media props to every action', () => { + render() + + expect(screen.getByTestId('lists-dropdown').dataset.movieId).toBe('42') + expect(screen.getByTestId('item-review')).toBeTruthy() + expect(screen.getByTestId('item-status').dataset.mediaType).toBe('MOVIE') + expect(screen.getByTestId('item-status').dataset.movieId).toBe('42') + expect(screen.getByTestId('share-page-button').dataset.language).toBe( + 'pt-BR' + ) + expect(screen.getByTestId('share-page-button').dataset.path).toBe( + 'movies/42' + ) + }) + + it('renders the TMDB score rounded to one decimal place', () => { + render() + + expect(screen.getByText('7.3')).toBeTruthy() + expect(screen.queryByText('7.26')).toBeNull() + }) +}) From ea1e41b0f04f7a972764eaf1ad414613831cb42e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:21:52 +0000 Subject: [PATCH 15/24] refactor(web): share movie detail actions Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../movies/[id]/_components/movie-actions.tsx | 25 ++++++++++ .../movies/[id]/_components/movie-infos.tsx | 47 ++----------------- .../movies/[id]/_components/movie-rating.tsx | 38 +++++++++++++++ 3 files changed, 67 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.tsx create mode 100644 apps/web/src/app/[lang]/movies/[id]/_components/movie-rating.tsx diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.tsx new file mode 100644 index 00000000..a882c2e9 --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.tsx @@ -0,0 +1,25 @@ +import { ItemReview } from '@/components/item-review' +import { ItemStatus } from '@/components/item-status' +import { ListsDropdown } from '@/components/lists' +import { SharePageButton } from '@/components/share-page-button' +import { cn } from '@/lib/utils' +import type { Language, MovieDetails } from '@/services/tmdb' + +type MovieActionsProps = { + movie: MovieDetails + language: Language + className?: string +} + +export const MovieActions = ({ + movie, + language, + className, +}: MovieActionsProps) => ( +
+ + + + +
+) diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-infos.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-infos.tsx index 5cb961f9..3f4313d9 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/movie-infos.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-infos.tsx @@ -1,56 +1,17 @@ -import { Badge } from '@plotwist/ui/components/ui/badge' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@plotwist/ui/components/ui/tooltip' import { format } from 'date-fns' -import Image from 'next/image' -import { ItemReview } from '@/components/item-review' -import { ItemStatus } from '@/components/item-status' -import { ListsDropdown } from '@/components/lists' import { Poster } from '@/components/poster' -import { SharePageButton } from '@/components/share-page-button' import type { Language, MovieDetails } from '@/services/tmdb' import { locale } from '@/utils/date/locale' +import { MovieActions } from './movie-actions' import { MovieGenres } from './movie-genres' +import { MovieRating } from './movie-rating' type MovieInfosProps = { movie: MovieDetails; language: Language } export const MovieInfos = ({ language, movie }: MovieInfosProps) => { - const actions = ( -
- - - - -
- ) - - const votes = ( - - - - - TMDB + const actions = - {movie.vote_average.toFixed(1)} - - - - -

{movie.vote_count} votes

-
-
-
- ) + const votes = return (
diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-rating.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-rating.tsx new file mode 100644 index 00000000..9d7efd8b --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-rating.tsx @@ -0,0 +1,38 @@ +import { Badge } from '@plotwist/ui/components/ui/badge' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@plotwist/ui/components/ui/tooltip' +import Image from 'next/image' +import type { MovieDetails } from '@/services/tmdb' + +type MovieRatingProps = { + movie: MovieDetails + className?: string +} + +export const MovieRating = ({ movie, className }: MovieRatingProps) => ( + + + + + TMDB + + {movie.vote_average.toFixed(1)} + + + + +

{movie.vote_count} votes

+
+
+
+) From f022d4c5b013dc6c674ed73ffd70c1b1e64b2068 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:22:19 +0000 Subject: [PATCH 16/24] style(web): format movie action test Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../[lang]/movies/[id]/_components/movie-actions.test.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx index e612c485..758a8f29 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx @@ -31,13 +31,7 @@ vi.mock('@/components/item-status', () => ({ })) vi.mock('@/components/share-page-button', () => ({ - SharePageButton: ({ - language, - path, - }: { - language: string - path: string - }) => ( + SharePageButton: ({ language, path }: { language: string; path: string }) => (
Date: Sun, 6 Sep 2026 17:24:44 +0000 Subject: [PATCH 17/24] test(web): cover movie detail renderer selection Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../[id]/_components/movie-details.test.tsx | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx new file mode 100644 index 00000000..dffa98ec --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx @@ -0,0 +1,108 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MovieDetails as MovieDetailsData } from '@/services/tmdb' +import { MovieDetails } from './movie-details' + +const mocks = vi.hoisted(() => ({ + breadcrumbJsonLd: vi.fn(() => null), + cookieGet: vi.fn(), + details: vi.fn(), + movieJsonLd: vi.fn(() => null), +})) + +vi.mock('@/services/tmdb', () => ({ + tmdb: { + movies: { + details: mocks.details, + }, + }, +})) + +vi.mock('next/headers', () => ({ + cookies: vi.fn(async () => ({ + get: mocks.cookieGet, + })), +})) + +vi.mock('next/font/google', () => ({ + Instrument_Sans: () => ({ + className: 'instrument-sans', + }), +})) + +vi.mock('next/image', () => ({ + default: ({ alt }: { alt: string }) => , +})) + +vi.mock('@/components/structured-data', () => ({ + BreadcrumbJsonLd: mocks.breadcrumbJsonLd, + MovieJsonLd: mocks.movieJsonLd, +})) + +vi.mock('./movie-actions', () => ({ + MovieActions: () =>
, +})) + +vi.mock('./movie-collection', () => ({ + MovieCollection: () =>
, +})) + +vi.mock('./movie-genres', () => ({ + MovieGenres: () =>
, +})) + +vi.mock('./movie-rating', () => ({ + MovieRating: () =>
, +})) + +vi.mock('./movie-tabs', () => ({ + MovieTabs: () =>
, +})) + +const movie = { + backdrop_path: '/backdrop.jpg', + belongs_to_collection: { id: 7 }, + genres: [{ id: 18, name: 'Drama' }], + id: 42, + overview: 'A story with an unexpected turn.', + poster_path: '/poster.jpg', + release_date: '2026-09-06', + title: 'The Plot Twist', + vote_average: 8.4, + vote_count: 1200, +} as MovieDetailsData + +describe('MovieDetails renderer selection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.details.mockResolvedValue(movie) + }) + + afterEach(() => cleanup()) + + it('renders the classic details by default with one details fetch', async () => { + mocks.cookieGet.mockReturnValue(undefined) + + render(await MovieDetails({ id: movie.id, language: 'en-US' })) + + expect(mocks.details).toHaveBeenCalledTimes(1) + expect(mocks.details).toHaveBeenCalledWith(movie.id, 'en-US') + expect(screen.getByTestId('classic-movie-details')).toBeTruthy() + expect(screen.queryByTestId('cinematic-movie-details')).toBeNull() + expect(mocks.breadcrumbJsonLd).toHaveBeenCalledTimes(1) + expect(mocks.movieJsonLd).toHaveBeenCalledTimes(1) + }) + + it('renders cinematic details from the UI cookie with one details fetch', async () => { + mocks.cookieGet.mockReturnValue({ value: 'cinematic' }) + + render(await MovieDetails({ id: movie.id, language: 'en-US' })) + + expect(screen.getByTestId('cinematic-movie-details')).toBeTruthy() + expect(screen.queryByTestId('classic-movie-details')).toBeNull() + expect(mocks.details).toHaveBeenCalledTimes(1) + expect(mocks.details).toHaveBeenCalledWith(movie.id, 'en-US') + expect(mocks.breadcrumbJsonLd).toHaveBeenCalledTimes(1) + expect(mocks.movieJsonLd).toHaveBeenCalledTimes(1) + }) +}) From f9e49ab6466840d9ff4a0098bc64ab488d0c905b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:26:00 +0000 Subject: [PATCH 18/24] feat(web): add cinematic movie detail renderer Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../_components/cinematic-movie-details.tsx | 134 ++++++++++++++++++ .../_components/classic-movie-details.tsx | 44 ++++++ .../movies/[id]/_components/movie-details.tsx | 46 +++--- 3 files changed, 198 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx create mode 100644 apps/web/src/app/[lang]/movies/[id]/_components/classic-movie-details.tsx diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx new file mode 100644 index 00000000..2455ce27 --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx @@ -0,0 +1,134 @@ +import { format } from 'date-fns' +import { Instrument_Sans } from 'next/font/google' +import { Suspense, type CSSProperties } from 'react' +import { Banner } from '@/components/banner' +import { Poster } from '@/components/poster' +import type { Language, MovieDetails } from '@/services/tmdb' +import { locale } from '@/utils/date/locale' +import { MovieActions } from './movie-actions' +import { MovieCollection } from './movie-collection' +import { MovieGenres } from './movie-genres' +import { MovieRating } from './movie-rating' +import { MovieTabs } from './movie-tabs' + +const instrumentSans = Instrument_Sans({ + subsets: ['latin'], + display: 'swap', +}) + +const cinematicTheme = { + '--background': '48 10% 4%', + '--foreground': '43 38% 94%', + '--card': '40 7% 8%', + '--card-foreground': '43 38% 94%', + '--popover': '40 7% 8%', + '--popover-foreground': '43 38% 94%', + '--primary': '4 100% 68%', + '--primary-foreground': '0 0% 100%', + '--secondary': '40 7% 12%', + '--secondary-foreground': '43 38% 94%', + '--muted': '40 7% 12%', + '--muted-foreground': '43 18% 70%', + '--accent': '40 7% 16%', + '--accent-foreground': '43 38% 94%', + '--border': '43 18% 22%', + '--input': '43 18% 22%', + '--ring': '4 100% 68%', + '--cinematic-background': '#0b0b09', + '--cinematic-foreground': '#f7f3ea', + '--cinematic-muted': 'rgba(247, 243, 234, 0.66)', + '--cinematic-accent': '#ff645a', + colorScheme: 'dark', +} as CSSProperties + +type CinematicMovieDetailsProps = { + movie: MovieDetails + language: Language + backdropUrl?: string + posterUrl?: string +} + +export const CinematicMovieDetails = ({ + movie, + language, + backdropUrl, + posterUrl, +}: CinematicMovieDetailsProps) => ( +
+
+ +
+
+
+ +
+
+ + +
+ {movie.release_date && ( +

+ {format(new Date(movie.release_date), 'PPP', { + locale: locale[language], + })} +

+ )} + +

+ {movie.title} +

+ +
+ + +
+
+ +
+

+ {movie.overview} +

+ + +
+
+
+ +
+ {movie.belongs_to_collection && ( + + + + )} + + + + +
+
+) diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/classic-movie-details.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/classic-movie-details.tsx new file mode 100644 index 00000000..b81e8636 --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/classic-movie-details.tsx @@ -0,0 +1,44 @@ +import { Suspense } from 'react' +import { Banner } from '@/components/banner' +import type { Language, MovieDetails } from '@/services/tmdb' +import { MovieCollection } from './movie-collection' +import { MovieInfos } from './movie-infos' +import { MovieTabs } from './movie-tabs' + +type ClassicMovieDetailsProps = { + movie: MovieDetails + language: Language + backdropUrl?: string + posterUrl?: string +} + +export const ClassicMovieDetails = ({ + movie, + language, + backdropUrl, + posterUrl, +}: ClassicMovieDetailsProps) => ( +
+ + +
+ + + {movie.belongs_to_collection && ( + + + + )} + + + + +
+
+) diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx index 6e43da14..0b54dac4 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx @@ -1,14 +1,16 @@ -import { Suspense } from 'react' - -import { Banner } from '@/components/banner' +import { cookies } from 'next/headers' import { BreadcrumbJsonLd, MovieJsonLd } from '@/components/structured-data' +import { + parseUiVersion, + UI_VERSION_COOKIE_NAME, + type UiVersion, +} from '@/lib/ui-version' import { tmdb } from '@/services/tmdb' import type { Language } from '@/types/languages' import { tmdbImage } from '@/utils/tmdb/image' import { APP_URL } from '../../../../../../constants' -import { MovieCollection } from './movie-collection' -import { MovieInfos } from './movie-infos' -import { MovieTabs } from './movie-tabs' +import { CinematicMovieDetails } from './cinematic-movie-details' +import { ClassicMovieDetails } from './classic-movie-details' type MovieDetailsProps = { id: number @@ -23,9 +25,14 @@ export const MovieDetails = async ({ id, language }: MovieDetailsProps) => { const posterUrl = movie.poster_path ? tmdbImage(movie.poster_path) : undefined const structuredDataImage = backdropUrl ?? posterUrl ?? `${APP_URL}/logo-black.png` + const cookieStore = await cookies() + const uiVersion: UiVersion = parseUiVersion( + cookieStore.get(UI_VERSION_COOKIE_NAME)?.value + ) + const rendererProps = { movie, language, backdropUrl, posterUrl } return ( -
+ <> { rating={movie.vote_average} url={`https://plotwist.app/${language}/movies/${id}`} /> - - -
- - - {movie.belongs_to_collection && ( - - - - )} - - - - -
-
+ {uiVersion === 'cinematic' ? ( + + ) : ( + + )} + ) } From 162bf608554174f81660a4613c0bfd89528069e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:26:25 +0000 Subject: [PATCH 19/24] style(web): organize cinematic renderer imports Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../[lang]/movies/[id]/_components/cinematic-movie-details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx index 2455ce27..109bc299 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx @@ -1,6 +1,6 @@ import { format } from 'date-fns' import { Instrument_Sans } from 'next/font/google' -import { Suspense, type CSSProperties } from 'react' +import { type CSSProperties, Suspense } from 'react' import { Banner } from '@/components/banner' import { Poster } from '@/components/poster' import type { Language, MovieDetails } from '@/services/tmdb' From a3a8ab7570b4c1d3bddb87c7b111b0d1f4b22305 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:39:32 +0000 Subject: [PATCH 20/24] test(web): cover cinematic final review findings Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../[id]/_components/movie-actions.test.tsx | 10 + .../[id]/_components/movie-details.test.tsx | 179 +++++++++++++++++- .../header/ui-version-control.test.tsx | 16 ++ 3 files changed, 200 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx index 758a8f29..eca41c23 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx @@ -74,4 +74,14 @@ describe('shared movie presentation primitives', () => { expect(screen.getByText('7.3')).toBeTruthy() expect(screen.queryByText('7.26')).toBeNull() }) + + it('exposes the TMDB score and vote count through a focusable trigger', () => { + render() + + const trigger = screen.getByRole('button', { + name: 'TMDB rating 7.3 out of 10, 321 votes', + }) + + expect(trigger.tabIndex).toBe(0) + }) }) diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx index dffa98ec..c8ee8558 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx @@ -40,23 +40,73 @@ vi.mock('@/components/structured-data', () => ({ })) vi.mock('./movie-actions', () => ({ - MovieActions: () =>
, + MovieActions: ({ + language, + movie, + }: { + language: string + movie: MovieDetailsData + }) => ( +
+ ), })) vi.mock('./movie-collection', () => ({ - MovieCollection: () =>
, + MovieCollection: ({ + collectionId, + language, + }: { + collectionId: number + language: string + }) => ( +
+ ), })) vi.mock('./movie-genres', () => ({ - MovieGenres: () =>
, + MovieGenres: ({ + className, + genres, + }: { + className?: string + genres: MovieDetailsData['genres'] + }) => ( +
+ ), })) vi.mock('./movie-rating', () => ({ - MovieRating: () =>
, + MovieRating: ({ movie }: { movie: MovieDetailsData }) => ( +
+ ), })) vi.mock('./movie-tabs', () => ({ - MovieTabs: () =>
, + MovieTabs: ({ + language, + movie, + }: { + language: string + movie: MovieDetailsData + }) => ( +
+ ), })) const movie = { @@ -72,6 +122,55 @@ const movie = { vote_count: 1200, } as MovieDetailsData +const parseHsl = (value: string) => { + const match = value.match( + /^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/ + ) as RegExpMatchArray + const hue = Number(match[1]) + const saturation = Number(match[2]) / 100 + const lightness = Number(match[3]) / 100 + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation + const segment = hue / 60 + const secondary = chroma * (1 - Math.abs((segment % 2) - 1)) + const [red, green, blue] = + segment < 1 + ? [chroma, secondary, 0] + : segment < 2 + ? [secondary, chroma, 0] + : segment < 3 + ? [0, chroma, secondary] + : segment < 4 + ? [0, secondary, chroma] + : segment < 5 + ? [secondary, 0, chroma] + : [chroma, 0, secondary] + const offset = lightness - chroma / 2 + + return [red + offset, green + offset, blue + offset] +} + +const relativeLuminance = (color: number[]) => + color + .map(channel => + channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4 + ) + .reduce( + (luminance, channel, index) => + luminance + channel * [0.2126, 0.7152, 0.0722][index], + 0 + ) + +const contrastRatio = (first: string, second: string) => { + const firstLuminance = relativeLuminance(parseHsl(first)) + const secondLuminance = relativeLuminance(parseHsl(second)) + const lighter = Math.max(firstLuminance, secondLuminance) + const darker = Math.min(firstLuminance, secondLuminance) + + return (lighter + 0.05) / (darker + 0.05) +} + describe('MovieDetails renderer selection', () => { beforeEach(() => { vi.clearAllMocks() @@ -104,5 +203,75 @@ describe('MovieDetails renderer selection', () => { expect(mocks.details).toHaveBeenCalledWith(movie.id, 'en-US') expect(mocks.breadcrumbJsonLd).toHaveBeenCalledTimes(1) expect(mocks.movieJsonLd).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('movie-actions').dataset).toMatchObject({ + language: 'en-US', + movieId: '42', + }) + expect(screen.getByTestId('movie-genres').dataset.genreCount).toBe('1') + expect(screen.getByTestId('movie-rating').dataset.voteCount).toBe('1200') + expect(screen.getByTestId('movie-collection').dataset).toMatchObject({ + collectionId: '7', + language: 'en-US', + }) + expect(screen.getByTestId('movie-tabs').dataset).toMatchObject({ + language: 'en-US', + movieId: '42', + }) + }) + + it('falls back to classic details for an invalid UI cookie', async () => { + mocks.cookieGet.mockReturnValue({ value: 'cinematic-preview' }) + + render(await MovieDetails({ id: movie.id, language: 'en-US' })) + + expect(screen.getByTestId('classic-movie-details')).toBeTruthy() + expect(screen.queryByTestId('cinematic-movie-details')).toBeNull() + }) + + it('keeps cinematic primary text at WCAG AA contrast', async () => { + mocks.cookieGet.mockReturnValue({ value: 'cinematic' }) + + render(await MovieDetails({ id: movie.id, language: 'en-US' })) + + const root = screen.getByTestId('cinematic-movie-details') + const primary = root.style.getPropertyValue('--primary') + const primaryForeground = root.style.getPropertyValue( + '--primary-foreground' + ) + + expect(contrastRatio(primary, primaryForeground)).toBeGreaterThanOrEqual( + 4.5 + ) + }) + + it('stacks the cinematic hero and safely wraps its title below sm', async () => { + mocks.cookieGet.mockReturnValue({ value: 'cinematic' }) + + render(await MovieDetails({ id: movie.id, language: 'en-US' })) + + const title = screen.getByRole('heading', { level: 1, name: movie.title }) + const heroGrid = title.closest('article')?.parentElement + + expect(heroGrid?.className.split(' ')).toContain('grid-cols-1') + expect(heroGrid?.className).toContain( + 'sm:grid-cols-[180px_minmax(0,1fr)]' + ) + expect(title.className).toContain('[overflow-wrap:anywhere]') + expect(screen.getByTestId('movie-genres').className).toContain( + 'whitespace-normal' + ) + }) + + it('renders existing banner and poster fallbacks when images are missing', async () => { + mocks.cookieGet.mockReturnValue({ value: 'cinematic' }) + mocks.details.mockResolvedValue({ + ...movie, + backdrop_path: null, + poster_path: null, + }) + + render(await MovieDetails({ id: movie.id, language: 'en-US' })) + + expect(screen.getAllByText(movie.title)).toHaveLength(3) }) }) diff --git a/apps/web/src/components/header/ui-version-control.test.tsx b/apps/web/src/components/header/ui-version-control.test.tsx index 40b7f483..833cd647 100644 --- a/apps/web/src/components/header/ui-version-control.test.tsx +++ b/apps/web/src/components/header/ui-version-control.test.tsx @@ -17,6 +17,7 @@ const labels = { describe('UiVersionControl', () => { afterEach(() => { cleanup() + vi.restoreAllMocks() refresh.mockClear() // biome-ignore lint/suspicious/noDocumentCookie: Reset the cookie changed by the control. document.cookie = 'plotwist-ui=; Path=/; Max-Age=0; SameSite=Lax' @@ -31,6 +32,21 @@ describe('UiVersionControl', () => { expect(refresh).toHaveBeenCalledOnce() }) + it('reverts the switch and announces an error when cookies are silently rejected', () => { + vi.spyOn(Document.prototype, 'cookie', 'set').mockImplementation(() => {}) + render() + + fireEvent.click(screen.getByRole('switch', { name: labels.label })) + + expect( + screen + .getByRole('switch', { name: labels.label }) + .getAttribute('data-state') + ).toBe('unchecked') + expect(screen.getByRole('alert').textContent).toBe(labels.errorLabel) + expect(refresh).not.toHaveBeenCalled() + }) + it('synchronizes the switch when the server preference changes', () => { const { rerender } = render( From cc0e8fae4b83b545adc4fb189e96f364720a6746 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:39:39 +0000 Subject: [PATCH 21/24] fix(web): address cinematic rollout review Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../_components/cinematic-movie-details.tsx | 15 ++++--- .../movies/[id]/_components/movie-genres.tsx | 13 +++++-- .../movies/[id]/_components/movie-rating.tsx | 39 ++++++++++++------- .../components/header/ui-version-control.tsx | 8 ++++ 4 files changed, 51 insertions(+), 24 deletions(-) diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx index 109bc299..505fc343 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx @@ -24,7 +24,7 @@ const cinematicTheme = { '--popover': '40 7% 8%', '--popover-foreground': '43 38% 94%', '--primary': '4 100% 68%', - '--primary-foreground': '0 0% 100%', + '--primary-foreground': '48 10% 4%', '--secondary': '40 7% 12%', '--secondary-foreground': '43 38% 94%', '--muted': '40 7% 12%', @@ -71,8 +71,8 @@ export const CinematicMovieDetails = ({
-
-