diff --git a/docs/superpowers/plans/2026-04-03-victory-condition.md b/docs/superpowers/plans/2026-04-03-victory-condition.md new file mode 100644 index 0000000..25d38e8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-victory-condition.md @@ -0,0 +1,1249 @@ +# Victory Condition 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:** When all highlighted cells are filled and the main actor name is correct, mark the game won, reveal all rows with colour coding, and display a victory card with the actor's photo. + +**Architecture:** React detects when all highlighted cells (one per non-separator row) are filled, calls `POST /api/game/{id}/check` with the submitted letters, and on a `won: true` response applies a reveal state: highlighted cells turn green, other cells show the correct actor name (red where the player was wrong), all inputs disabled, and a `VictoryCard` renders above the grid. + +**Tech Stack:** Symfony 7 / PHP 8.4 / Doctrine ORM / PHPUnit / React 18 / functional components with hooks + +--- + +## File map + +| File | Action | +|---|---| +| `src/Entity/Actor.php` | Add `profilePath` property + getter/setter | +| `migrations/Version20260403000001.php` | Add `profile_path` column to `actor` table | +| `src/Entity/Game.php` | Add `STATUS_WON` + `win()` method | +| `tests/Entity/GameTest.php` | Unit test for `win()` | +| `src/Model/TMDB/TMDBMovieCredit.php` | Add `profile_path` field | +| `src/Model/TMDB/TMDBPerson.php` | New model for `/person/{id}` response | +| `src/Gateway/TMDBGateway.php` | Add `getPersonDetails()` method | +| `tests/Gateway/TMDBGatewayTest.php` | Unit test for `getPersonDetails()` | +| `src/Import/ActorSyncer.php` | Populate `profilePath` from credit | +| `tests/Import/ActorSyncerTest.php` | Unit test for profilePath population | +| `src/Repository/ActorRepository.php` | Add `findWithTmdbIdAndNoProfilePath()` | +| `src/Command/BackfillActorProfilePathCommand.php` | Backfill command for existing actors | +| `src/Controller/Api/GameCheckController.php` | `POST /api/game/{id}/check` endpoint | +| `templates/homepage/index.html.twig` | Pass `gameId` to `GameGrid` React props | +| `assets/react/controllers/LetterInput.jsx` | Controlled input; add `value`, `onChange`, `disabled`, `colorClass` props | +| `assets/react/controllers/GameRow.jsx` | Lift state; compute reveal classes per cell | +| `assets/react/controllers/GameGrid.jsx` | Hold all state; victory detection; API call; reveal logic | +| `assets/react/controllers/VictoryCard.jsx` | New component: photo + name + new-game button | +| `assets/styles/app.css` | Add `.letter-correct`, `.letter-wrong`, `.victory-card` styles | + +--- + +## Task 1: Add `profilePath` to Actor entity + +**Files:** +- Modify: `src/Entity/Actor.php` +- Create: `migrations/Version20260403000001.php` + +- [ ] **Step 1: Add property, getter and setter to Actor** + +In `src/Entity/Actor.php`, after the `$tmdbId` column declaration, add: + +```php +#[ORM\Column(length: 255, nullable: true)] +private ?string $profilePath = null; +``` + +After the `setTmdbId()` method, add: + +```php +public function getProfilePath(): ?string +{ + return $this->profilePath; +} + +public function setProfilePath(?string $profilePath): static +{ + $this->profilePath = $profilePath; + + return $this; +} +``` + +- [ ] **Step 2: Create the migration** + +Create `migrations/Version20260403000001.php`: + +```php +addSql('ALTER TABLE actor ADD profile_path VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE actor DROP COLUMN profile_path'); + } +} +``` + +- [ ] **Step 3: Run the migration** + +```bash +make migrate +``` + +Expected: `[OK] Successfully executed 1 migrations.` + +- [ ] **Step 4: Commit** + +```bash +git add src/Entity/Actor.php migrations/Version20260403000001.php +git commit -m "feat: add profile_path column to actor" +``` + +--- + +## Task 2: Add `STATUS_WON` and `win()` to Game entity + +**Files:** +- Modify: `src/Entity/Game.php` +- Create: `tests/Entity/GameTest.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Entity/GameTest.php`: + +```php +win(); + + $after = new \DateTimeImmutable(); + + $this->assertSame(Game::STATUS_WON, $game->getStatus()); + $this->assertNotNull($game->getEndedAt()); + $this->assertGreaterThanOrEqual($before->getTimestamp(), $game->getEndedAt()->getTimestamp()); + $this->assertLessThanOrEqual($after->getTimestamp(), $game->getEndedAt()->getTimestamp()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +make test +``` + +Expected: FAIL — `Call to undefined method App\Entity\Game::win()` + +- [ ] **Step 3: Add STATUS_WON and win() to Game** + +In `src/Entity/Game.php`, add the constant alongside the existing ones: + +```php +public const string STATUS_WON = 'won'; +``` + +After the `abandon()` method, add: + +```php +public function win(): static +{ + $this->status = self::STATUS_WON; + $this->endedAt = new \DateTimeImmutable(); + + return $this; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +make test +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Entity/Game.php tests/Entity/GameTest.php +git commit -m "feat: add STATUS_WON and win() to Game entity" +``` + +--- + +## Task 3: TMDBPerson model + `getPersonDetails()` gateway method + +**Files:** +- Modify: `src/Model/TMDB/TMDBMovieCredit.php` +- Create: `src/Model/TMDB/TMDBPerson.php` +- Modify: `src/Gateway/TMDBGateway.php` +- Create: `tests/Gateway/TMDBGatewayTest.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Gateway/TMDBGatewayTest.php`: + +```php +createMock(ResponseInterface::class); + $response->method('getContent')->willReturn('{}'); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request') + ->with('GET', 'https://api.themoviedb.org/3/person/1136406') + ->willReturn($response); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer->method('deserialize') + ->with('{}', TMDBPerson::class, 'json') + ->willReturn($person); + + $gateway = new TMDBGateway($httpClient, $serializer, 'fake-token', 'https://api.themoviedb.org/3'); + $result = $gateway->getPersonDetails(1136406); + + $this->assertInstanceOf(TMDBPerson::class, $result); + $this->assertSame(1136406, $result->id); + $this->assertSame('/abc123.jpg', $result->profile_path); + } + + public function testGetPersonDetailsReturnsNullOnGatewayException(): void + { + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willThrowException(new \RuntimeException('network error')); + + $gateway = new TMDBGateway( + $httpClient, + $this->createMock(SerializerInterface::class), + 'fake-token', + 'https://api.themoviedb.org/3' + ); + + $result = $gateway->getPersonDetails(1136406); + + $this->assertNull($result); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +make test +``` + +Expected: FAIL — `TMDBPerson` class not found + +- [ ] **Step 3: Create TMDBPerson model** + +Create `src/Model/TMDB/TMDBPerson.php`: + +```php + $this->id; }, + public string $name { get => $this->name; }, + public ?string $profile_path { get => $this->profile_path; }, + ) {} +} +``` + +- [ ] **Step 4: Add `getPersonDetails()` to TMDBGateway** + +In `src/Gateway/TMDBGateway.php`, add the constant with the others: + +```php +private const string PERSON_URI = '/person/{id}'; +``` + +Add the method after `getMovieCredits()`: + +```php +/** + * @throws GatewayException + */ +public function getPersonDetails(int $personId): ?TMDBPerson +{ + $url = $this->host . str_replace('{id}', (string) $personId, self::PERSON_URI); + try { + return $this->fetchSerialized('GET', $url, TMDBPerson::class); + } catch (GatewayException) { + return null; + } +} +``` + +Add the import at the top of the file: + +```php +use App\Model\TMDB\TMDBPerson; +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +make test +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/Model/TMDB/TMDBPerson.php src/Gateway/TMDBGateway.php tests/Gateway/TMDBGatewayTest.php +git commit -m "feat: add TMDBPerson model and getPersonDetails() to TMDBGateway" +``` + +--- + +## Task 4: Populate `profilePath` during actor import + +**Files:** +- Modify: `src/Model/TMDB/TMDBMovieCredit.php` +- Modify: `src/Import/ActorSyncer.php` +- Create: `tests/Import/ActorSyncerTest.php` + +The TMDB movie credits response includes `profile_path` for each cast member. Adding it to `TMDBMovieCredit` avoids an extra API call per actor during import. + +- [ ] **Step 1: Write the failing test** + +Create `tests/Import/ActorSyncerTest.php`: + +```php +createMock(TMDBGateway::class); + $gateway->method('getMovieCredits')->willReturn($context); + + $actorRepo = $this->createMock(EntityRepository::class); + $actorRepo->method('findOneBy')->with(['tmdbId' => 42])->willReturn(null); + + $roleRepo = $this->createMock(EntityRepository::class); + $roleRepo->method('count')->willReturn(0); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturnMap([ + [Actor::class, $actorRepo], + [\App\Entity\MovieRole::class, $roleRepo], + ]); + + $persisted = []; + $em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) { + $persisted[] = $entity; + }); + + $syncer = new ActorSyncer($gateway, $em); + + $movie = new Movie(); + $movie->setTmdbId(999); + $movie->setTitle('A Film'); + $movie->setLtbxdRef('a-film'); + $syncer->syncActorsForMovie($movie); + + $actor = array_values(array_filter($persisted, fn($e) => $e instanceof Actor))[0]; + $this->assertSame('/abc123.jpg', $actor->getProfilePath()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +make test +``` + +Expected: FAIL — `TMDBMovieCredit` constructor does not accept a 5th argument + +- [ ] **Step 3: Add `profile_path` to TMDBMovieCredit** + +In `src/Model/TMDB/TMDBMovieCredit.php`, add the field: + +```php + $this->id; }, + public string $name { get => $this->name; }, + public float $popularity { get => $this->popularity; }, + public string $character { get => $this->character; }, + public ?string $profile_path { get => $this->profile_path; } = null, + ) {} +} +``` + +- [ ] **Step 4: Populate profilePath in ActorSyncer** + +In `src/Import/ActorSyncer.php`, update the actor creation block inside `syncActorsForMovie()`: + +```php +if (!$actor instanceof Actor) { + $actor = new Actor() + ->setPopularity($actorModel->popularity) + ->setName($actorModel->name) + ->setTmdbId($actorModel->id) + ->setProfilePath($actorModel->profile_path); + + $this->em->persist($actor); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +make test +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/Model/TMDB/TMDBMovieCredit.php src/Import/ActorSyncer.php tests/Import/ActorSyncerTest.php +git commit -m "feat: populate actor profilePath from TMDB credits during import" +``` + +--- + +## Task 5: Backfill command for existing actors + +**Files:** +- Modify: `src/Repository/ActorRepository.php` +- Create: `src/Command/BackfillActorProfilePathCommand.php` + +- [ ] **Step 1: Add `findWithTmdbIdAndNoProfilePath()` to ActorRepository** + +In `src/Repository/ActorRepository.php`, add the method: + +```php +/** + * @return Actor[] + */ +public function findWithTmdbIdAndNoProfilePath(): array +{ + return $this->createQueryBuilder('a') + ->where('a.tmdbId IS NOT NULL') + ->andWhere('a.profilePath IS NULL') + ->getQuery() + ->getResult(); +} +``` + +- [ ] **Step 2: Create the backfill command** + +Create `src/Command/BackfillActorProfilePathCommand.php`: + +```php +actorRepository->findWithTmdbIdAndNoProfilePath(); + + if (empty($actors)) { + $io->success('No actors to backfill.'); + return Command::SUCCESS; + } + + $io->progressStart(count($actors)); + $updated = 0; + + foreach ($actors as $actor) { + try { + $person = $this->tmdbGateway->getPersonDetails($actor->getTmdbId()); + if ($person !== null && $person->profile_path !== null) { + $actor->setProfilePath($person->profile_path); + ++$updated; + } + } catch (\Throwable) { + // Skip actors that fail — TMDB may throttle or the person may not exist + } + $io->progressAdvance(); + } + + $this->em->flush(); + $io->progressFinish(); + $io->success(sprintf('Updated %d/%d actors.', $updated, count($actors))); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 3: Run tests to confirm nothing broke** + +```bash +make test +``` + +Expected: all PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/Repository/ActorRepository.php src/Command/BackfillActorProfilePathCommand.php +git commit -m "feat: add backfill command for actor profile_path" +``` + +--- + +## Task 6: `POST /api/game/{id}/check` endpoint + +**Files:** +- Create: `src/Controller/Api/GameCheckController.php` + +The endpoint receives the submitted highlighted letters, compares them case-insensitively to the main actor's name alphabetic characters in order, marks the game as won if they match, and returns the full reveal data. + +- [ ] **Step 1: Create the controller** + +Create `src/Controller/Api/GameCheckController.php`: + +```php +getUser(); + + // Ownership check + if ($user) { + if ($game->getUser() !== $user) { + return $this->json(['error' => 'Forbidden'], 403); + } + } else { + $sessionGameId = $request->getSession()->get('current_game_id'); + if ($game->getId() !== $sessionGameId) { + return $this->json(['error' => 'Forbidden'], 403); + } + } + + if ($game->getStatus() !== Game::STATUS_IN_PROGRESS) { + return $this->json(['error' => 'Game is not in progress'], 400); + } + + $data = json_decode($request->getContent(), true); + /** @var list $submittedLetters */ + $submittedLetters = $data['letters'] ?? []; + + $mainActorName = $game->getMainActor()->getName(); + $alphaChars = array_values( + array_filter(str_split($mainActorName), fn(string $c) => ctype_alpha($c)) + ); + + if (count($submittedLetters) !== count($alphaChars)) { + return $this->json(['error' => 'Invalid letter count'], 400); + } + + $won = true; + foreach ($alphaChars as $i => $char) { + if (strtoupper($submittedLetters[$i]) !== strtoupper($char)) { + $won = false; + break; + } + } + + if (!$won) { + return $this->json(['won' => false]); + } + + $game->win(); + $em->flush(); + + $mainActor = $game->getMainActor(); + $profilePath = $mainActor->getProfilePath(); + $actorPhotoUrl = $profilePath !== null + ? 'https://image.tmdb.org/t/p/w500' . $profilePath + : null; + + $rows = array_map( + fn($row) => [ + 'actorName' => $row->getActor()->getName(), + 'letters' => str_split($row->getActor()->getName()), + ], + $game->getRows()->toArray() + ); + + return $this->json([ + 'won' => true, + 'actorName' => $mainActor->getName(), + 'actorPhotoUrl' => $actorPhotoUrl, + 'rows' => $rows, + ]); + } +} +``` + +- [ ] **Step 2: Run tests to confirm nothing broke** + +```bash +make test +``` + +Expected: all PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/Controller/Api/GameCheckController.php +git commit -m "feat: add POST /api/game/{id}/check endpoint" +``` + +--- + +## Task 7: Pass `gameId` from Twig to React + +**Files:** +- Modify: `templates/homepage/index.html.twig` + +- [ ] **Step 1: Add gameId to react_component props** + +In `templates/homepage/index.html.twig`, find: + +```twig +
+``` + +Replace with: + +```twig +
+``` + +- [ ] **Step 2: Commit** + +```bash +git add templates/homepage/index.html.twig +git commit -m "feat: pass gameId to GameGrid React component" +``` + +--- + +## Task 8: Lift state in React components + +**Files:** +- Modify: `assets/react/controllers/LetterInput.jsx` +- Modify: `assets/react/controllers/GameRow.jsx` + +`LetterInput` becomes fully controlled: it receives its `value`, reports changes via `onChange`, accepts a `disabled` prop, and renders a CSS `colorClass` on the ``. + +- [ ] **Step 1: Rewrite LetterInput** + +Replace the entire content of `assets/react/controllers/LetterInput.jsx`: + +```jsx +import React, { useCallback } from 'react'; + +export default function LetterInput({ highlighted, onNext, onPrev, inputRef, value, onChange, disabled, colorClass }) { + const handleKeyUp = useCallback((e) => { + if (disabled) return; + if (e.key === 'Backspace') { + onChange(''); + onPrev?.(); + } else if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) { + onChange(e.key.toUpperCase()); + onNext?.(); + } + }, [disabled, onChange, onNext, onPrev]); + + const classes = [ + 'letter-input', + highlighted ? 'letter-highlighted' : '', + colorClass || '', + ].filter(Boolean).join(' '); + + return ( + + {}} + disabled={disabled} + className={classes} + onKeyUp={handleKeyUp} + autoComplete="off" + /> + + ); +} +``` + +- [ ] **Step 2: Rewrite GameRow to accept lifted-state props** + +Replace the entire content of `assets/react/controllers/GameRow.jsx`: + +```jsx +import React, { useRef, useCallback, useMemo } from 'react'; +import LetterInput from './LetterInput'; +import ActorPopover from './ActorPopover'; + +function isLetter(ch) { + return /[a-zA-Z]/.test(ch); +} + +export default function GameRow({ + actorName, + pos, + colStart, + totalWidth, + hintType, + hintText, + playerLetters, + onLetterChange, + disabled, + revealedLetters, +}) { + const inputRefs = useRef([]); + const letters = actorName.split(''); + + const letterIndices = useMemo( + () => letters.reduce((acc, ch, i) => { if (isLetter(ch)) acc.push(i); return acc; }, []), + [actorName] + ); + + const setInputRef = useCallback((index) => (el) => { + inputRefs.current[index] = el; + }, []); + + const focusNextInput = useCallback((charIndex, direction) => { + const currentPos = letterIndices.indexOf(charIndex); + const nextPos = currentPos + direction; + if (nextPos >= 0 && nextPos < letterIndices.length) { + inputRefs.current[letterIndices[nextPos]]?.focus(); + } + }, [letterIndices]); + + return ( + + + + + {Array.from({ length: totalWidth + 1 }, (_, colIndex) => { + const charIndex = colIndex - colStart; + const isInRange = charIndex >= 0 && charIndex < letters.length; + + if (!isInRange) { + return ; + } + + const ch = letters[charIndex]; + + if (!isLetter(ch)) { + return ( + + {ch} + + ); + } + + const isHighlighted = charIndex === pos; + const playerValue = playerLetters?.[charIndex] || ''; + + let displayValue = playerValue; + let colorClass = ''; + + if (revealedLetters) { + const correctChar = revealedLetters[charIndex] || ''; + if (isHighlighted) { + // Highlighted cells are always correct on win (victory condition) + colorClass = 'letter-correct'; + displayValue = correctChar; + } else { + displayValue = correctChar; + if (playerValue !== '' && playerValue.toUpperCase() !== correctChar.toUpperCase()) { + colorClass = 'letter-wrong'; + } + } + } + + return ( + focusNextInput(charIndex, 1)} + onPrev={() => focusNextInput(charIndex, -1)} + value={displayValue} + onChange={(val) => onLetterChange(charIndex, val)} + disabled={disabled} + colorClass={colorClass} + /> + ); + })} + + ); +} +``` + +- [ ] **Step 3: Run the dev server to verify no crash** + +```bash +# In your local dev environment: +# Open the game page and verify the grid still renders and you can type letters +``` + +- [ ] **Step 4: Commit** + +```bash +git add assets/react/controllers/LetterInput.jsx assets/react/controllers/GameRow.jsx +git commit -m "feat: lift letter state in LetterInput and GameRow" +``` + +--- + +## Task 9: Victory detection and reveal in GameGrid + +**Files:** +- Modify: `assets/react/controllers/GameGrid.jsx` + +`GameGrid` holds all letter state, detects when every highlighted cell is filled, calls the check API, and on win applies the reveal state. + +- [ ] **Step 1: Rewrite GameGrid** + +Replace the entire content of `assets/react/controllers/GameGrid.jsx`: + +```jsx +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import GameRow from './GameRow'; +import ActorPopover from './ActorPopover'; +import VictoryCard from './VictoryCard'; + +export default function GameGrid({ grid, width, middle, gameId }) { + // letters[gridIndex][charIndex] = typed value + const [letters, setLetters] = useState(() => + Object.fromEntries(grid.map((_, i) => [i, {}])) + ); + // 'playing' | 'checking' | 'won' + const [gameState, setGameState] = useState('playing'); + const [wonData, setWonData] = useState(null); + + // Map grid indices of actor rows to their corresponding wonData.rows index + const wonRowsByGridIndex = useMemo(() => { + if (!wonData) return {}; + const result = {}; + let wonIdx = 0; + grid.forEach((row, gridIndex) => { + if (row.separator === undefined) { + result[gridIndex] = wonData.rows[wonIdx++]; + } + }); + return result; + }, [wonData, grid]); + + const handleLetterChange = useCallback((gridIndex, charIndex, value) => { + setLetters(prev => ({ + ...prev, + [gridIndex]: { ...prev[gridIndex], [charIndex]: value }, + })); + }, []); + + // Trigger check when all highlighted cells are filled + useEffect(() => { + if (gameState !== 'playing') return; + + const actorRows = grid + .map((row, i) => ({ row, i })) + .filter(({ row }) => row.separator === undefined); + + const allFilled = actorRows.every(({ row, i }) => + (letters[i]?.[row.pos] || '').length > 0 + ); + + if (!allFilled) return; + + const highlightedLetters = actorRows.map(({ row, i }) => letters[i]?.[row.pos] || ''); + + setGameState('checking'); + + fetch(`/api/game/${gameId}/check`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ letters: highlightedLetters }), + }) + .then(r => r.json()) + .then(data => { + if (data.won) { + setWonData(data); + setGameState('won'); + } else { + setGameState('playing'); + } + }) + .catch(() => setGameState('playing')); + }, [letters, gameState, grid, gameId]); + + const isWon = gameState === 'won'; + + return ( +
+ {isWon && wonData && ( + + )} + + + {grid.map((row, gridIndex) => { + if (row.separator !== undefined) { + return ( + + + {Array.from({ length: width - middle }, (_, i) => ( + + ); + } + + const wonRow = wonRowsByGridIndex[gridIndex] ?? null; + + return ( + + handleLetterChange(gridIndex, charIndex, value) + } + disabled={isWon} + revealedLetters={wonRow ? wonRow.letters : null} + /> + ); + })} + +
+ {Array.from({ length: middle }, (_, i) => ( + + ))} + + {row.separator === ' ' ? '' : row.separator} + + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/react/controllers/GameGrid.jsx +git commit -m "feat: victory detection and reveal logic in GameGrid" +``` + +--- + +## Task 10: VictoryCard component + +**Files:** +- Create: `assets/react/controllers/VictoryCard.jsx` + +- [ ] **Step 1: Create VictoryCard** + +Create `assets/react/controllers/VictoryCard.jsx`: + +```jsx +import React from 'react'; + +export default function VictoryCard({ actorName, actorPhotoUrl }) { + return ( +
+
+ {actorPhotoUrl ? ( + {actorName} + ) : ( +
+ + + + +
+ )} +
+
+

C'était

+

{actorName}

+ + Nouvelle partie + +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/react/controllers/VictoryCard.jsx +git commit -m "feat: add VictoryCard component" +``` + +--- + +## Task 11: CSS for victory states + +**Files:** +- Modify: `assets/styles/app.css` + +- [ ] **Step 1: Add victory CSS** + +At the end of `assets/styles/app.css`, add: + +```css +/* ── Victory states ── */ + +.letter-correct { + background-color: #dcfce7; + border-color: #16a34a; + color: #15803d; +} + +.letter-wrong { + background-color: #fee2e2; + border-color: #dc2626; + color: #dc2626; +} + +.letter-input:disabled { + opacity: 1; + cursor: default; +} + +/* ── Victory card ── */ + +.victory-card { + display: flex; + align-items: center; + gap: 20px; + background: var(--surface); + border: 1px solid var(--border-warm); + border-radius: var(--radius-lg); + padding: 20px 28px; + margin: 0 auto 32px; + width: fit-content; + max-width: 100%; + box-shadow: 0 4px 24px var(--shadow-warm); +} + +.victory-card__photo-wrapper { + flex-shrink: 0; +} + +.victory-card__photo { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--border-warm); +} + +.victory-card__photo-placeholder { + width: 80px; + height: 80px; + border-radius: 50%; + background: var(--surface-tint); + border: 2px solid var(--border-warm); + display: flex; + align-items: center; + justify-content: center; +} + +.victory-card__body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.victory-card__label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin: 0; +} + +.victory-card__name { + font-family: 'Fraunces', serif; + font-size: 24px; + font-weight: 700; + color: var(--text); + margin: 0 0 8px; + letter-spacing: -0.3px; +} + +.victory-card__btn { + align-self: flex-start; + padding: 8px 18px; + font-size: 14px; +} +``` + +- [ ] **Step 2: Run tests to confirm nothing broke** + +```bash +make test +``` + +Expected: all PASS + +- [ ] **Step 3: Commit** + +```bash +git add assets/styles/app.css +git commit -m "feat: add victory card and letter state CSS" +``` + +--- + +## Post-implementation checklist + +- [ ] Run the backfill command in your dev environment: `php bin/console app:actor:backfill-profile-path` +- [ ] Start a game, fill all highlighted cells with correct letters → verify victory card appears with photo +- [ ] Start a game, fill all highlighted cells with wrong letters → verify `won: false` is returned and grid stays interactive +- [ ] Fill highlighted cells partially → verify no premature API call +- [ ] Verify the "Nouvelle partie" button returns to the start screen