Files
ltbxd-actorle/docs/superpowers/plans/2026-03-28-auth-react-frontend.md
thibaud-leclere 8af386bd5c docs: add implementation plan for auth + React frontend
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:06:42 +01:00

38 KiB

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:

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:

"auto-scripts": {
    "cache:clear": "symfony-cmd",
    "assets:install %PUBLIC_DIR%": "symfony-cmd"
},
  • Step 3: Delete importmap.php
rm importmap.php
  • Step 4: Install Vite bundle and UX React
docker compose exec app composer require pentatrion/vite-bundle symfony/ux-react

This will create config/packages/pentatrion_vite.yaml automatically.

  • Step 5: Commit
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:

{
  "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:

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:

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:

import './bootstrap.js';
import './styles/app.css';
  • Step 5: Delete assets/stimulus_bootstrap.js
rm assets/stimulus_bootstrap.js
  • Step 6: Update assets/controllers.json

Replace assets/controllers.json with:

{
    "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
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:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
        {% block stylesheets %}
            {{ vite_entry_link_tags('app') }}
        {% endblock %}

        {% block javascripts %}
            {{ vite_entry_script_tags('app') }}
        {% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
    </body>
</html>
  • Step 2: Commit
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

mkdir -p docker/node

Create docker/node/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:

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:

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:

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:

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

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

declare(strict_types=1);

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    private ?string $email = null;

    /** @var list<string> */
    #[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<string> */
    public function getRoles(): array
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    /** @param list<string> $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

declare(strict_types=1);

namespace App\Repository;

use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;

/**
 * @extends ServiceEntityRepository<User>
 */
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
docker compose exec app php bin/console doctrine:migrations:diff --no-interaction
  • Step 4: Run migration
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction

Expected: the user table is created in PostgreSQL.

  • Step 5: Commit
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:

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
docker compose exec app php bin/console debug:config security

Expected: outputs the resolved security config without errors.

  • Step 3: Commit
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

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends AbstractController
{
    #[Route('/login', name: 'app_login')]
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        if ($this->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
{% extends 'base.html.twig' %}

{% block title %}Log in{% endblock %}

{% block body %}
<div class="auth-container">
    <h1>Log in</h1>

    {% if error %}
        <div class="auth-error">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
    {% endif %}

    <form method="post">
        <label for="inputEmail">Email</label>
        <input type="email" value="{{ last_username }}" name="_username" id="inputEmail" autocomplete="email" required autofocus>

        <label for="inputPassword">Password</label>
        <input type="password" name="_password" id="inputPassword" autocomplete="current-password" required>

        <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">

        <button type="submit">Sign in</button>
    </form>

    <p class="auth-link">No account yet? <a href="{{ path('app_register') }}">Register</a></p>
</div>
{% endblock %}
  • Step 3: Add the app_homepage route name to HomepageController

In src/Controller/HomepageController.php, update the Route attribute:

#[Route('/', name: 'app_homepage')]
  • Step 4: Commit
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

declare(strict_types=1);

namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

class RegistrationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->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

declare(strict_types=1);

namespace App\Controller;

use App\Entity\User;
use App\Form\RegistrationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

class RegistrationController extends AbstractController
{
    #[Route('/register', name: 'app_register')]
    public function register(
        Request $request,
        UserPasswordHasherInterface $passwordHasher,
        EntityManagerInterface $entityManager,
    ): Response {
        if ($this->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
{% extends 'base.html.twig' %}

{% block title %}Register{% endblock %}

{% block body %}
<div class="auth-container">
    <h1>Register</h1>

    {{ form_start(registrationForm) }}
        {{ form_row(registrationForm.email) }}
        {{ form_row(registrationForm.plainPassword.first) }}
        {{ form_row(registrationForm.plainPassword.second) }}

        <button type="submit">Create account</button>
    {{ form_end(registrationForm) }}

    <p class="auth-link">Already have an account? <a href="{{ path('app_login') }}">Log in</a></p>
</div>
{% endblock %}
  • Step 4: Commit
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:

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

import React from 'react';

export default function GameGrid({ grid, width, middle }) {
    return (
        <table id="actors">
            <tbody>
                {grid.map((row, rowIndex) => (
                    <tr key={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 (
                                <td key={colIndex} style={isHighlighted ? { color: 'red' } : undefined}>
                                    {isInRange ? name[charIndex].toUpperCase() : ''}
                                </td>
                            );
                        })}
                    </tr>
                ))}
            </tbody>
        </table>
    );
}
  • 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:

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:

{% 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
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

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 (
        <td>
            <input
                ref={inputRef}
                type="text"
                maxLength={1}
                className={`letter-input${highlighted ? ' letter-highlighted' : ''}`}
                onKeyUp={handleKeyUp}
                autoComplete="off"
            />
        </td>
    );
}
  • Step 2: Commit
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

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 (
        <>
            <button
                ref={refs.setReference}
                {...getReferenceProps()}
                className="popover-trigger"
                type="button"
            >
                ?
            </button>
            {isOpen && (
                <div
                    ref={refs.setFloating}
                    style={floatingStyles}
                    {...getFloatingProps()}
                    className="actor-popover"
                >
                    <strong>{actorName}</strong>
                </div>
            )}
        </>
    );
}
  • Step 2: Commit
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

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 (
        <tr>
            {Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
                const charIndex = colIndex - colStart;
                const isInRange = charIndex >= 0 && charIndex < letters.length;

                if (!isInRange) {
                    return <td key={colIndex} />;
                }

                return (
                    <LetterInput
                        key={colIndex}
                        highlighted={charIndex === pos}
                        inputRef={setInputRef(charIndex)}
                        onNext={() => focusInput(charIndex + 1)}
                        onPrev={() => focusInput(charIndex - 1)}
                    />
                );
            })}
            <td>
                <ActorPopover actorName={actorName} />
            </td>
        </tr>
    );
}
  • Step 2: Commit
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:

import React from 'react';
import GameRow from './GameRow';

export default function GameGrid({ grid, width, middle }) {
    return (
        <table id="actors">
            <tbody>
                {grid.map((row, rowIndex) => (
                    <GameRow
                        key={rowIndex}
                        actorName={row.actorName}
                        pos={row.pos}
                        colStart={middle - row.pos}
                        totalWidth={width}
                    />
                ))}
            </tbody>
        </table>
    );
}
  • Step 2: Commit
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:

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