From 8af386bd5c9992db123e7406c9d1d0850b88e8ff Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Sat, 28 Mar 2026 13:06:42 +0100 Subject: [PATCH] docs: add implementation plan for auth + React frontend Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-28-auth-react-frontend.md | 1526 +++++++++++++++++ 1 file changed, 1526 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-auth-react-frontend.md diff --git a/docs/superpowers/plans/2026-03-28-auth-react-frontend.md b/docs/superpowers/plans/2026-03-28-auth-react-frontend.md new file mode 100644 index 0000000..9a671ae --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-auth-react-frontend.md @@ -0,0 +1,1526 @@ +# Auth + React Frontend 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 user authentication (login/register) and migrate the frontend from Asset Mapper to Vite + React via SymfonyUX, converting the Actorle game grid into interactive React components. + +**Architecture:** Symfony handles routing, sessions, and auth via Twig pages. Interactive game components are React, mounted in Twig via `{{ react_component() }}`. Vite replaces Asset Mapper for JS bundling. A dedicated Node Docker service runs the Vite dev server. + +**Tech Stack:** Symfony 8.0, FrankenPHP, PostgreSQL 16, Vite (pentatrion/vite-bundle), React 19 (symfony/ux-react), @floating-ui/react, Docker + +--- + +## File Structure + +### New Files +| File | Responsibility | +|------|---------------| +| `docker/node/Dockerfile` | Node 22 image for Vite dev server (dev) and asset build (prod) | +| `vite.config.js` | Vite configuration with React plugin and Symfony integration | +| `package.json` | npm dependencies (React, Vite, Floating UI) | +| `assets/bootstrap.js` | Stimulus app initialization (replaces `stimulus_bootstrap.js`) | +| `assets/react/controllers/GameGrid.jsx` | Root game grid component | +| `assets/react/controllers/GameRow.jsx` | Single actor row | +| `assets/react/controllers/LetterInput.jsx` | Single letter input with auto-focus | +| `assets/react/controllers/ActorPopover.jsx` | Actor info popover | +| `src/Entity/User.php` | User entity with email/password/roles | +| `src/Repository/UserRepository.php` | Doctrine repository for User | +| `src/Controller/SecurityController.php` | Login/logout routes | +| `src/Controller/RegistrationController.php` | Registration route | +| `src/Form/RegistrationType.php` | Registration form type | +| `templates/security/login.html.twig` | Login page | +| `templates/security/register.html.twig` | Registration page | + +### Modified Files +| File | Change | +|------|--------| +| `composer.json` | Remove `symfony/asset-mapper`, add `pentatrion/vite-bundle` + `symfony/ux-react` | +| `config/packages/security.yaml` | Full auth config with form_login | +| `config/packages/asset_mapper.yaml` | Deleted | +| `importmap.php` | Deleted | +| `assets/stimulus_bootstrap.js` | Deleted (replaced by `bootstrap.js`) | +| `assets/app.js` | Adjusted imports for Vite | +| `assets/controllers.json` | Add ux-react controller | +| `templates/base.html.twig` | Vite tags instead of importmap | +| `templates/homepage/index.html.twig` | Replace HTML table with `{{ react_component() }}` | +| `src/Controller/HomepageController.php` | Pass grid data as JSON-serializable props | +| `docker-compose.override.yaml` | Add node service for dev | +| `docker-compose.yaml` | Add node build for prod | +| `docker/app/Dockerfile` | Prod stage copies built JS assets | +| `Makefile` | Add node/npm targets | + +--- + +## Task 1: Remove Asset Mapper and Install Vite + React (Composer) + +**Files:** +- Modify: `composer.json` +- Delete: `importmap.php` +- Delete: `config/packages/asset_mapper.yaml` + +- [ ] **Step 1: Remove Asset Mapper package** + +Run inside the app container: +```bash +docker compose exec app composer remove symfony/asset-mapper +``` + +This removes the package from `composer.json`, deletes `config/packages/asset_mapper.yaml`, and updates the autoloader. + +- [ ] **Step 2: Remove the `importmap:install` auto-script from composer.json** + +Edit `composer.json` — in the `"auto-scripts"` section, remove the `"importmap:install": "symfony-cmd"` line. The result should be: +```json +"auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" +}, +``` + +- [ ] **Step 3: Delete importmap.php** + +```bash +rm importmap.php +``` + +- [ ] **Step 4: Install Vite bundle and UX React** + +```bash +docker compose exec app composer require pentatrion/vite-bundle symfony/ux-react +``` + +This will create `config/packages/pentatrion_vite.yaml` automatically. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore: remove asset-mapper, install vite-bundle and ux-react" +``` + +--- + +## Task 2: Setup npm and Vite Configuration + +**Files:** +- Create: `package.json` +- Create: `vite.config.js` +- Modify: `assets/app.js` +- Create: `assets/bootstrap.js` +- Delete: `assets/stimulus_bootstrap.js` +- Modify: `assets/controllers.json` + +- [ ] **Step 1: Create package.json** + +Create `package.json` at project root: +```json +{ + "name": "ltbxd-actorle", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@floating-ui/react": "^0.27", + "@hotwired/stimulus": "^3.2", + "@hotwired/turbo": "^7.3", + "@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets", + "@symfony/ux-react": "file:vendor/symfony/ux-react/assets", + "react": "^19.0", + "react-dom": "^19.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3", + "vite": "^6.0" + } +} +``` + +- [ ] **Step 2: Create vite.config.js** + +Create `vite.config.js` at project root: +```js +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import symfonyPlugin from 'vite-plugin-symfony'; + +export default defineConfig({ + plugins: [ + react(), + symfonyPlugin(), + ], + build: { + rollupOptions: { + input: { + app: './assets/app.js', + }, + }, + }, + server: { + host: '0.0.0.0', + port: 5173, + strictPort: true, + origin: 'http://localhost:5173', + }, +}); +``` + +- [ ] **Step 3: Create assets/bootstrap.js** + +Create `assets/bootstrap.js`: +```js +import { startStimulusApp } from '@symfony/stimulus-bundle'; +import { registerReactControllerComponents } from '@symfony/ux-react'; + +const app = startStimulusApp(); + +registerReactControllerComponents(require.context('./react/controllers', true, /\.(j|t)sx?$/)); +``` + +- [ ] **Step 4: Update assets/app.js** + +Replace the contents of `assets/app.js` with: +```js +import './bootstrap.js'; +import './styles/app.css'; +``` + +- [ ] **Step 5: Delete assets/stimulus_bootstrap.js** + +```bash +rm assets/stimulus_bootstrap.js +``` + +- [ ] **Step 6: Update assets/controllers.json** + +Replace `assets/controllers.json` with: +```json +{ + "controllers": { + "@symfony/ux-turbo": { + "turbo-core": { + "enabled": true, + "fetch": "eager" + }, + "mercure-turbo-stream": { + "enabled": false, + "fetch": "eager" + } + }, + "@symfony/ux-react": { + "react": { + "enabled": true, + "fetch": "eager" + } + } + }, + "entrypoints": [] +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add package.json vite.config.js assets/ +git commit -m "chore: configure vite, react, and stimulus bootstrap" +``` + +--- + +## Task 3: Update Twig Base Template for Vite + +**Files:** +- Modify: `templates/base.html.twig` + +- [ ] **Step 1: Replace importmap with Vite tags** + +Replace `templates/base.html.twig` with: +```twig + + + + + {% block title %}Welcome!{% endblock %} + + {% block stylesheets %} + {{ vite_entry_link_tags('app') }} + {% endblock %} + + {% block javascripts %} + {{ vite_entry_script_tags('app') }} + {% endblock %} + + + {% block body %}{% endblock %} + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add templates/base.html.twig +git commit -m "chore: switch base template from importmap to vite" +``` + +--- + +## Task 4: Docker Node Service + +**Files:** +- Create: `docker/node/Dockerfile` +- Modify: `docker-compose.override.yaml` +- Modify: `docker-compose.yaml` +- Modify: `docker/app/Dockerfile` +- Modify: `Makefile` + +- [ ] **Step 1: Create docker/node/Dockerfile** + +```bash +mkdir -p docker/node +``` + +Create `docker/node/Dockerfile`: +```dockerfile +FROM node:22-alpine AS base + +WORKDIR /app + +### +# Dev stage +### +FROM base AS dev + +# Dependencies are mounted via volume, install at startup +CMD ["sh", "-c", "npm install && npx vite"] + +### +# Build stage (used by app prod image) +### +FROM base AS build + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY assets/ ./assets/ +COPY vite.config.js ./ + +RUN npm run build +``` + +- [ ] **Step 2: Update docker-compose.override.yaml** + +Replace `docker-compose.override.yaml` with: +```yaml +services: + app: + build: + context: . + dockerfile: docker/app/Dockerfile + target: dev + environment: + APP_ENV: dev + volumes: + - .:/app + - vendor:/app/vendor + ports: + - "80:80" + + database: + ports: + - "0.0.0.0:5432:5432" + + node: + build: + context: . + dockerfile: docker/node/Dockerfile + target: dev + volumes: + - .:/app + - node_modules:/app/node_modules + ports: + - "5173:5173" + +volumes: + vendor: + node_modules: +``` + +- [ ] **Step 3: Update docker-compose.yaml prod to build JS assets** + +Replace `docker-compose.yaml` with: +```yaml +services: + app: + build: + context: . + dockerfile: docker/app/Dockerfile + target: prod + additional_contexts: + node-build: docker/node + image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:latest + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - caddy_data:/data + - caddy_config:/config + depends_on: + database: + condition: service_healthy + + database: + build: + context: docker/database + image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/database:latest + healthcheck: + test: ["CMD", "pg_isready", "-d", "app", "-U", "app"] + timeout: 5s + retries: 5 + start_period: 60s + volumes: + - database_data:/var/lib/postgresql/data:rw + +volumes: + database_data: + caddy_data: + caddy_config: +``` + +- [ ] **Step 4: Update docker/app/Dockerfile to copy built assets in prod** + +Replace `docker/app/Dockerfile` with: +```dockerfile +FROM dunglas/frankenphp:php8.4-alpine AS base + +RUN install-php-extensions \ + intl \ + opcache \ + pdo_pgsql \ + zip + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +### +# Dev stage +### +FROM base AS dev + +COPY composer.json composer.lock symfony.lock ./ +RUN composer install --no-scripts --no-autoloader --prefer-dist + +COPY . . +RUN composer dump-autoload + +ENV APP_ENV=dev \ + SERVER_NAME=":80" \ + POSTGRES_HOST=database \ + POSTGRES_PORT=5432 \ + POSTGRES_VERSION=16 \ + POSTGRES_DB=app \ + POSTGRES_USER=app \ + POSTGRES_PASSWORD=pwd + +### +# Node build stage (for prod assets) +### +FROM node:22-alpine AS node-build + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY assets/ ./assets/ +COPY vite.config.js ./ + +RUN npm run build + +### +# Prod stage +### +FROM base AS prod + +COPY composer.json composer.lock symfony.lock ./ +RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist + +COPY . . + +# Copy Vite-built assets +COPY --from=node-build /app/public/build /app/public/build + +RUN APP_ENV=prod composer dump-autoload --classmap-authoritative \ + && APP_ENV=prod composer run-script post-install-cmd \ + && chown -R www-data:www-data var/ + +ENV APP_ENV=prod \ + SERVER_NAME=localhost \ + POSTGRES_HOST=database \ + POSTGRES_PORT=5432 \ + POSTGRES_VERSION=16 \ + POSTGRES_DB=app \ + POSTGRES_USER=app \ + POSTGRES_PASSWORD=pwd +``` + +- [ ] **Step 5: Add npm targets to Makefile** + +Append these targets to the `Makefile`, before the `help` target: +```makefile +node\:shell: ## Ouvre un shell dans le conteneur node + docker compose exec node sh + +node\:install: ## Installe les dépendances npm + docker compose exec node npm install + +node\:build: ## Build les assets pour la production + docker compose exec node npm run build +``` + +- [ ] **Step 6: Add .gitignore entries** + +Append to `.gitignore`: +``` +/node_modules/ +/public/build/ +``` + +- [ ] **Step 7: Verify dev environment starts** + +```bash +docker compose up --build -d +``` + +Wait for all services to be healthy. Check that: +- `http://localhost` responds (FrankenPHP) — may show Twig errors until React components exist, that's expected +- `http://localhost:5173` responds (Vite dev server) + +- [ ] **Step 8: Commit** + +```bash +git add docker/ docker-compose.yaml docker-compose.override.yaml Makefile .gitignore +git commit -m "chore: add node docker service for vite dev server and prod build" +``` + +--- + +## Task 5: User Entity and Migration + +**Files:** +- Create: `src/Entity/User.php` +- Create: `src/Repository/UserRepository.php` +- Create: migration file (auto-generated) + +- [ ] **Step 1: Create src/Entity/User.php** + +```php + */ + #[ORM\Column] + private array $roles = []; + + #[ORM\Column] + private ?string $password = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** @return list */ + public function getRoles(): array + { + $roles = $this->roles; + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + /** @param list $roles */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + public function eraseCredentials(): void + { + } +} +``` + +- [ ] **Step 2: Create src/Repository/UserRepository.php** + +```php + + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } +} +``` + +- [ ] **Step 3: Generate migration** + +```bash +docker compose exec app php bin/console doctrine:migrations:diff --no-interaction +``` + +- [ ] **Step 4: Run migration** + +```bash +docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction +``` + +Expected: the `user` table is created in PostgreSQL. + +- [ ] **Step 5: Commit** + +```bash +git add src/Entity/User.php src/Repository/UserRepository.php migrations/ +git commit -m "feat: add User entity with email/password/roles" +``` + +--- + +## Task 6: Security Configuration + +**Files:** +- Modify: `config/packages/security.yaml` + +- [ ] **Step 1: Update security.yaml** + +Replace `config/packages/security.yaml` with: +```yaml +security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + + providers: + app_user_provider: + entity: + class: App\Entity\User + property: email + + firewalls: + dev: + pattern: ^/(_profiler|_wdt|assets|build)/ + security: false + main: + lazy: true + provider: app_user_provider + form_login: + login_path: app_login + check_path: app_login + default_target_path: / + logout: + path: app_logout + + access_control: + - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/register, roles: PUBLIC_ACCESS } + - { path: ^/, roles: ROLE_USER } + +when@test: + security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 + time_cost: 3 + memory_cost: 10 +``` + +- [ ] **Step 2: Verify config is valid** + +```bash +docker compose exec app php bin/console debug:config security +``` + +Expected: outputs the resolved security config without errors. + +- [ ] **Step 3: Commit** + +```bash +git add config/packages/security.yaml +git commit -m "feat: configure security with form_login and access control" +``` + +--- + +## Task 7: Login Controller and Template + +**Files:** +- Create: `src/Controller/SecurityController.php` +- Create: `templates/security/login.html.twig` + +- [ ] **Step 1: Create src/Controller/SecurityController.php** + +```php +getUser()) { + return $this->redirectToRoute('app_homepage'); + } + + return $this->render('security/login.html.twig', [ + 'last_username' => $authenticationUtils->getLastUsername(), + 'error' => $authenticationUtils->getLastAuthenticationError(), + ]); + } + + #[Route('/logout', name: 'app_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} +``` + +- [ ] **Step 2: Create templates/security/login.html.twig** + +```twig +{% extends 'base.html.twig' %} + +{% block title %}Log in{% endblock %} + +{% block body %} +
+

