diff --git a/docs/superpowers/plans/2026-04-01-game-config-panel.md b/docs/superpowers/plans/2026-04-01-game-config-panel.md new file mode 100644 index 0000000..a1b597b --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-game-config-panel.md @@ -0,0 +1,1176 @@ +# Game Configuration Panel 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 configuration panel above the "Commencer une partie" button that lets players filter actors by watched films, choose hint types, and select which award categories to use. + +**Architecture:** New repository method to query eligible AwardTypes, config parameters passed through the existing POST form to `GameGridProvider::generate()` which gains retry logic. A Stimulus controller handles UI interactions (toggle visibility, prevent last-unchecked). CSS-only toggle switch. + +**Tech Stack:** Symfony 8 / Twig / Stimulus.js / CSS + +--- + +### File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Modify | `src/Repository/AwardTypeRepository.php` | Add `findWithMinActors()` query | +| Modify | `src/Repository/ActorRepository.php` | Add `findOneRandomInWatchedFilms()` method | +| Modify | `src/Repository/AwardRepository.php` | Add `findOneRandomByActorAndTypes()` method | +| Modify | `src/Controller/HomepageController.php` | Pass eligible AwardTypes to template | +| Modify | `src/Controller/GameController.php` | Extract config from POST, pass to generator | +| Modify | `src/Provider/GameGridProvider.php` | Accept config, filter hints, retry logic | +| Modify | `templates/homepage/index.html.twig` | Render config panel in form | +| Create | `assets/controllers/game_config_controller.js` | Stimulus controller for UI interactions | +| Modify | `assets/styles/app.css` | Toggle switch, config panel styles | +| Modify | `tests/Provider/GameGridProviderTest.php` | Test hint filtering and retry logic | + +--- + +### Task 1: AwardTypeRepository — `findWithMinActors()` + +**Files:** +- Modify: `src/Repository/AwardTypeRepository.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Repository/AwardTypeRepositoryTest.php`: + +```php +em = self::getContainer()->get(EntityManagerInterface::class); + $this->repo = self::getContainer()->get(AwardTypeRepository::class); + + // Clean slate + $this->em->createQuery('DELETE FROM App\Entity\Award')->execute(); + $this->em->createQuery('DELETE FROM App\Entity\AwardType')->execute(); + $this->em->createQuery('DELETE FROM App\Entity\Actor')->execute(); + } + + public function testFindWithMinActorsFiltersCorrectly(): void + { + // Create AwardType with 3 distinct actors (below threshold of 5) + $smallType = new AwardType(); + $smallType->setName('Small Award')->setPattern('small'); + $this->em->persist($smallType); + + // Create AwardType with 6 distinct actors (above threshold) + $bigType = new AwardType(); + $bigType->setName('Big Award')->setPattern('big'); + $this->em->persist($bigType); + + for ($i = 0; $i < 6; $i++) { + $actor = new Actor(); + $actor->setName("Actor $i"); + $this->em->persist($actor); + + $award = new Award(); + $award->setName("Big Award $i"); + $award->setActor($actor); + $award->setAwardType($bigType); + $this->em->persist($award); + + if ($i < 3) { + $awardSmall = new Award(); + $awardSmall->setName("Small Award $i"); + $awardSmall->setActor($actor); + $awardSmall->setAwardType($smallType); + $this->em->persist($awardSmall); + } + } + + $this->em->flush(); + + $result = $this->repo->findWithMinActors(5); + + $this->assertCount(1, $result); + $this->assertSame('Big Award', $result[0]->getName()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php bin/phpunit tests/Repository/AwardTypeRepositoryTest.php -v` +Expected: FAIL — method `findWithMinActors` does not exist. + +- [ ] **Step 3: Implement `findWithMinActors()`** + +In `src/Repository/AwardTypeRepository.php`, add this method after the existing `findAll()`: + +```php +/** @return list */ +public function findWithMinActors(int $minActors): array +{ + return $this->createQueryBuilder('at') + ->join('at.awards', 'a') + ->groupBy('at.id') + ->having('COUNT(DISTINCT a.actor) >= :minActors') + ->setParameter('minActors', $minActors) + ->orderBy('at.name', 'ASC') + ->getQuery() + ->getResult(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php bin/phpunit tests/Repository/AwardTypeRepositoryTest.php -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Repository/AwardTypeRepository.php tests/Repository/AwardTypeRepositoryTest.php +git commit -m "feat: add AwardTypeRepository::findWithMinActors()" +``` + +--- + +### Task 2: ActorRepository — `findOneRandomInWatchedFilms()` + +**Files:** +- Modify: `src/Repository/ActorRepository.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Repository/ActorRepositoryTest.php`: + +```php +em = self::getContainer()->get(EntityManagerInterface::class); + $this->repo = self::getContainer()->get(ActorRepository::class); + } + + public function testFindOneRandomInWatchedFilmsReturnsOnlyWatchedActors(): void + { + // Create user + $user = new User(); + $user->setEmail('test-watched-' . uniqid() . '@example.com'); + $user->setPassword('test'); + $this->em->persist($user); + + // Actor in a watched film + $watchedActor = new Actor(); + $watchedActor->setName('Watched Actor'); + $watchedActor->setPopularity(10.0); + $this->em->persist($watchedActor); + + $movie = new Movie(); + $movie->setTmdbId(99990); + $movie->setLtbxdRef('watched-test'); + $movie->setTitle('Watched Film'); + $this->em->persist($movie); + + $role = new MovieRole(); + $role->setActor($watchedActor); + $role->setMovie($movie); + $role->setCharacter('Hero'); + $this->em->persist($role); + + $userMovie = new UserMovie(); + $userMovie->setUser($user); + $userMovie->setMovie($movie); + $this->em->persist($userMovie); + + // Actor NOT in any watched film + $unwatchedActor = new Actor(); + $unwatchedActor->setName('Unwatched Actor'); + $unwatchedActor->setPopularity(10.0); + $this->em->persist($unwatchedActor); + + $movie2 = new Movie(); + $movie2->setTmdbId(99991); + $movie2->setLtbxdRef('unwatched-test'); + $movie2->setTitle('Unwatched Film'); + $this->em->persist($movie2); + + $role2 = new MovieRole(); + $role2->setActor($unwatchedActor); + $role2->setMovie($movie2); + $role2->setCharacter('Villain'); + $this->em->persist($role2); + + $this->em->flush(); + + // Run many times to be sure — should always return watchedActor + for ($i = 0; $i < 10; $i++) { + $result = $this->repo->findOneRandomInWatchedFilms($user, 0, 'w'); + $this->assertNotNull($result); + $this->assertSame($watchedActor->getId(), $result->getId()); + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php bin/phpunit tests/Repository/ActorRepositoryTest.php -v` +Expected: FAIL — method `findOneRandomInWatchedFilms` does not exist. + +- [ ] **Step 3: Implement `findOneRandomInWatchedFilms()`** + +In `src/Repository/ActorRepository.php`, add after `findOneRandom()`: + +```php +public function findOneRandomInWatchedFilms(User $user, ?float $popularity = null, ?string $char = null): ?Actor +{ + $qb = $this->createQueryBuilder('a') + ->join('a.movieRoles', 'mr') + ->join('mr.movie', 'm') + ->join(UserMovie::class, 'um', 'WITH', 'um.movie = m AND um.user = :user') + ->setParameter('user', $user); + + if (!empty($popularity)) { + $qb->andWhere('a.popularity >= :popularity') + ->setParameter('popularity', $popularity); + } + + if (!empty($char)) { + $qb->andWhere('a.name LIKE :name') + ->setParameter('name', '%' . $char . '%'); + } + + return $qb + ->orderBy('RANDOM()') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); +} +``` + +Add the missing import at the top of the file: + +```php +use App\Entity\User; +use App\Entity\UserMovie; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php bin/phpunit tests/Repository/ActorRepositoryTest.php -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Repository/ActorRepository.php tests/Repository/ActorRepositoryTest.php +git commit -m "feat: add ActorRepository::findOneRandomInWatchedFilms()" +``` + +--- + +### Task 3: AwardRepository — `findOneRandomByActorAndTypes()` + +**Files:** +- Modify: `src/Repository/AwardRepository.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Repository/AwardRepositoryTest.php`: + +```php +em = self::getContainer()->get(EntityManagerInterface::class); + $this->repo = self::getContainer()->get(AwardRepository::class); + } + + public function testFindOneRandomByActorAndTypesFiltersCorrectly(): void + { + $actor = new Actor(); + $actor->setName('Award Actor Test'); + $this->em->persist($actor); + + $oscarType = new AwardType(); + $oscarType->setName('Oscar')->setPattern('oscar'); + $this->em->persist($oscarType); + + $globeType = new AwardType(); + $globeType->setName('Golden Globe')->setPattern('globe'); + $this->em->persist($globeType); + + $oscar = new Award(); + $oscar->setName('Best Actor Oscar'); + $oscar->setActor($actor); + $oscar->setAwardType($oscarType); + $this->em->persist($oscar); + + $globe = new Award(); + $globe->setName('Best Actor Globe'); + $globe->setActor($actor); + $globe->setAwardType($globeType); + $this->em->persist($globe); + + $this->em->flush(); + + // Filter to only Oscar type + for ($i = 0; $i < 10; $i++) { + $result = $this->repo->findOneRandomByActorAndTypes($actor->getId(), [$oscarType->getId()]); + $this->assertNotNull($result); + $this->assertSame('Best Actor Oscar', $result->getName()); + } + + // Null awardTypeIds = all types allowed + $result = $this->repo->findOneRandomByActorAndTypes($actor->getId(), null); + $this->assertNotNull($result); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php bin/phpunit tests/Repository/AwardRepositoryTest.php -v` +Expected: FAIL — method `findOneRandomByActorAndTypes` does not exist. + +- [ ] **Step 3: Implement `findOneRandomByActorAndTypes()`** + +In `src/Repository/AwardRepository.php`, add after `findOneRandomByActor()`: + +```php +/** + * @param list|null $awardTypeIds null means all types + */ +public function findOneRandomByActorAndTypes(int $actorId, ?array $awardTypeIds): ?Award +{ + $qb = $this->createQueryBuilder('a') + ->andWhere('a.actor = :actorId') + ->setParameter('actorId', $actorId); + + if ($awardTypeIds !== null) { + $qb->andWhere('a.awardType IN (:typeIds)') + ->setParameter('typeIds', $awardTypeIds); + } + + return $qb + ->orderBy('RANDOM()') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php bin/phpunit tests/Repository/AwardRepositoryTest.php -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Repository/AwardRepository.php tests/Repository/AwardRepositoryTest.php +git commit -m "feat: add AwardRepository::findOneRandomByActorAndTypes()" +``` + +--- + +### Task 4: GameGridProvider — accept config and add retry logic + +**Files:** +- Modify: `src/Provider/GameGridProvider.php` +- Modify: `tests/Provider/GameGridProviderTest.php` + +- [ ] **Step 1: Write the failing test for hint type filtering** + +Add to `tests/Provider/GameGridProviderTest.php`: + +```php +public function testGenerateHintRespectsAllowedTypes(): void +{ + $movieRoleRepo = $this->createMock(MovieRoleRepository::class); + $movieRoleRepo->method('findOneRandomByActor')->willReturn(null); + + $awardRepo = $this->createMock(AwardRepository::class); + $awardRepo->method('findOneRandomByActor')->willReturn(null); + $awardRepo->method('findOneRandomByActorAndTypes')->willReturn(null); + + $generator = new GameGridProvider( + $this->createMock(ActorRepository::class), + $movieRoleRepo, + $this->createMock(MovieRepository::class), + $awardRepo, + $this->createMock(EntityManagerInterface::class), + ); + + $actor = new Actor(); + $actor->setName('Test'); + + // Only allow 'award' type, but no awards exist → should return null + $method = new \ReflectionMethod($generator, 'generateHint'); + $result = $method->invoke($generator, $actor, ['award'], null); + + $this->assertNull($result); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php bin/phpunit tests/Provider/GameGridProviderTest.php --filter=testGenerateHintRespectsAllowedTypes -v` +Expected: FAIL — `generateHint` signature doesn't accept extra arguments. + +- [ ] **Step 3: Update `generateHint()` and `resolveHint()` signatures** + +In `src/Provider/GameGridProvider.php`, change `generateHint()`: + +```php +/** + * @param list $allowedTypes + * @param list|null $awardTypeIds + * @return array{type: string, data: string}|null + */ +private function generateHint(Actor $rowActor, array $allowedTypes = ['film', 'character', 'award'], ?array $awardTypeIds = null): ?array +{ + $types = $allowedTypes; + shuffle($types); + + foreach ($types as $type) { + $hint = $this->resolveHint($type, $rowActor, $awardTypeIds); + if ($hint !== null) { + return $hint; + } + } + + return null; +} +``` + +Change `resolveHint()`: + +```php +/** + * @param list|null $awardTypeIds + * @return array{type: string, data: string}|null + */ +private function resolveHint(string $type, Actor $rowActor, ?array $awardTypeIds = null): ?array +{ + switch ($type) { + case 'film': + $role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId()); + if ($role === null) { + return null; + } + return ['type' => 'film', 'data' => (string) $role->getMovie()->getId()]; + + case 'character': + $role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId()); + if ($role === null) { + return null; + } + return ['type' => 'character', 'data' => (string) $role->getId()]; + + case 'award': + $award = $this->awardRepository->findOneRandomByActorAndTypes($rowActor->getId(), $awardTypeIds); + if ($award === null) { + return null; + } + return ['type' => 'award', 'data' => (string) $award->getId()]; + } + + return null; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php bin/phpunit tests/Provider/GameGridProviderTest.php -v` +Expected: PASS (both old and new tests) + +- [ ] **Step 5: Update `generate()` to accept config and add retry logic** + +Replace the `generate()` method in `src/Provider/GameGridProvider.php`: + +```php +/** + * @param array{watchedOnly?: bool, hintTypes?: list, awardTypeIds?: list|null} $config + */ +public function generate(?User $user = null, array $config = []): ?Game +{ + $watchedOnly = $config['watchedOnly'] ?? false; + $hintTypes = $config['hintTypes'] ?? ['film', 'character', 'award']; + $awardTypeIds = $config['awardTypeIds'] ?? null; + + for ($attempt = 0; $attempt < 5; $attempt++) { + $game = $this->tryGenerate($user, $watchedOnly, $hintTypes, $awardTypeIds); + if ($game !== null) { + return $game; + } + } + + return null; +} + +/** + * @param list $hintTypes + * @param list|null $awardTypeIds + */ +private function tryGenerate(?User $user, bool $watchedOnly, array $hintTypes, ?array $awardTypeIds): ?Game +{ + if ($watchedOnly && $user !== null) { + $mainActor = $this->actorRepository->findOneRandomInWatchedFilms($user, 4); + } else { + $mainActor = $this->actorRepository->findOneRandom(4); + } + + if ($mainActor === null) { + return null; + } + + $game = new Game(); + $game->setMainActor($mainActor); + $game->setUser($user); + + $usedActors = [$mainActor->getId()]; + $rowOrder = 0; + + foreach (str_split(strtolower($mainActor->getName())) as $char) { + if (!preg_match('/[a-z]/', $char)) { + continue; + } + + $actor = null; + for ($try = 0; $try < 5; $try++) { + if ($watchedOnly && $user !== null) { + $candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char); + } else { + $candidate = $this->actorRepository->findOneRandom(4, $char); + } + + if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) { + $actor = $candidate; + break; + } + } + + if ($actor === null) { + return null; + } + + $usedActors[] = $actor->getId(); + + $row = new GameRow(); + $row->setActor($actor); + $row->setPosition(strpos(strtolower($actor->getName()), $char)); + $row->setRowOrder($rowOrder); + + $hint = $this->generateHint($actor, $hintTypes, $awardTypeIds); + if ($hint === null) { + return null; // Every row must have a hint + } + $row->setHintType($hint['type']); + $row->setHintData($hint['data']); + + $game->addRow($row); + ++$rowOrder; + } + + $this->em->persist($game); + $this->em->flush(); + + return $game; +} +``` + +- [ ] **Step 6: Run all tests** + +Run: `php bin/phpunit -v` +Expected: All PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/Provider/GameGridProvider.php tests/Provider/GameGridProviderTest.php +git commit -m "feat: GameGridProvider accepts config for hint types, watched-only, and retry logic" +``` + +--- + +### Task 5: GameController — extract config from POST + +**Files:** +- Modify: `src/Controller/GameController.php` + +- [ ] **Step 1: Update the `start()` method** + +Replace the `$game = $generator->generate($user);` line (line 45) and surrounding logic. The full updated `start()` method: + +```php +#[Route('/game/start', name: 'app_game_start', methods: ['POST'])] +public function start( + Request $request, + GameGridProvider $generator, + GameRepository $gameRepository, +): Response { + $this->validateCsrfToken('game_start', $request); + + /** @var User|null $user */ + $user = $this->getUser(); + + // Check no game already in progress + if ($user) { + $existing = $gameRepository->findActiveForUser($user); + } else { + $gameId = $request->getSession()->get('current_game_id'); + $existing = $gameId ? $gameRepository->find($gameId) : null; + if ($existing && $existing->getStatus() !== Game::STATUS_IN_PROGRESS) { + $existing = null; + } + } + + if ($existing) { + return $this->redirectToRoute('app_homepage'); + } + + // Build config from form parameters + $config = []; + + if ($user && $request->request->getBoolean('watched_only')) { + $config['watchedOnly'] = true; + } + + $hintTypes = []; + if ($request->request->getBoolean('hint_film', true)) { + $hintTypes[] = 'film'; + } + if ($request->request->getBoolean('hint_character', true)) { + $hintTypes[] = 'character'; + } + if ($request->request->getBoolean('hint_award', true)) { + $hintTypes[] = 'award'; + } + if (empty($hintTypes)) { + $hintTypes = ['film', 'character', 'award']; + } + $config['hintTypes'] = $hintTypes; + + /** @var list $awardTypeIds */ + $awardTypeIds = $request->request->all('award_types'); + if (!empty($awardTypeIds) && in_array('award', $hintTypes)) { + $config['awardTypeIds'] = array_map('intval', $awardTypeIds); + } + + $game = $generator->generate($user, $config); + + if ($game === null) { + $this->addFlash('error', 'Impossible de générer une grille avec ces paramètres. Essayez avec des critères moins restrictifs.'); + return $this->redirectToRoute('app_homepage'); + } + + if (!$user) { + $request->getSession()->set('current_game_id', $game->getId()); + } + + return $this->redirectToRoute('app_homepage'); +} +``` + +- [ ] **Step 2: Run existing tests to check nothing is broken** + +Run: `php bin/phpunit -v` +Expected: All PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/Controller/GameController.php +git commit -m "feat: extract game config from POST and pass to generator" +``` + +--- + +### Task 6: HomepageController — pass AwardTypes to template + +**Files:** +- Modify: `src/Controller/HomepageController.php` + +- [ ] **Step 1: Add AwardTypeRepository and pass eligible types to template** + +Update the `index()` method. Add `AwardTypeRepository` as a parameter and pass the eligible types when rendering the start screen: + +```php +use App\Repository\AwardTypeRepository; +``` + +Change the method signature to: + +```php +public function index( + Request $request, + GameRepository $gameRepository, + GameGridProvider $gridGenerator, + AwardTypeRepository $awardTypeRepository, +): Response { +``` + +Change the no-game render block (around line 43) from: + +```php +return $this->render('homepage/index.html.twig', [ + 'game' => null, +]); +``` + +To: + +```php +return $this->render('homepage/index.html.twig', [ + 'game' => null, + 'awardTypes' => $awardTypeRepository->findWithMinActors(5), +]); +``` + +- [ ] **Step 2: Run tests** + +Run: `php bin/phpunit -v` +Expected: All PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/Controller/HomepageController.php +git commit -m "feat: pass eligible AwardTypes to homepage template" +``` + +--- + +### Task 7: Twig template — render config panel + +**Files:** +- Modify: `templates/homepage/index.html.twig` + +- [ ] **Step 1: Add the config panel HTML inside the form** + +Replace the `{% else %}` block (lines 57-76) with: + +```twig + {% else %} +
+
+ + +
+ {% if app.user %} +
+ +
+ {% endif %} + +
+
Paramètres des indices
+
+ + + +
+ +
+
Récompenses
+
+ + {% for awardType in awardTypes %} + + {% endfor %} +
+
+
+
+ + +
+
+ {% if not app.user %} + + {% endif %} + + {% for message in app.flashes('error') %} +
{{ message }}
+ {% endfor %} + + +
+ {% endif %} +``` + +- [ ] **Step 2: Verify the page loads in the browser** + +Run: `symfony serve` (or existing dev server) and visit the homepage. Verify the config panel renders above the button. + +- [ ] **Step 3: Commit** + +```bash +git add templates/homepage/index.html.twig +git commit -m "feat: render game config panel in start form" +``` + +--- + +### Task 8: Stimulus controller — `game_config_controller.js` + +**Files:** +- Create: `assets/controllers/game_config_controller.js` + +- [ ] **Step 1: Create the Stimulus controller** + +```js +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['hintType', 'awardSection', 'allAwards', 'awardType']; + + toggleAwardSection() { + const awardChecked = this.hintTypeTargets.find( + (el) => el.name === 'hint_award' + )?.checked; + + this.awardSectionTarget.style.display = awardChecked ? '' : 'none'; + } + + toggleAllAwards() { + const checked = this.allAwardsTarget.checked; + this.awardTypeTargets.forEach((el) => { + el.checked = checked; + }); + } + + syncAllAwards() { + const allChecked = this.awardTypeTargets.every((el) => el.checked); + this.allAwardsTarget.checked = allChecked; + } + + hintTypeTargetConnected() { + this.#bindMinOneChecked(); + } + + #bindMinOneChecked() { + this.hintTypeTargets.forEach((el) => { + el.addEventListener('change', () => { + const checked = this.hintTypeTargets.filter((e) => e.checked); + if (checked.length === 0) { + el.checked = true; + } + this.toggleAwardSection(); + }); + }); + } +} +``` + +- [ ] **Step 2: Verify in browser** + +Visit homepage, test: +- Toggle "Récompense" off → award list disappears +- Toggle "Récompense" on → award list appears +- Uncheck all AwardTypes → "Toutes" unchecked +- Check "Toutes" → all checked +- Try to uncheck the last hint type → stays checked + +- [ ] **Step 3: Commit** + +```bash +git add assets/controllers/game_config_controller.js +git commit -m "feat: add Stimulus game-config controller" +``` + +--- + +### Task 9: CSS — config panel styles and toggle switch + +**Files:** +- Modify: `assets/styles/app.css` + +- [ ] **Step 1: Add config panel CSS** + +Add the following CSS after the `.btn-start` block (after line 733 in `app.css`): + +```css +/* ── Game config panel ── */ + +.config-panel { + width: 100%; + max-width: 360px; + margin-bottom: 24px; +} + +.config-section { + padding: 12px 0; +} + +.config-section + .config-section { + border-top: 1px solid var(--border); +} + +.config-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 10px; +} + +.config-section-subtitle { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + margin: 10px 0 6px; +} + +/* Toggle switch */ + +.config-toggle { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; +} + +.config-toggle-label { + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.toggle-switch { + appearance: none; + width: 40px; + height: 22px; + background: var(--border); + border-radius: 100px; + position: relative; + cursor: pointer; + transition: background 0.2s; +} + +.toggle-switch::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: white; + border-radius: 50%; + transition: transform 0.2s; +} + +.toggle-switch:checked { + background: var(--orange); +} + +.toggle-switch:checked::before { + transform: translateX(18px); +} + +/* Hint type checkboxes */ + +.config-hint-types { + display: flex; + gap: 16px; +} + +.config-checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text); + cursor: pointer; +} + +.config-checkbox input[type="checkbox"] { + accent-color: var(--orange); +} + +/* Award type list */ + +.config-award-types { + margin-top: 4px; +} + +.config-award-list { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + max-height: 150px; + overflow-y: auto; + padding: 6px 0; +} + +.config-award-list .config-checkbox { + padding: 4px 12px; +} + +.config-award-list .config-checkbox:first-child { + padding-bottom: 6px; + margin-bottom: 2px; + border-bottom: 1px solid var(--border); +} + +/* Flash messages */ + +.flash-error { + margin-top: 16px; + padding: 10px 16px; + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; + border-radius: var(--radius-sm); + font-size: 13px; +} +``` + +- [ ] **Step 2: Verify in browser** + +Visit homepage and verify: +- Toggle switch slides with orange color when on +- Checkboxes are orange-accented +- Award list scrolls if content overflows +- Panel is centered and aligned above the start button +- Responsive on mobile widths + +- [ ] **Step 3: Commit** + +```bash +git add assets/styles/app.css +git commit -m "feat: add config panel CSS with toggle switch and award list styles" +``` + +--- + +### Task 10: Final integration test + +**Files:** +- None new — manual verification + +- [ ] **Step 1: Run all tests** + +Run: `php bin/phpunit -v` +Expected: All PASS + +- [ ] **Step 2: Manual integration test — anonymous user** + +1. Log out +2. Visit homepage +3. Verify: "Films vus" toggle is NOT shown +4. Verify: Three hint type checkboxes are shown, all checked +5. Verify: Award list is shown (since "Récompense" is checked) +6. Uncheck "Film" and "Rôle", try to uncheck "Récompense" → prevented +7. Click "Commencer une partie" → game starts + +- [ ] **Step 3: Manual integration test — authenticated user** + +1. Log in +2. Visit homepage +3. Verify: "Films vus" toggle IS shown +4. Enable "Films vus", uncheck "Film" hint type +5. Click "Commencer une partie" → game starts with only character/award hints +6. Verify hints match selected types + +- [ ] **Step 4: Manual integration test — error case** + +1. Log in, enable "Films vus" +2. Uncheck "Film" and "Rôle" (keep only "Récompense") +3. Select only a rare award type +4. Click "Commencer une partie" +5. If generation fails: verify flash error message appears + +- [ ] **Step 5: Commit any final fixes if needed** + +```bash +git add -A +git commit -m "fix: integration fixes for game config panel" +```