diff --git a/apps/web/public/dictionaries/de-DE.json b/apps/web/public/dictionaries/de-DE.json index 1e5004d12..d3796841c 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 b1a0c69d1..398f4c2bd 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 58c47623f..70dae2c94 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 da8fa1499..46b5eb730 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 88110b403..1c1f8b980 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 7546fd924..16a53a70d 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 a983cbf11..9c3cd6844 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 5606bc8a0..5cb5ea63a 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,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 type { Language } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' import { SUPPORTED_LANGUAGES } from '../../../languages' @@ -34,6 +36,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 +62,7 @@ export default async function RootLayout({ } + header={
} footer={
} proBadge={ session?.user.subscriptionType !== 'PRO' ? ( 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 000000000..bbee60361 --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/cinematic-movie-details.tsx @@ -0,0 +1,137 @@ +import { format } from 'date-fns' +import { Instrument_Sans } from 'next/font/google' +import { type CSSProperties, Suspense } 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': '48 10% 4%', + '--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 000000000..b81e8636c --- /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-actions.test.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx new file mode 100644 index 000000000..06cd9d389 --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-actions.test.tsx @@ -0,0 +1,87 @@ +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() + }) + + it('exposes the TMDB score and vote count through a focusable trigger', () => { + render() + + const trigger = screen.getByRole('button', { + name: 'TMDB 7.3/10 · 321', + }) + + expect(trigger.tabIndex).toBe(0) + }) +}) 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 000000000..a882c2e9d --- /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-details.test.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx new file mode 100644 index 000000000..0a5cedffd --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.test.tsx @@ -0,0 +1,283 @@ +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: ({ + language, + movie, + }: { + language: string + movie: MovieDetailsData + }) => ( +
+ ), +})) + +vi.mock('./movie-collection', () => ({ + MovieCollection: ({ + collectionId, + language, + }: { + collectionId: number + language: string + }) => ( +
+ ), +})) + +vi.mock('./movie-genres', () => ({ + MovieGenres: ({ + className, + genres, + }: { + className?: string + genres: MovieDetailsData['genres'] + }) => ( +
+ ), +})) + +vi.mock('./movie-rating', () => ({ + MovieRating: ({ movie }: { movie: MovieDetailsData }) => ( +
+ ), +})) + +vi.mock('./movie-tabs', () => ({ + MovieTabs: ({ + language, + movie, + }: { + language: string + movie: MovieDetailsData + }) => ( +
+ ), +})) + +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 + +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() + 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) + 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', + }) + expect(screen.getByText(movie.overview)).toBeTruthy() + }) + + 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 + const contentRow = screen.getByText(movie.overview).parentElement + + expect(heroGrid?.className.split(' ')).toContain('grid-cols-1') + expect(heroGrid?.className).toContain('sm:grid-cols-[180px_minmax(0,1fr)]') + expect(contentRow?.className.split(' ')).toEqual( + expect.arrayContaining([ + 'col-span-1', + 'sm:col-span-2', + 'lg:col-start-2', + 'lg:col-end-3', + ]) + ) + 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/app/[lang]/movies/[id]/_components/movie-details.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx index 6e43da14f..0b54dac4d 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' ? ( + + ) : ( + + )} + ) } diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-genres.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-genres.tsx index 0170eb411..e37b6ea6c 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/movie-genres.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-genres.tsx @@ -3,11 +3,15 @@ import { Badge } from '@plotwist/ui/components/ui/badge' import { Link } from 'next-view-transitions' import { useLanguage } from '@/context/language' +import { cn } from '@/lib/utils' import type { MovieDetails } from '@/services/tmdb' -type MovieGenresProps = { genres: MovieDetails['genres'] } +type MovieGenresProps = { + genres: MovieDetails['genres'] + className?: string +} -export const MovieGenres = ({ genres }: MovieGenresProps) => { +export const MovieGenres = ({ genres, className }: MovieGenresProps) => { const { language } = useLanguage() const hasGenres = genres.length > 0 @@ -18,7 +22,10 @@ export const MovieGenres = ({ genres }: MovieGenresProps) => { {genres.map(({ id, name }) => { return ( - + {name} 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 5cb961f98..3f4313d97 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 000000000..ce212b424 --- /dev/null +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-rating.tsx @@ -0,0 +1,47 @@ +import { badgeVariants } from '@plotwist/ui/components/ui/badge' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@plotwist/ui/components/ui/tooltip' +import Image from 'next/image' +import { cn } from '@/lib/utils' +import type { MovieDetails } from '@/services/tmdb' + +type MovieRatingProps = { + movie: MovieDetails + className?: string +} + +export const MovieRating = ({ movie, className }: MovieRatingProps) => { + const score = movie.vote_average.toFixed(1) + + return ( + + + + + + + +

{movie.vote_count} votes

+
+
+
+ ) +} diff --git a/apps/web/src/components/header/header-account.tsx b/apps/web/src/components/header/header-account.tsx index 7d21abe08..a8edac42e 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 9ef9deae6..bdb124ec4 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 207de8cbd..539d1ab17 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 f72cff530..c773fac9b 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 000000000..833cd647a --- /dev/null +++ b/apps/web/src/components/header/ui-version-control.test.tsx @@ -0,0 +1,69 @@ +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() + 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' + }) + + it('persists the cinematic preference and refreshes the route', () => { + render() + + fireEvent.click(screen.getByRole('switch', { name: labels.label })) + + expect(document.cookie).toContain('plotwist-ui=cinematic') + 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( + + ) + + expect( + screen + .getByRole('switch', { name: labels.label }) + .getAttribute('data-state') + ).toBe('unchecked') + + 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 000000000..855ca0c52 --- /dev/null +++ b/apps/web/src/components/header/ui-version-control.tsx @@ -0,0 +1,79 @@ +'use client' + +import { Badge } from '@plotwist/ui/components/ui/badge' +import { Switch } from '@plotwist/ui/components/ui/switch' +import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { + parseUiVersion, + readUiVersionCookie, + 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) + + useEffect(() => { + setVersion(parseUiVersion(initialVersion)) + }, [initialVersion]) + + const handleCheckedChange = (checked: boolean) => { + const previousVersion = version + const nextVersion: UiVersion = checked ? 'cinematic' : 'classic' + + setError(null) + + try { + // biome-ignore lint/suspicious/noDocumentCookie: The server reads this first-party preference cookie. + document.cookie = serializeUiVersionCookie(nextVersion) + + if (readUiVersionCookie(document.cookie) !== nextVersion) { + setVersion(previousVersion) + setError(errorLabel) + return + } + + setVersion(nextVersion) + router.refresh() + } catch { + setVersion(previousVersion) + setError(errorLabel) + } + } + + return ( +
+
+
+ {label} + {experimentalLabel} +
+ +
+ {error && ( +

+ {error} +

+ )} +
+ ) +} 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 000000000..9d6d2d438 --- /dev/null +++ b/apps/web/src/lib/ui-version.test.ts @@ -0,0 +1,35 @@ +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('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 new file mode 100644 index 000000000..e5688e50c --- /dev/null +++ b/apps/web/src/lib/ui-version.ts @@ -0,0 +1,21 @@ +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 prefix = `${UI_VERSION_COOKIE_NAME}=` + const value = cookieHeader + .split(';') + .map(part => part.trim()) + .find(part => part.startsWith(prefix)) + ?.slice(prefix.length) + + 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/src/utils/dictionaries/get-dictionaries.test.ts b/apps/web/src/utils/dictionaries/get-dictionaries.test.ts new file mode 100644 index 000000000..96ee0dbcf --- /dev/null +++ b/apps/web/src/utils/dictionaries/get-dictionaries.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { languages } from '../../../languages' +import { getDictionary } from './get-dictionaries' + +const REQUIRED_APPEARANCE_KEYS = [ + 'appearance', + 'new_interface', + 'experimental', + 'ui_preference_error', +] as const + +describe('appearance dictionary contract', () => { + it('covers all seven supported locales', () => { + expect(languages).toHaveLength(7) + }) + + it.each(languages)('%s provides every appearance label', async language => { + const dictionary = await getDictionary(language) + + for (const key of REQUIRED_APPEARANCE_KEYS) { + expect(dictionary[key].trim(), `${language}.${key}`).not.toBe('') + } + }) +}) diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/apps/web/test/setup.ts @@ -0,0 +1 @@ +export {} diff --git a/apps/web/vitest.preview.config.ts b/apps/web/vitest.preview.config.ts index b8cc6b6b4..02dbfc8bf 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'), + }, }, }) 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 000000000..da34931fa --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-cinematic-movie-details.md @@ -0,0 +1,444 @@ +# 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` +- Create: `apps/web/test/setup.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: 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: + +```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. 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 000000000..d44657ba2 --- /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.