Log in

+ + {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + +
+ + + + + + + + + +
+ + +
+{% endblock %} +``` + +- [ ] **Step 3: Add the `app_homepage` route name to HomepageController** + +In `src/Controller/HomepageController.php`, update the Route attribute: +```php +#[Route('/', name: 'app_homepage')] +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Controller/SecurityController.php templates/security/login.html.twig src/Controller/HomepageController.php +git commit -m "feat: add login page with SecurityController" +``` + +--- + +## Task 8: Registration Controller, Form, and Template + +**Files:** +- Create: `src/Form/RegistrationType.php` +- Create: `src/Controller/RegistrationController.php` +- Create: `templates/security/register.html.twig` + +- [ ] **Step 1: Create src/Form/RegistrationType.php** + +```php +add('email', EmailType::class) + ->add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'mapped' => false, + 'first_options' => ['label' => 'Password'], + 'second_options' => ['label' => 'Confirm password'], + 'constraints' => [ + new NotBlank(message: 'Please enter a password.'), + new Length( + min: 6, + minMessage: 'Your password should be at least {{ limit }} characters.', + max: 4096, + ), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} +``` + +- [ ] **Step 2: Create src/Controller/RegistrationController.php** + +```php +getUser()) { + return $this->redirectToRoute('app_homepage'); + } + + $user = new User(); + $form = $this->createForm(RegistrationType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->setPassword( + $passwordHasher->hashPassword($user, $form->get('plainPassword')->getData()) + ); + + $entityManager->persist($user); + $entityManager->flush(); + + return $this->redirectToRoute('app_login'); + } + + return $this->render('security/register.html.twig', [ + 'registrationForm' => $form, + ]); + } +} +``` + +- [ ] **Step 3: Create templates/security/register.html.twig** + +```twig +{% extends 'base.html.twig' %} + +{% block title %}Register{% endblock %} + +{% block body %} +
+

Register

+ + {{ form_start(registrationForm) }} + {{ form_row(registrationForm.email) }} + {{ form_row(registrationForm.plainPassword.first) }} + {{ form_row(registrationForm.plainPassword.second) }} + + + {{ form_end(registrationForm) }} + + +
+{% endblock %} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Form/RegistrationType.php src/Controller/RegistrationController.php templates/security/register.html.twig +git commit -m "feat: add registration page with form validation" +``` + +--- + +## Task 9: Auth Styling + +**Files:** +- Modify: `assets/styles/app.css` + +- [ ] **Step 1: Add auth styles to app.css** + +Append to `assets/styles/app.css`: +```css +.auth-container { + max-width: 400px; + margin: 80px auto; + padding: 32px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.auth-container h1 { + margin: 0 0 24px; + text-align: center; +} + +.auth-container label { + display: block; + margin-bottom: 4px; + font-weight: 600; +} + +.auth-container input[type="email"], +.auth-container input[type="password"] { + width: 100%; + padding: 8px 12px; + margin-bottom: 16px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 16px; + box-sizing: border-box; +} + +.auth-container button[type="submit"] { + width: 100%; + padding: 10px; + background: #2563eb; + color: white; + border: none; + border-radius: 4px; + font-size: 16px; + cursor: pointer; +} + +.auth-container button[type="submit"]:hover { + background: #1d4ed8; +} + +.auth-error { + background: #fef2f2; + color: #dc2626; + padding: 12px; + border-radius: 4px; + margin-bottom: 16px; +} + +.auth-link { + text-align: center; + margin-top: 16px; +} + +.auth-link a { + color: #2563eb; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/styles/app.css +git commit -m "style: add auth page styling" +``` + +--- + +## Task 10: Smoke Test Auth Flow + +No new files — manual verification. + +- [ ] **Step 1: Rebuild and start the dev environment** + +```bash +docker compose up --build -d +``` + +- [ ] **Step 2: Verify unauthenticated redirect** + +Open `http://localhost` in a browser. Expected: redirect to `/login`. + +- [ ] **Step 3: Test registration** + +Go to `/register`, fill in email + password, submit. Expected: redirect to `/login`. + +- [ ] **Step 4: Test login** + +Log in with the credentials just created. Expected: redirect to `/` (homepage). The page may be broken (React components not yet built) — that's expected at this stage. The important thing is the redirect works and you're authenticated. + +- [ ] **Step 5: Test logout** + +Go to `/logout`. Expected: redirect to `/login`. + +--- + +## Task 11: GameGrid React Component (Placeholder) + +**Files:** +- Create: `assets/react/controllers/GameGrid.jsx` +- Modify: `templates/homepage/index.html.twig` +- Modify: `src/Controller/HomepageController.php` + +- [ ] **Step 1: Create assets/react/controllers/GameGrid.jsx** + +Start with a simple placeholder that renders the grid data as a static table, confirming React mounting works: + +```jsx +import React from 'react'; + +export default function GameGrid({ grid, width, middle }) { + return ( + + + {grid.map((row, rowIndex) => ( + + {Array.from({ length: width + 1 }, (_, colIndex) => { + const start = middle - row.pos; + const charIndex = colIndex - start; + const name = row.actorName; + const isInRange = charIndex >= 0 && charIndex < name.length; + const isHighlighted = charIndex === row.pos; + + return ( + + ); + })} + + ))} + +
+ {isInRange ? name[charIndex].toUpperCase() : ''} +
+ ); +} +``` + +- [ ] **Step 2: Update HomepageController to pass JSON-serializable props** + +Replace the return statement in `src/Controller/HomepageController.php`'s `index()` method. Change the entire method body to: + +```php +public function index(SerializerInterface $serializer): Response +{ + // Final actor to be guessed + $mainActor = $this->actorRepository->findOneRandom(4); + + // Actors for the grid + $actors = []; + $leftSize = 0; + $rightSize = 0; + foreach (str_split(strtolower($mainActor->getName())) as $char) { + if (!preg_match('/[a-z]/', $char)) { + continue; + } + + $tryFindActor = 0; + do { + $actor = $this->actorRepository->findOneRandom(4, $char); + ++$tryFindActor; + } while ( + $actor === $mainActor + || in_array($actor, array_map(fn ($actorMap) => $actorMap['actor'], $actors)) + || $tryFindActor < 5 + ); + + $actorData = [ + 'actor' => $actor, + 'pos' => strpos($actor->getName(), $char), + ]; + + if ($leftSize < $actorData['pos']) { + $leftSize = $actorData['pos']; + } + + $rightSizeActor = strlen($actor->getName()) - $actorData['pos'] - 1; + if ($rightSize < $rightSizeActor) { + $rightSize = $rightSizeActor; + } + + $actors[] = $actorData; + } + + // Predict grid size + $width = $rightSize + $leftSize + 1; + $middle = $leftSize; + + // Build JSON-serializable grid for React + $grid = array_map(fn (array $actorData) => [ + 'actorName' => $actorData['actor']->getName(), + 'actorId' => $actorData['actor']->getId(), + 'pos' => $actorData['pos'], + ], $actors); + + return $this->render('homepage/index.html.twig', [ + 'grid' => $grid, + 'width' => $width, + 'middle' => $middle, + ]); +} +``` + +- [ ] **Step 3: Update templates/homepage/index.html.twig** + +Replace `templates/homepage/index.html.twig` with: +```twig +{% extends 'base.html.twig' %} + +{% block body %} + {{ react_component('GameGrid', { + grid: grid, + width: width, + middle: middle, + }) }} +{% endblock %} +``` + +- [ ] **Step 4: Verify the React component renders** + +Rebuild and open `http://localhost` (after logging in). Expected: the same grid as before, but now rendered by React. Check the browser console for any errors. + +- [ ] **Step 5: Commit** + +```bash +git add assets/react/controllers/GameGrid.jsx templates/homepage/index.html.twig src/Controller/HomepageController.php +git commit -m "feat: render game grid as React component via SymfonyUX" +``` + +--- + +## Task 12: LetterInput Component + +**Files:** +- Create: `assets/react/controllers/LetterInput.jsx` + +- [ ] **Step 1: Create LetterInput.jsx** + +```jsx +import React, { useRef, useCallback } from 'react'; + +export default function LetterInput({ highlighted, onNext, onPrev, inputRef }) { + const handleKeyUp = useCallback((e) => { + if (e.key === 'Backspace') { + e.target.value = ''; + onPrev?.(); + } else if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) { + e.target.value = e.key.toUpperCase(); + onNext?.(); + } + }, [onNext, onPrev]); + + return ( + + + + ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/react/controllers/LetterInput.jsx +git commit -m "feat: add LetterInput component with auto-focus navigation" +``` + +--- + +## Task 13: ActorPopover Component + +**Files:** +- Create: `assets/react/controllers/ActorPopover.jsx` + +- [ ] **Step 1: Create ActorPopover.jsx** + +```jsx +import React, { useState } from 'react'; +import { useFloating, useClick, useDismiss, useInteractions, offset, flip, shift } from '@floating-ui/react'; + +export default function ActorPopover({ actorName }) { + const [isOpen, setIsOpen] = useState(false); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [offset(8), flip(), shift()], + placement: 'top', + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]); + + return ( + <> + + {isOpen && ( +
+ {actorName} +
+ )} + + ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/react/controllers/ActorPopover.jsx +git commit -m "feat: add ActorPopover component with floating-ui" +``` + +--- + +## Task 14: GameRow Component + +**Files:** +- Create: `assets/react/controllers/GameRow.jsx` + +- [ ] **Step 1: Create GameRow.jsx** + +```jsx +import React, { useRef, useCallback } from 'react'; +import LetterInput from './LetterInput'; +import ActorPopover from './ActorPopover'; + +export default function GameRow({ actorName, pos, colStart, totalWidth }) { + const inputRefs = useRef([]); + + const setInputRef = useCallback((index) => (el) => { + inputRefs.current[index] = el; + }, []); + + const focusInput = useCallback((index) => { + inputRefs.current[index]?.focus(); + }, []); + + const letters = actorName.split(''); + + return ( + + {Array.from({ length: totalWidth + 1 }, (_, colIndex) => { + const charIndex = colIndex - colStart; + const isInRange = charIndex >= 0 && charIndex < letters.length; + + if (!isInRange) { + return ; + } + + return ( + focusInput(charIndex + 1)} + onPrev={() => focusInput(charIndex - 1)} + /> + ); + })} + + + + + ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/react/controllers/GameRow.jsx +git commit -m "feat: add GameRow component composing LetterInput and ActorPopover" +``` + +--- + +## Task 15: Integrate GameRow into GameGrid + +**Files:** +- Modify: `assets/react/controllers/GameGrid.jsx` + +- [ ] **Step 1: Update GameGrid to use GameRow** + +Replace `assets/react/controllers/GameGrid.jsx` with: + +```jsx +import React from 'react'; +import GameRow from './GameRow'; + +export default function GameGrid({ grid, width, middle }) { + return ( + + + {grid.map((row, rowIndex) => ( + + ))} + +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/react/controllers/GameGrid.jsx +git commit -m "feat: integrate GameRow into GameGrid" +``` + +--- + +## Task 16: Game Grid Styling + +**Files:** +- Modify: `assets/styles/app.css` + +- [ ] **Step 1: Add game grid styles** + +Replace the existing `#actors td` rule in `assets/styles/app.css` with: + +```css +#actors { + border-collapse: collapse; + margin: 40px auto; +} + +#actors td { + width: 32px; + height: 32px; + text-align: center; + vertical-align: middle; +} + +.letter-input { + width: 32px; + height: 32px; + text-align: center; + font-size: 16px; + font-weight: bold; + border: 2px solid #d1d5db; + border-radius: 4px; + text-transform: uppercase; + padding: 0; + box-sizing: border-box; +} + +.letter-input:focus { + outline: none; + border-color: #2563eb; +} + +.letter-highlighted { + background-color: #fecaca; + border-color: #dc2626; +} + +.popover-trigger { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid #d1d5db; + background: white; + cursor: pointer; + font-size: 14px; + color: #6b7280; +} + +.popover-trigger:hover { + background: #f3f4f6; +} + +.actor-popover { + background: white; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 12px 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + font-size: 14px; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/styles/app.css +git commit -m "style: add game grid and popover styling" +``` + +--- + +## Task 17: End-to-End Smoke Test + +No new files — manual verification. + +- [ ] **Step 1: Rebuild everything** + +```bash +docker compose down && docker compose up --build -d +``` + +- [ ] **Step 2: Test full flow** + +1. Open `http://localhost` — should redirect to `/login` +2. Click "Register" link — should go to `/register` +3. Create an account (email + password) +4. Log in with those credentials +5. Homepage shows the Actorle grid with interactive letter inputs +6. Type a letter in an input — cursor moves to next input +7. Press Backspace — cursor moves to previous input +8. Click the `?` button on a row — popover appears with actor name +9. Click outside popover — it closes +10. Go to `/logout` — redirected to `/login` + +- [ ] **Step 3: Check browser console** + +Open DevTools console. Expected: no JavaScript errors. Vite HMR should be connected.