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 %}
+
+
+
+
No account yet? Register
+
+{% 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) }}
+
+
Already have an account? Log in
+
+{% 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.