# 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.