# Slidia v2 — Étape 1 (socle) — Plan d'implémentation

> **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 :** installer les fondations partagées (composants Twig, recherche et pagination en repository, couche Responses API, comptage des tokens) dont dépendent les étapes 2 et 3 de la refonte Slidia, sans aucun changement visible pour l'utilisateur.

**Architecture :** trois blocs indépendants. Le bloc A généralise le pattern de recherche/pagination SQL déjà en place dans `ScormiaModuleRepository`. Le bloc B étend `OpenAIHttpClient::callResponsesApi()` — chemin de code aujourd'hui inutilisé par tous les outils — pour supporter les sorties structurées, l'effort de raisonnement et la détection de troncature, et ajoute un accumulateur de consommation de tokens. Le bloc C extrait cinq composants Twig du markup de `templates/scormia/index.html.twig`, plus un contrôleur Stimulus de recherche.

**Tech Stack :** Symfony 7.3, PHP 8.2+, Doctrine ORM, PHPUnit 12, Twig, Tailwind CSS v4, Stimulus 3, Turbo, Webpack Encore.

**Spec de référence :** `docs/superpowers/specs/2026-07-31-slidia-v2-socle-design.md`

## Global Constraints

- Branche de travail : `slidia_v2`. Ne pas merger.
- **Ne jamais utiliser `git add -A`, `git add .` ni `git commit -a`.** Stager nommément les fichiers de sa propre tâche. L'arbre de travail contient des modifications et des suppressions préexistantes appartenant au propriétaire du projet : les embarquer dans un commit pollue l'historique.
- **Scormia n'est modifié en aucune façon.** Ni `src/Controller/Scormia/`, ni `src/Repository/Scormia/`, ni `templates/scormia/`, ni `assets/controllers/scormia/`. Ces fichiers servent uniquement de référence à copier.
- Tout nouveau fichier PHP commence par `declare(strict_types=1);`.
- Commentaires, messages de commit et vocabulaire métier en **français**.
- Chaque composant Twig commence par un en-tête de documentation au format des composants existants (voir `templates/_molecules/filter-tabs/filter-tabs.html.twig`) : bloc `COMPOSITION`, bloc `PARAMÈTRES` sous forme de tableau, bloc `EXEMPLES D'UTILISATION`.
- Aucun `<script>` inline dans un composant Twig. Le comportement passe par un contrôleur Stimulus.
- Les tests tournent avec `php bin/phpunit`. La configuration est dans `phpunit.dist.xml`.
- Ne pas exécuter `php bin/console doctrine:*` sur l'environnement de développement : la base MySQL n'est pas joignable. En revanche la base de test sqlite existe (`var/data_test.db`, schéma et fixtures chargés) : les tests fonctionnels sont exécutables avec `php -d memory_limit=1G bin/phpunit tests/Functional/ --testdox` — la limite mémoire par défaut de 128 Mo est insuffisante pour la suite fonctionnelle complète.
- Aucun changement de modèle IA, de prompt, de template d'outil ou de contrôleur d'outil au-delà de ce que les tâches décrivent explicitement.

---

## Structure des fichiers

**Bloc A — repositories**

| Fichier | Responsabilité |
|---|---|
| `src/Repository/Concern/LikeSearchTrait.php` | *(créé)* Recherche `LIKE` insensible à la casse, métacaractères échappés |
| `src/Repository/SlidiaPresentationRepository.php` | *(modifié)* Pagination et filtres SQL |
| `src/Repository/Moodia/MoodiaCourseRepository.php` | *(modifié)* Pagination et filtres SQL |
| `src/Config/AppLimits.php` | *(modifié)* Constante de pagination partagée |
| `src/Config/SlidiaConfig.php` | *(modifié)* Retrait de la constante morte |
| `src/Controller/Moodia/MoodiaController.php` | *(modifié)* Branchement sur la pagination SQL |

**Bloc B — couche IA**

| Fichier | Responsabilité |
|---|---|
| `src/Exception/Shared/AI/TruncatedResponseException.php` | *(créé)* Réponse coupée par la limite de tokens |
| `src/Exception/Slidia/TruncatedResponseException.php` | *(supprimé)* |
| `src/Service/Shared/AI/OpenAIResponseParser.php` | *(modifié)* Clé `reasoning` dans l'usage |
| `src/Service/Shared/AI/OpenAIHttpClient.php` | *(modifié)* Paramètres et troncature de la Responses API |
| `src/Service/Shared/AI/TokenUsageAccumulator.php` | *(créé)* Cumul de la consommation d'une génération |
| `src/Config/OpenAIConfig.php` | *(modifié)* Constantes d'effort de raisonnement |

**Bloc C — composants**

| Fichier | Responsabilité |
|---|---|
| `templates/_molecules/search-input/search-input.html.twig` | *(créé)* Champ de recherche |
| `templates/_organisms/tool-header/tool-header.html.twig` | *(créé)* En-tête d'outil |
| `templates/_organisms/tool-toolbar/tool-toolbar.html.twig` | *(créé)* Barre filtres + recherche |
| `templates/_organisms/choice-page/choice-page.html.twig` | *(créé)* Page de choix de méthode |
| `templates/_organisms/entity-card/entity-card.html.twig` | *(créé)* Coque de carte |
| `assets/controllers/shared/library_search_controller.js` | *(créé)* Recherche serveur avec anti-rebond |
| `templates/admin/design-system/**` + `src/Controller/Admin/DesignSystemController.php` | *(modifiés)* Pages de démonstration |

---

## Task 1 — trait de recherche partagé

**Files:**
- Create: `src/Repository/Concern/LikeSearchTrait.php`
- Test: `tests/Unit/Repository/Concern/LikeSearchTraitTest.php`

**Interfaces:**
- Consumes: rien
- Produces: `App\Repository\Concern\LikeSearchTrait::applyLikeSearch(QueryBuilder $qb, ?string $search, string ...$fields): void`, méthode `private`. Nom de paramètre DQL : `:search`.

**Contexte :** `src/Repository/Scormia/ScormiaModuleRepository.php::applySearch()` fait déjà exactement ce travail. On le généralise sans en changer la sémantique. **Ne pas modifier `ScormiaModuleRepository`.**

- [ ] **Étape 1 : écrire le test qui échoue**

Créer `tests/Unit/Repository/Concern/LikeSearchTraitTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Repository\Concern;

use App\Repository\Concern\LikeSearchTrait;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\TestCase;

class LikeSearchTraitTest extends TestCase
{
    /** Expose la méthode privée du trait pour la tester isolément. */
    private object $subject;

    protected function setUp(): void
    {
        $this->subject = new class {
            use LikeSearchTrait;

            public function call(QueryBuilder $qb, ?string $search, string ...$fields): void
            {
                $this->applyLikeSearch($qb, $search, ...$fields);
            }
        };
    }

    private function createQueryBuilder(): QueryBuilder
    {
        $em = $this->createMock(EntityManagerInterface::class);
        $em->method('getExpressionBuilder')->willReturn(new Expr());

        $qb = new QueryBuilder($em);
        $qb->select('p')->from('Entity', 'p');

        return $qb;
    }

    public function testChaineVideNAjouteAucuneCondition(): void
    {
        $qb = $this->createQueryBuilder();
        $this->subject->call($qb, '', 'p.title');

        $this->assertStringNotContainsString('WHERE', $qb->getDQL());
        $this->assertCount(0, $qb->getParameters());
    }

    public function testNullNAjouteAucuneCondition(): void
    {
        $qb = $this->createQueryBuilder();
        $this->subject->call($qb, null, 'p.title');

        $this->assertStringNotContainsString('WHERE', $qb->getDQL());
    }

    public function testAucunChampNAjouteAucuneCondition(): void
    {
        $qb = $this->createQueryBuilder();
        $this->subject->call($qb, 'test');

        $this->assertStringNotContainsString('WHERE', $qb->getDQL());
        $this->assertCount(0, $qb->getParameters());
    }

    public function testChampUniqueGenereUnLikeInsensibleALaCasse(): void
    {
        $qb = $this->createQueryBuilder();
        $this->subject->call($qb, 'Rapport', 'p.title');

        $this->assertStringContainsString("LOWER(p.title) LIKE :search ESCAPE '!'", $qb->getDQL());
        $this->assertSame('%rapport%', $qb->getParameter('search')->getValue());
    }

    public function testPlusieursChampsSontCombinesEnOu(): void
    {
        $qb = $this->createQueryBuilder();
        $this->subject->call($qb, 'test', 'p.title', 'p.brief');

        $dql = $qb->getDQL();
        $this->assertStringContainsString('LOWER(p.title) LIKE :search', $dql);
        $this->assertStringContainsString('LOWER(p.brief) LIKE :search', $dql);
        $this->assertStringContainsString('OR', $dql);
    }

    public function testLesMetacaracteresLikeSontEchappes(): void
    {
        $qb = $this->createQueryBuilder();
        $this->subject->call($qb, '100%_test!', 'p.title');

        // '!' doublé en premier, sinon il échapperait les échappements ajoutés ensuite.
        $this->assertSame('%100!%!_test!!%', $qb->getParameter('search')->getValue());
    }

    public function testLesEspacesDeBordSontIgnores(): void
    {
        $qb = $this->createQueryBuilder();
        $this->subject->call($qb, '   ', 'p.title');

        $this->assertStringNotContainsString('WHERE', $qb->getDQL());
    }
}
```

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

```bash
php bin/phpunit tests/Unit/Repository/Concern/LikeSearchTraitTest.php --testdox
```

Attendu : ÉCHEC — `Trait "App\Repository\Concern\LikeSearchTrait" not found`.

- [ ] **Étape 3 : écrire le trait**

Créer `src/Repository/Concern/LikeSearchTrait.php` :

```php
<?php

declare(strict_types=1);

namespace App\Repository\Concern;

use Doctrine\ORM\QueryBuilder;

/**
 * Recherche plein-texte simple, partagée par les bibliothèques d'outils.
 *
 * Les métacaractères LIKE saisis par l'utilisateur sont échappés via ESCAPE '!' :
 * sans cela, un « % » dans la recherche remonterait toute la table.
 *
 * Repris à l'identique de ScormiaModuleRepository::applySearch(), généralisé à
 * plusieurs champs.
 */
trait LikeSearchTrait
{
    /**
     * Ajoute une condition de recherche insensible à la casse sur un ou plusieurs champs.
     *
     * Ne fait rien si la recherche est vide ou si aucun champ n'est fourni.
     *
     * @param string ...$fields Champs préfixés de leur alias (ex. 'p.title').
     *                          Plusieurs champs sont combinés en OR.
     */
    private function applyLikeSearch(QueryBuilder $qb, ?string $search, string ...$fields): void
    {
        $search = trim((string) $search);

        if ($search === '' || $fields === []) {
            return;
        }

        // L'échappement du caractère d'échappement vient EN PREMIER, sinon il
        // échapperait les « ! » que l'on ajoute juste après.
        $term = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower($search));

        $conditions = array_map(
            static fn (string $field): string => sprintf("LOWER(%s) LIKE :search ESCAPE '!'", $field),
            $fields,
        );

        $qb->andWhere('(' . implode(' OR ', $conditions) . ')')
           ->setParameter('search', '%' . $term . '%');
    }
}
```

- [ ] **Étape 4 : lancer le test et vérifier qu'il passe**

```bash
php bin/phpunit tests/Unit/Repository/Concern/LikeSearchTraitTest.php --testdox
```

Attendu : 7 tests, 7 assertions minimum, OK.

- [ ] **Étape 5 : commit**

```bash
git add src/Repository/Concern/LikeSearchTrait.php tests/Unit/Repository/Concern/LikeSearchTraitTest.php
git commit -m "feat(repository): trait de recherche LIKE partagé entre les bibliothèques d'outils"
```

---

## Task 2 — pagination SQL du repository Slidia

**Files:**
- Modify: `src/Repository/SlidiaPresentationRepository.php`
- Test: `tests/Unit/Repository/SlidiaPresentationRepositoryTest.php`

**Interfaces:**
- Consumes: `App\Repository\Concern\LikeSearchTrait::applyLikeSearch()` (tâche 1)
- Produces:
  - `SlidiaPresentationRepository::NO_TEMPLATE` (constante `int`, valeur `-1`)
  - `findByUserPaginated(User $user, int $offset, int $limit, ?int $templateId = null, ?string $search = null): array`
  - `countByUser(User $user, ?int $templateId = null, ?string $search = null): int`
  - `private buildUserQuery(User $user, ?int $templateId, ?string $search): QueryBuilder`

**Attention :** `countByUser(User $user): int` existe déjà avec une signature à un seul paramètre. Les nouveaux paramètres sont **optionnels**, donc les appelants existants (`SlidiaController`, `DashboardDataService`) continuent de fonctionner sans modification. Ne pas toucher `findByUser()`, `countByTemplate()` ni `countTotalSlidesByUser()`.

Ces méthodes ne sont pas encore appelées par le contrôleur Slidia : son branchement fait partie de l'étape 3, qui refond la page. C'est intentionnel.

- [ ] **Étape 1 : écrire le test qui échoue**

Créer `tests/Unit/Repository/SlidiaPresentationRepositoryTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Repository;

use App\Repository\SlidiaPresentationRepository;
use PHPUnit\Framework\TestCase;

/**
 * Tests de contrat du repository : on vérifie les signatures et la constante,
 * pas l'exécution SQL (qui relève des tests fonctionnels avec base).
 */
class SlidiaPresentationRepositoryTest extends TestCase
{
    public function testConstanteNoTemplate(): void
    {
        $this->assertSame(-1, SlidiaPresentationRepository::NO_TEMPLATE);
    }

    public function testSignatureDeFindByUserPaginated(): void
    {
        $method = new \ReflectionMethod(SlidiaPresentationRepository::class, 'findByUserPaginated');
        $names  = array_map(static fn ($p) => $p->getName(), $method->getParameters());

        $this->assertSame(['user', 'offset', 'limit', 'templateId', 'search'], $names);
        $this->assertTrue($method->getParameters()[3]->isOptional());
        $this->assertTrue($method->getParameters()[4]->isOptional());
    }

    public function testCountByUserResteCompatibleAvecUnSeulArgument(): void
    {
        $method = new \ReflectionMethod(SlidiaPresentationRepository::class, 'countByUser');

        $this->assertSame(1, $method->getNumberOfRequiredParameters());
        $this->assertSame(3, $method->getNumberOfParameters());
    }

    public function testLeRepositoryUtiliseLeTraitDeRecherche(): void
    {
        $this->assertContains(
            \App\Repository\Concern\LikeSearchTrait::class,
            class_uses(SlidiaPresentationRepository::class),
        );
    }
}
```

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

```bash
php bin/phpunit tests/Unit/Repository/SlidiaPresentationRepositoryTest.php --testdox
```

Attendu : ÉCHEC — `Undefined constant ... ::NO_TEMPLATE`.

- [ ] **Étape 3 : modifier le repository**

Dans `src/Repository/SlidiaPresentationRepository.php`, ajouter les imports, le trait et les méthodes. Le fichier complet devient :

```php
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\SlidiaPresentation;
use App\Entity\SlidiaTemplate;
use App\Entity\User;
use App\Repository\Concern\LikeSearchTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<SlidiaPresentation>
 */
class SlidiaPresentationRepository extends ServiceEntityRepository
{
    use LikeSearchTrait;

    /** Valeur de filtre désignant les présentations sans masque. */
    public const NO_TEMPLATE = -1;

    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, SlidiaPresentation::class);
    }

    /** @return SlidiaPresentation[] */
    public function findByUser(User $user): array
    {
        return $this->findBy(['user' => $user], ['updatedAt' => 'DESC']);
    }

    /**
     * Page de présentations de l'utilisateur, les plus récentes d'abord.
     *
     * @param int|null $templateId null = tous les masques, self::NO_TEMPLATE = sans masque
     *
     * @return SlidiaPresentation[]
     */
    public function findByUserPaginated(
        User $user,
        int $offset,
        int $limit,
        ?int $templateId = null,
        ?string $search = null,
    ): array {
        return $this->buildUserQuery($user, $templateId, $search)
            ->orderBy('p.updatedAt', 'DESC')
            ->setFirstResult(max(0, $offset))
            ->setMaxResults(max(1, $limit))
            ->getQuery()
            ->getResult();
    }

    public function countByUser(User $user, ?int $templateId = null, ?string $search = null): int
    {
        return (int) $this->buildUserQuery($user, $templateId, $search)
            ->select('COUNT(p.id)')
            ->getQuery()
            ->getSingleScalarResult();
    }

    public function countByTemplate(SlidiaTemplate $template): int
    {
        return $this->count(['template' => $template]);
    }

    public function countTotalSlidesByUser(User $user): int
    {
        $presentations = $this->findByUser($user);
        return array_sum(array_map(fn($p) => count($p->getSlidesData()), $presentations));
    }

    /** Socle commun aux méthodes paginées et de comptage. */
    private function buildUserQuery(User $user, ?int $templateId, ?string $search): QueryBuilder
    {
        $qb = $this->createQueryBuilder('p')
            ->where('p.user = :user')
            ->setParameter('user', $user);

        if ($templateId === self::NO_TEMPLATE) {
            $qb->andWhere('p.template IS NULL');
        } elseif ($templateId !== null) {
            $qb->andWhere('p.template = :template')
               ->setParameter('template', $templateId);
        }

        $this->applyLikeSearch($qb, $search, 'p.title');

        return $qb;
    }
}
```

- [ ] **Étape 4 : lancer le test et vérifier qu'il passe**

```bash
php bin/phpunit tests/Unit/Repository/SlidiaPresentationRepositoryTest.php --testdox
```

Attendu : 4 tests, OK.

- [ ] **Étape 5 : vérifier que rien n'est cassé ailleurs**

```bash
php bin/phpunit tests/Unit/ --testdox
```

Attendu : aucune régression.

- [ ] **Étape 6 : commit**

```bash
git add src/Repository/SlidiaPresentationRepository.php tests/Unit/Repository/SlidiaPresentationRepositoryTest.php
git commit -m "feat(slidia): pagination et filtres SQL dans le repository des présentations"
```

---

## Task 3 — pagination SQL du repository Moodia et branchement du contrôleur

**Files:**
- Modify: `src/Repository/Moodia/MoodiaCourseRepository.php`
- Modify: `src/Config/AppLimits.php`
- Modify: `src/Config/SlidiaConfig.php`
- Modify: `src/Controller/Moodia/MoodiaController.php`
- Test: `tests/Unit/Repository/Moodia/MoodiaCourseRepositoryTest.php`

**Interfaces:**
- Consumes: `LikeSearchTrait::applyLikeSearch()` (tâche 1)
- Produces:
  - `AppLimits::TOOL_LIBRARY_PER_PAGE` (constante `int`, valeur `9`)
  - `MoodiaCourseRepository::findByUserPaginated(User $user, int $offset, int $limit, string $statusFilter = 'all', string $typeFilter = 'all', ?string $search = null): array`
  - `MoodiaCourseRepository::countByUser(User $user, string $statusFilter = 'all', string $typeFilter = 'all', ?string $search = null): int`

**Contexte :** `findByUserWithFilters()` construit déjà un `QueryBuilder` et exprime les filtres de statut et de type en SQL. Il suffit d'en extraire la construction dans une méthode privée, puis d'ajouter la recherche et la pagination. `findByUserWithFilters()` est conservée pour ses appelants existants.

Le contrôleur Moodia est branché sur la version paginée dans cette tâche : le comportement affiché reste **strictement identique** (mêmes 9 cours par page, même ordre), seule la pagination passe de PHP à SQL. Le paramètre `?q=` est lu et transmis, mais aucune interface ne l'envoie encore — l'ajout du champ de recherche relève de l'étape 3.

- [ ] **Étape 1 : écrire le test qui échoue**

Créer `tests/Unit/Repository/Moodia/MoodiaCourseRepositoryTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Repository\Moodia;

use App\Config\AppLimits;
use App\Repository\Concern\LikeSearchTrait;
use App\Repository\Moodia\MoodiaCourseRepository;
use PHPUnit\Framework\TestCase;

class MoodiaCourseRepositoryTest extends TestCase
{
    public function testPaginationPartageeAneufElements(): void
    {
        $this->assertSame(9, AppLimits::TOOL_LIBRARY_PER_PAGE);
    }

    public function testSignatureDeFindByUserPaginated(): void
    {
        $method = new \ReflectionMethod(MoodiaCourseRepository::class, 'findByUserPaginated');
        $names  = array_map(static fn ($p) => $p->getName(), $method->getParameters());

        $this->assertSame(
            ['user', 'offset', 'limit', 'statusFilter', 'typeFilter', 'search'],
            $names,
        );
    }

    public function testCountByUserAcceptePourSeulArgumentObligatoireLUtilisateur(): void
    {
        $method = new \ReflectionMethod(MoodiaCourseRepository::class, 'countByUser');

        $this->assertSame(1, $method->getNumberOfRequiredParameters());
    }

    public function testFindByUserWithFiltersEstConservee(): void
    {
        $this->assertTrue(method_exists(MoodiaCourseRepository::class, 'findByUserWithFilters'));
    }

    public function testLeRepositoryUtiliseLeTraitDeRecherche(): void
    {
        $this->assertContains(LikeSearchTrait::class, class_uses(MoodiaCourseRepository::class));
    }
}
```

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

```bash
php bin/phpunit tests/Unit/Repository/Moodia/MoodiaCourseRepositoryTest.php --testdox
```

Attendu : ÉCHEC — `Undefined constant App\Config\AppLimits::TOOL_LIBRARY_PER_PAGE`.

- [ ] **Étape 3 : ajouter la constante partagée**

Dans `src/Config/AppLimits.php`, remplacer le bloc de la constante `MOODIA_COURSES_PER_PAGE` (aux alentours de la ligne 332) par :

```php
    /**
     * Nombre d'éléments par page dans les bibliothèques d'outils.
     *
     * Valeur unique pour Moodia, Slidia et Scormia : trois lignes de trois cartes
     * sur écran large. Une valeur différente par outil créerait un écart visible
     * sans justification métier.
     */
    public const TOOL_LIBRARY_PER_PAGE = 9;
```

Ne toucher à aucune autre constante de ce fichier. En particulier, `SLIDIA_MAX_CHARS` est laissée en place : son sort relève de l'étape 2.

Dans `src/Config/SlidiaConfig.php`, supprimer la ligne :

```php
    public const PRESENTATIONS_PER_PAGE = 30;
```

ainsi que son bloc de commentaire. Vérifier qu'elle n'est référencée nulle part :

```bash
grep -rn "PRESENTATIONS_PER_PAGE\|MOODIA_COURSES_PER_PAGE" src/ templates/ assets/ tests/
```

Attendu après modification : seule `MoodiaController` référence encore `MOODIA_COURSES_PER_PAGE` — elle est corrigée à l'étape 5.

- [ ] **Étape 4 : modifier le repository Moodia**

Dans `src/Repository/Moodia/MoodiaCourseRepository.php` :

1. Ajouter les imports en tête de fichier :

```php
use App\Repository\Concern\LikeSearchTrait;
use Doctrine\ORM\QueryBuilder;
```

2. Ajouter `use LikeSearchTrait;` comme première ligne du corps de la classe.

3. Remplacer la méthode `findByUserWithFilters()` (lignes 50-73) par ce bloc de quatre méthodes :

```php
    /** @return MoodiaCourse[] */
    public function findByUserWithFilters(User $user, string $statusFilter = 'all', string $typeFilter = 'all'): array
    {
        return $this->buildUserQuery($user, $statusFilter, $typeFilter, null)
            ->getQuery()
            ->getResult();
    }

    /**
     * Page de cours de l'utilisateur, les plus récents d'abord.
     *
     * @return MoodiaCourse[]
     */
    public function findByUserPaginated(
        User $user,
        int $offset,
        int $limit,
        string $statusFilter = 'all',
        string $typeFilter = 'all',
        ?string $search = null,
    ): array {
        return $this->buildUserQuery($user, $statusFilter, $typeFilter, $search)
            ->setFirstResult(max(0, $offset))
            ->setMaxResults(max(1, $limit))
            ->getQuery()
            ->getResult();
    }

    public function countByUser(
        User $user,
        string $statusFilter = 'all',
        string $typeFilter = 'all',
        ?string $search = null,
    ): int {
        return (int) $this->buildUserQuery($user, $statusFilter, $typeFilter, $search)
            ->select('COUNT(c.id)')
            ->getQuery()
            ->getSingleScalarResult();
    }

    /** Socle commun aux méthodes de liste, de page et de comptage. */
    private function buildUserQuery(
        User $user,
        string $statusFilter,
        string $typeFilter,
        ?string $search,
    ): QueryBuilder {
        $qb = $this->createQueryBuilder('c')
            ->where('c.user = :user')
            ->setParameter('user', $user)
            ->orderBy('c.updatedAt', 'DESC');

        // Filtre par statut (utilise les constantes de MoodiaCourse pour cohérence)
        if ($statusFilter === MoodiaCourse::STATUS_DRAFT) {
            $qb->andWhere('c.status != :deployed')
               ->setParameter('deployed', MoodiaCourse::STATUS_DEPLOYED);
        } elseif ($statusFilter === MoodiaCourse::STATUS_DEPLOYED) {
            $qb->andWhere('c.status = :deployed')
               ->setParameter('deployed', MoodiaCourse::STATUS_DEPLOYED);
        }

        // Filtre par type
        if ($typeFilter !== 'all') {
            $qb->andWhere('c.generationType = :type')
               ->setParameter('type', $typeFilter);
        }

        $this->applyLikeSearch($qb, $search, 'c.title');

        return $qb;
    }
```

- [ ] **Étape 5 : brancher le contrôleur Moodia**

Dans `src/Controller/Moodia/MoodiaController.php`, remplacer **intégralement** le corps de la méthode `index()` par le code ci-dessous. Le repository est injecté en propriété (`$this->courseRepository`), promue dans le constructeur.

**Toutes les variables passées au template sont conservées** : en retirer une casserait `dashboard.html.twig`. Seule `search` est ajoutée.

```php
    #[Route('/moodia', name: 'app_moodia')]
    public function index(Request $request): Response
    {
        // Récupérer les filtres
        $typeFilter   = $request->query->get('type', 'all');
        $search       = trim((string) $request->query->get('q', ''));
        $searchOrNull = $search !== '' ? $search : null;

        // Récupérer les statistiques globales (sans filtre ni recherche : les
        // compteurs d'onglets restent absolus, comme dans la bibliothèque Scormia)
        $tokenStats  = $this->tokenService->getTokenStatistics($this->getUser()->getId());
        $courseStats = $this->courseRepository->getStatsByUser($this->getUser());
        $typeStats   = $this->courseRepository->getTypeStatsByUser($this->getUser());

        // Pagination SQL : le comptage et la page sont délégués au repository.
        $perPage      = AppLimits::TOOL_LIBRARY_PER_PAGE;
        $totalCourses = $this->courseRepository->countByUser($this->getUser(), 'all', $typeFilter, $searchOrNull);
        $totalPages   = max(1, (int) ceil($totalCourses / $perPage));
        $page         = max(1, min($totalPages, $request->query->getInt('page', 1)));

        $courses = $this->courseRepository->findByUserPaginated(
            $this->getUser(),
            ($page - 1) * $perPage,
            $perPage,
            'all',
            $typeFilter,
            $searchOrNull,
        );

        return $this->render('moodia/dashboard.html.twig', [
            'tokenStats'      => $tokenStats,
            'courseStats'     => $courseStats,
            'typeStats'       => $typeStats,
            'medianTokenCost' => $this->toolsService->getToolMedianCosts()['moodia'] ?? 0,
            'courses'         => $courses,
            'currentPage'     => $page,
            'totalPages'      => $totalPages,
            'totalCourses'    => $totalCourses,
            'typeFilter'      => $typeFilter,
            // Lu et transmis dès maintenant ; le champ de recherche est ajouté au
            // template à l'étape 3.
            'search'          => $search,
            'canCreate'       => $tokenStats['remaining'] >= AppLimits::MOODIA_MIN_TOKENS,
            'moodiaMinTokens' => AppLimits::MOODIA_MIN_TOKENS,
        ]);
    }
```

Vérifier qu'aucune autre référence à `AppLimits::MOODIA_COURSES_PER_PAGE` ne subsiste :

```bash
grep -rn "MOODIA_COURSES_PER_PAGE" src/ templates/ tests/
```

Attendu : aucun résultat.

- [ ] **Étape 6 : lancer les tests**

```bash
php bin/phpunit tests/Unit/ --testdox
```

Attendu : les 5 nouveaux tests passent, aucune régression.

- [ ] **Étape 7 : vérifier que le conteneur se compile**

```bash
php bin/console lint:container
```

Attendu : `[OK] The container was linted successfully`.

- [ ] **Étape 8 : commit**

```bash
git add src/Repository/Moodia/MoodiaCourseRepository.php src/Config/AppLimits.php src/Config/SlidiaConfig.php src/Controller/Moodia/MoodiaController.php tests/Unit/Repository/Moodia/MoodiaCourseRepositoryTest.php
git commit -m "feat(moodia): pagination SQL des cours et constante de pagination partagée"
```

---

## Task 4 — ventilation des tokens de raisonnement

**Files:**
- Modify: `src/Service/Shared/AI/OpenAIResponseParser.php:158-186`
- Test: `tests/Unit/Service/Shared/AI/OpenAIResponseParserTest.php`

**Interfaces:**
- Produces: `OpenAIResponseParser::extractUsage()` renvoie une clé supplémentaire `'reasoning' => int`.

**Règle métier à ne pas enfreindre :** OpenAI inclut **déjà** les tokens de raisonnement dans `output_tokens`. La clé `reasoning` sert uniquement à la ventilation dans les logs. Elle ne doit **jamais** être ajoutée au total, sous peine de facturer le raisonnement deux fois à l'utilisateur.

- [ ] **Étape 1 : écrire le test qui échoue**

Ajouter à `tests/Unit/Service/Shared/AI/OpenAIResponseParserTest.php` :

```php
    public function testExtractUsageRemonteLesTokensDeRaisonnementDeLaResponsesApi(): void
    {
        $usage = OpenAIResponseParser::extractUsage([
            'usage' => [
                'input_tokens'  => 500,
                'output_tokens' => 1000,
                'total_tokens'  => 1500,
                'output_tokens_details' => ['reasoning_tokens' => 400],
            ],
        ]);

        $this->assertSame(400, $usage['reasoning']);
    }

    public function testLeRaisonnementNEstPasAjouteAuTotal(): void
    {
        $usage = OpenAIResponseParser::extractUsage([
            'usage' => [
                'input_tokens'  => 500,
                'output_tokens' => 1000,
                'total_tokens'  => 1500,
                'output_tokens_details' => ['reasoning_tokens' => 400],
            ],
        ]);

        // 1500, pas 1900 : le raisonnement est déjà compris dans output_tokens.
        $this->assertSame(1500, $usage['total']);
    }

    public function testExtractUsageRenvoieZeroQuandLeRaisonnementEstAbsent(): void
    {
        $usage = OpenAIResponseParser::extractUsage([
            'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 20, 'total_tokens' => 30],
        ]);

        $this->assertSame(0, $usage['reasoning']);
    }
```

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

```bash
php bin/phpunit tests/Unit/Service/Shared/AI/OpenAIResponseParserTest.php --testdox
```

Attendu : ÉCHEC — `Undefined array key "reasoning"`.

- [ ] **Étape 3 : ajouter la clé**

Dans `src/Service/Shared/AI/OpenAIResponseParser.php`, méthode `extractUsage()`, ajouter dans le tableau retourné, juste après la clé `'cached'` :

```php
            // Tokens de raisonnement (modèles gpt-5+). OpenAI les inclut DÉJÀ dans
            // output_tokens : cette clé sert à la ventilation dans les logs, jamais
            // à l'addition — sinon le raisonnement serait facturé deux fois.
            'reasoning'    => $outputDetails['reasoning_tokens'] ?? 0,
```

- [ ] **Étape 4 : lancer le test et vérifier qu'il passe**

```bash
php bin/phpunit tests/Unit/Service/Shared/AI/OpenAIResponseParserTest.php --testdox
```

Attendu : tous verts.

- [ ] **Étape 5 : commit**

```bash
git add src/Service/Shared/AI/OpenAIResponseParser.php tests/Unit/Service/Shared/AI/OpenAIResponseParserTest.php
git commit -m "feat(ai): ventilation des tokens de raisonnement dans l'usage"
```

---

## Task 5 — exception de troncature dans la couche partagée

**Files:**
- Create: `src/Exception/Shared/AI/TruncatedResponseException.php`
- Delete: `src/Exception/Slidia/TruncatedResponseException.php`
- Test: `tests/Unit/Exception/Shared/AI/TruncatedResponseExceptionTest.php`

**Interfaces:**
- Produces: `App\Exception\Shared\AI\TruncatedResponseException`, constructeur `(string $model, string $reason, int $tokensUsed = 0, ?\Throwable $previous = null)`, accesseurs `getModel(): string`, `getReason(): string`, `getTokensUsed(): int`.

**Contexte :** `src/Exception/Slidia/TruncatedResponseException.php` existe (9 lignes) mais n'est jamais levée ni interceptée dans tout `src/`. Vérifier avant suppression :

```bash
grep -rn "TruncatedResponseException" src/ tests/
```

Attendu : uniquement la déclaration.

- [ ] **Étape 1 : écrire le test qui échoue**

Créer `tests/Unit/Exception/Shared/AI/TruncatedResponseExceptionTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Exception\Shared\AI;

use App\Exception\Shared\AI\TruncatedResponseException;
use PHPUnit\Framework\TestCase;

class TruncatedResponseExceptionTest extends TestCase
{
    public function testPorteLeModeleLaRaisonEtLesTokens(): void
    {
        $e = new TruncatedResponseException('gpt-5-mini', 'max_output_tokens', 16384);

        $this->assertSame('gpt-5-mini', $e->getModel());
        $this->assertSame('max_output_tokens', $e->getReason());
        $this->assertSame(16384, $e->getTokensUsed());
    }

    public function testLeMessageEstExploitablePourLeDiagnostic(): void
    {
        $e = new TruncatedResponseException('gpt-5-mini', 'max_output_tokens', 16384);

        $this->assertStringContainsString('gpt-5-mini', $e->getMessage());
        $this->assertStringContainsString('max_output_tokens', $e->getMessage());
    }

    public function testEstUneRuntimeException(): void
    {
        $this->assertInstanceOf(
            \RuntimeException::class,
            new TruncatedResponseException('gpt-5-mini', 'max_output_tokens'),
        );
    }
}
```

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

```bash
php bin/phpunit tests/Unit/Exception/Shared/AI/TruncatedResponseExceptionTest.php --testdox
```

Attendu : ÉCHEC — classe introuvable.

- [ ] **Étape 3 : créer l'exception**

Créer `src/Exception/Shared/AI/TruncatedResponseException.php` :

```php
<?php

declare(strict_types=1);

namespace App\Exception\Shared\AI;

/**
 * Levée lorsqu'un modèle interrompt sa réponse avant la fin.
 *
 * Cas principal : max_output_tokens couvre à la fois les tokens de raisonnement
 * et le texte produit. Un raisonnement long peut donc consommer tout le budget et
 * faire couper la réponse au milieu — ce qui se manifeste par du JSON invalide
 * plutôt que par une erreur HTTP.
 */
class TruncatedResponseException extends \RuntimeException
{
    public function __construct(
        private readonly string $model,
        private readonly string $reason,
        private readonly int $tokensUsed = 0,
        ?\Throwable $previous = null,
    ) {
        parent::__construct(
            sprintf(
                'Réponse tronquée du modèle %s (raison : %s, %d tokens consommés).',
                $model,
                $reason,
                $tokensUsed,
            ),
            0,
            $previous,
        );
    }

    public function getModel(): string
    {
        return $this->model;
    }

    public function getReason(): string
    {
        return $this->reason;
    }

    public function getTokensUsed(): int
    {
        return $this->tokensUsed;
    }
}
```

- [ ] **Étape 4 : supprimer l'ancienne exception**

```bash
git rm src/Exception/Slidia/TruncatedResponseException.php
rmdir src/Exception/Slidia 2>/dev/null || true
```

- [ ] **Étape 5 : lancer le test et vérifier qu'il passe**

```bash
php bin/phpunit tests/Unit/Exception/ --testdox
php bin/console lint:container
```

Attendu : tests verts, conteneur OK.

- [ ] **Étape 6 : commit**

```bash
git add src/Exception/Shared/AI/TruncatedResponseException.php tests/Unit/Exception/Shared/AI/TruncatedResponseExceptionTest.php
git commit -m "refactor(ai): déplace TruncatedResponseException dans la couche partagée"
```

---

## Task 6 — paramètres et troncature de la Responses API

**Files:**
- Modify: `src/Service/Shared/AI/OpenAIHttpClient.php:52-121`
- Modify: `src/Config/OpenAIConfig.php`
- Test: `tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php`

**Interfaces:**
- Consumes: `App\Exception\Shared\AI\TruncatedResponseException` (tâche 5)
- Produces:
  - `OpenAIConfig::REASONING_EFFORT_MINIMAL|LOW|MEDIUM|HIGH` (constantes `string`)
  - `OpenAIHttpClient::mapExtraParamsToResponsesApi(array $extraParams): array`, méthode `private`

**Contexte critique :** `callResponsesApi()` ignore aujourd'hui `$extraParams`. Aucun outil n'emprunte ce chemin (`gpt-4o-mini`, `gpt-4.1`, `gpt-4.1-mini` passent tous par Chat Completions), donc l'extension est sans risque de régression. Les tests existants du fichier utilisent `createMock(HttpClientInterface::class)` et une instanciation par réflexion, la classe étant `final` : suivre cette convention.

Table de traduction à implémenter :

| Clé fournie par l'appelant | Envoyé à la Responses API |
|---|---|
| `response_format: {type:'json_schema', json_schema:{name, schema, strict}}` | `text: {format: {type:'json_schema', name, schema, strict}}` |
| `response_format: {type:'json_object'}` | `text: {format: {type:'json_object'}}` |
| `reasoning: {...}` | `reasoning: {...}` |
| `verbosity: 'low'` | `text: {verbosity: 'low'}` |
| `prompt_cache_key: '...'` | `prompt_cache_key: '...'` |
| toute autre clé | transmise telle quelle |

- [ ] **Étape 1 : écrire les tests qui échouent**

Ajouter à `tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php` :

```php
    // =========================================================================
    // RESPONSES API — PARAMÈTRES ET TRONCATURE
    // =========================================================================

    public function testGpt5PasseParLaResponsesApi(): void
    {
        $response = $this->createMockResponse(200, [
            'status' => 'completed',
            'output' => [
                ['type' => 'reasoning'],
                ['type' => 'message', 'content' => [['type' => 'output_text', 'text' => 'ok']]],
            ],
            'usage' => ['input_tokens' => 10, 'output_tokens' => 5, 'total_tokens' => 15],
        ]);

        $this->httpClient->expects($this->once())
            ->method('request')
            ->with(
                'POST',
                OpenAIConfig::RESPONSES_API_URL,
                $this->callback(function ($options) {
                    return $options['json']['model'] === 'gpt-5-mini'
                        && $options['json']['max_output_tokens'] === 8192
                        && !isset($options['json']['max_tokens'])
                        && !isset($options['json']['temperature']);
                }),
            )
            ->willReturn($response);

        $result = $this->client->chatCompletion(
            'gpt-5-mini',
            [['role' => 'user', 'content' => 'Bonjour']],
            8192,
        );

        $this->assertSame('ok', $result['content']);
    }

    public function testJsonSchemaEstTraduitEnTextFormat(): void
    {
        $schema = ['type' => 'object', 'properties' => ['slides' => ['type' => 'array']]];

        $response = $this->createMockResponse(200, [
            'status' => 'completed',
            'output' => [['type' => 'message', 'content' => [['type' => 'output_text', 'text' => '{}']]]],
            'usage'  => ['input_tokens' => 1, 'output_tokens' => 1, 'total_tokens' => 2],
        ]);

        $this->httpClient->expects($this->once())
            ->method('request')
            ->with(
                'POST',
                OpenAIConfig::RESPONSES_API_URL,
                $this->callback(function ($options) use ($schema) {
                    $format = $options['json']['text']['format'] ?? null;

                    return $format !== null
                        && $format['type'] === 'json_schema'
                        && $format['name'] === 'plan'
                        && $format['schema'] === $schema
                        && $format['strict'] === true
                        && !isset($options['json']['response_format']);
                }),
            )
            ->willReturn($response);

        $this->client->chatCompletion(
            'gpt-5-mini',
            [['role' => 'user', 'content' => 'Bonjour']],
            4096,
            0.7,
            300,
            ['response_format' => [
                'type' => 'json_schema',
                'json_schema' => ['name' => 'plan', 'schema' => $schema, 'strict' => true],
            ]],
        );
    }

    public function testJsonObjectEstTraduitEnTextFormat(): void
    {
        $response = $this->createMockResponse(200, [
            'status' => 'completed',
            'output' => [['type' => 'message', 'content' => [['type' => 'output_text', 'text' => '{}']]]],
            'usage'  => ['input_tokens' => 1, 'output_tokens' => 1, 'total_tokens' => 2],
        ]);

        $this->httpClient->expects($this->once())
            ->method('request')
            ->with('POST', OpenAIConfig::RESPONSES_API_URL, $this->callback(
                static fn ($options) => ($options['json']['text']['format']['type'] ?? null) === 'json_object',
            ))
            ->willReturn($response);

        $this->client->chatCompletion(
            'gpt-5-mini',
            [['role' => 'user', 'content' => 'Bonjour']],
            4096,
            0.7,
            300,
            ['response_format' => ['type' => 'json_object']],
        );
    }

    public function testEffortDeRaisonnementEtVerbositeSontTransmis(): void
    {
        $response = $this->createMockResponse(200, [
            'status' => 'completed',
            'output' => [['type' => 'message', 'content' => [['type' => 'output_text', 'text' => 'ok']]]],
            'usage'  => ['input_tokens' => 1, 'output_tokens' => 1, 'total_tokens' => 2],
        ]);

        $this->httpClient->expects($this->once())
            ->method('request')
            ->with('POST', OpenAIConfig::RESPONSES_API_URL, $this->callback(static function ($options) {
                return ($options['json']['reasoning']['effort'] ?? null) === 'low'
                    && ($options['json']['text']['verbosity'] ?? null) === 'low'
                    && ($options['json']['prompt_cache_key'] ?? null) === 'slidia-plan';
            }))
            ->willReturn($response);

        $this->client->chatCompletion(
            'gpt-5-mini',
            [['role' => 'user', 'content' => 'Bonjour']],
            4096,
            0.7,
            300,
            [
                'reasoning'        => ['effort' => OpenAIConfig::REASONING_EFFORT_LOW],
                'verbosity'        => 'low',
                'prompt_cache_key' => 'slidia-plan',
            ],
        );
    }

    public function testUneReponseIncompleteLeveUneException(): void
    {
        $response = $this->createMockResponse(200, [
            'status' => 'incomplete',
            'incomplete_details' => ['reason' => 'max_output_tokens'],
            'output' => [['type' => 'reasoning']],
            'usage'  => ['input_tokens' => 100, 'output_tokens' => 8192, 'total_tokens' => 8292],
        ]);

        $this->httpClient->method('request')->willReturn($response);

        $this->expectException(TruncatedResponseException::class);
        $this->expectExceptionMessageMatches('/max_output_tokens/');

        $this->client->chatCompletion(
            'gpt-5-mini',
            [['role' => 'user', 'content' => 'Bonjour']],
            8192,
        );
    }

    public function testUneCleInconnueEstTransmiseTelleQuelle(): void
    {
        $response = $this->createMockResponse(200, [
            'status' => 'completed',
            'output' => [['type' => 'message', 'content' => [['type' => 'output_text', 'text' => 'ok']]]],
            'usage'  => ['input_tokens' => 1, 'output_tokens' => 1, 'total_tokens' => 2],
        ]);

        $this->httpClient->expects($this->once())
            ->method('request')
            ->with('POST', OpenAIConfig::RESPONSES_API_URL, $this->callback(
                static fn ($options) => ($options['json']['futur_parametre'] ?? null) === 'valeur',
            ))
            ->willReturn($response);

        $this->client->chatCompletion(
            'gpt-5-mini',
            [['role' => 'user', 'content' => 'Bonjour']],
            4096,
            0.7,
            300,
            ['futur_parametre' => 'valeur'],
        );
    }

    public function testLaChatCompletionsApiNEstPasAffecteeParLaTraduction(): void
    {
        $response = $this->createMockResponse(200, [
            'choices' => [['message' => ['content' => 'ok']]],
            'usage'   => ['total_tokens' => 10],
        ]);

        $this->httpClient->expects($this->once())
            ->method('request')
            ->with('POST', OpenAIConfig::API_URL, $this->callback(static function ($options) {
                // gpt-4o garde response_format et n'a pas de bloc text.
                return ($options['json']['response_format']['type'] ?? null) === 'json_object'
                    && !isset($options['json']['text']);
            }))
            ->willReturn($response);

        $this->client->chatCompletion(
            'gpt-4o',
            [['role' => 'user', 'content' => 'Bonjour']],
            4096,
            0.7,
            300,
            ['response_format' => ['type' => 'json_object']],
        );
    }
```

Ajouter en tête du fichier de test :

```php
use App\Exception\Shared\AI\TruncatedResponseException;
```

- [ ] **Étape 2 : lancer les tests et vérifier qu'ils échouent**

```bash
php bin/phpunit tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php --testdox
```

Attendu : ÉCHEC sur les nouveaux tests (`text` absent du corps de requête, aucune exception levée).

- [ ] **Étape 3 : ajouter les constantes d'effort**

Dans `src/Config/OpenAIConfig.php`, après le bloc des modèles, ajouter :

```php
    // =========================================================================
    // EFFORT DE RAISONNEMENT (modèles gpt-5+)
    // =========================================================================

    /**
     * Curseur de réflexion des modèles de raisonnement.
     *
     * Les tokens de réflexion sont facturés au tarif de SORTIE, le plus cher, et
     * consomment le budget max_output_tokens partagé avec la réponse. Rester bas
     * par défaut ; ne remonter qu'au vu de mesures réelles.
     */
    public const REASONING_EFFORT_MINIMAL = 'minimal';
    public const REASONING_EFFORT_LOW     = 'low';
    public const REASONING_EFFORT_MEDIUM  = 'medium';
    public const REASONING_EFFORT_HIGH    = 'high';
```

- [ ] **Étape 4 : étendre le client HTTP**

Dans `src/Service/Shared/AI/OpenAIHttpClient.php` :

1. Ajouter l'import :

```php
use App\Exception\Shared\AI\TruncatedResponseException;
```

2. Dans `chatCompletion()`, transmettre `$extraParams` à la branche Responses API :

```php
        if ($useResponsesApi) {
            return $this->callResponsesApi($model, $messages, $maxTokens, $timeout, $extraParams);
        }
```

3. Remplacer intégralement `callResponsesApi()` par :

```php
    /**
     * Appelle l'API Responses (GPT-5 et ultérieurs)
     *
     * temperature n'est pas supporté par cette API : le paramètre est volontairement
     * absent de la signature pour que l'oubli soit visible côté appelant.
     */
    private function callResponsesApi(
        string $model,
        array $messages,
        int $maxOutputTokens,
        int $timeout,
        array $extraParams = []
    ): array {
        $params = array_merge(
            [
                'model'             => $model,
                'input'             => $messages,
                'max_output_tokens' => $maxOutputTokens,
            ],
            $this->mapExtraParamsToResponsesApi($extraParams),
        );

        $response = $this->httpClient->request('POST', OpenAIConfig::RESPONSES_API_URL, [
            'headers' => $this->getHeaders(),
            'json'    => $params,
            'timeout' => $timeout,
        ]);

        $data  = $this->handleResponse($response, 'Responses API');
        $usage = OpenAIResponseParser::extractUsage($data);

        // Une réponse coupée revient en HTTP 200 : sans ce contrôle, l'appelant
        // reçoit du JSON tronqué et échoue plus loin, sans cause identifiable.
        if (($data['status'] ?? null) === 'incomplete') {
            $reason = $data['incomplete_details']['reason'] ?? 'inconnue';

            $this->logger->error('OpenAI Responses API : réponse tronquée', [
                'model'  => $model,
                'reason' => $reason,
                'usage'  => $usage,
            ]);

            throw new TruncatedResponseException($model, $reason, $usage['total'] ?? 0);
        }

        return [
            'content' => OpenAIResponseParser::extractFromResponsesApi($data),
            'usage'   => $usage,
        ];
    }

    /**
     * Traduit les paramètres exprimés au format Chat Completions vers la Responses API.
     *
     * Les appelants décrivent ce qu'ils veulent (une sortie JSON conforme à un schéma,
     * un effort de raisonnement) sans avoir à connaître le format attendu par l'API :
     * c'est ce qui permet de changer de modèle sans toucher aux services métier.
     *
     * Les clés inconnues sont transmises telles quelles pour ne pas bloquer un
     * paramètre futur.
     */
    private function mapExtraParamsToResponsesApi(array $extraParams): array
    {
        $mapped = [];
        $text   = [];

        foreach ($extraParams as $key => $value) {
            switch ($key) {
                case 'response_format':
                    $type = $value['type'] ?? null;

                    if ($type === 'json_schema') {
                        $schema = $value['json_schema'] ?? [];
                        $text['format'] = [
                            'type'   => 'json_schema',
                            'name'   => $schema['name'] ?? 'response',
                            'schema' => $schema['schema'] ?? [],
                            'strict' => $schema['strict'] ?? true,
                        ];
                    } elseif ($type !== null) {
                        $text['format'] = ['type' => $type];
                    }
                    break;

                case 'verbosity':
                    $text['verbosity'] = $value;
                    break;

                // temperature et max_tokens ne sont pas supportés par la Responses API.
                // On les journalise plutôt que de les ignorer en silence : c'est
                // exactement ce genre de disparition muette qui a motivé ce chantier.
                case 'temperature':
                case 'max_tokens':
                    $this->logger->debug('Paramètre ignoré par la Responses API', ['param' => $key]);
                    break;

                default:
                    $mapped[$key] = $value;
            }
        }

        if ($text !== []) {
            $mapped['text'] = array_merge($mapped['text'] ?? [], $text);
        }

        return $mapped;
    }
```

- [ ] **Étape 5 : lancer les tests et vérifier qu'ils passent**

```bash
php bin/phpunit tests/Unit/Service/Shared/AI/ --testdox
```

Attendu : tous verts, y compris les tests préexistants du fichier.

- [ ] **Étape 6 : commit**

```bash
git add src/Service/Shared/AI/OpenAIHttpClient.php src/Config/OpenAIConfig.php tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php
git commit -m "feat(ai): sorties structurées, effort de raisonnement et détection de troncature sur la Responses API"
```

---

## Task 7 — accumulateur de consommation de tokens

**Files:**
- Create: `src/Service/Shared/AI/TokenUsageAccumulator.php`
- Test: `tests/Unit/Service/Shared/AI/TokenUsageAccumulatorTest.php`

**Interfaces:**
- Consumes: le tableau renvoyé par `OpenAIResponseParser::extractUsage()` (clés `input`, `output`, `cached`, `reasoning`, `total`)
- Produces:
  - `record(string $model, string $step, array $usage): void`
  - `total(): int`
  - `breakdown(): array` — liste ordonnée de `['step' => string, 'model' => string, 'input' => int, 'cached' => int, 'output' => int, 'reasoning' => int, 'total' => int]`
  - `callCount(): int`
  - `hasUsage(): bool`

**Rôle :** une génération Slidia enchaîne plusieurs appels (condensation, plan, un par lot de slides, notes). L'accumulateur les collecte tous pour un débit unique en fin de génération, et rend l'omission d'un appel visible.

- [ ] **Étape 1 : écrire le test qui échoue**

Créer `tests/Unit/Service/Shared/AI/TokenUsageAccumulatorTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service\Shared\AI;

use App\Service\Shared\AI\TokenUsageAccumulator;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

class TokenUsageAccumulatorTest extends TestCase
{
    private function usage(int $input, int $output, int $reasoning = 0, int $cached = 0): array
    {
        return [
            'input'     => $input,
            'output'    => $output,
            'cached'    => $cached,
            'reasoning' => $reasoning,
            'total'     => $input + $output,
        ];
    }

    public function testUnAccumulateurVideRenvoieZero(): void
    {
        $acc = new TokenUsageAccumulator($this->createMock(LoggerInterface::class));

        $this->assertSame(0, $acc->total());
        $this->assertSame(0, $acc->callCount());
        $this->assertFalse($acc->hasUsage());
        $this->assertSame([], $acc->breakdown());
    }

    public function testLeTotalEstLaSommeDesAppels(): void
    {
        $acc = new TokenUsageAccumulator($this->createMock(LoggerInterface::class));
        $acc->record('gpt-5-nano', 'sommaire', $this->usage(5000, 500));
        $acc->record('gpt-5-mini', 'plan', $this->usage(4000, 1500));
        $acc->record('gpt-5-mini', 'contenu-1', $this->usage(8000, 3000));

        $this->assertSame(22000, $acc->total());
        $this->assertSame(3, $acc->callCount());
        $this->assertTrue($acc->hasUsage());
    }

    public function testLeRaisonnementEstCompteUneSeuleFois(): void
    {
        $acc = new TokenUsageAccumulator($this->createMock(LoggerInterface::class));
        // 1000 tokens de sortie DONT 400 de raisonnement.
        $acc->record('gpt-5-mini', 'plan', $this->usage(500, 1000, 400));

        // 1500, jamais 1900.
        $this->assertSame(1500, $acc->total());
        $this->assertSame(400, $acc->breakdown()[0]['reasoning']);
    }

    public function testLaVentilationConserveLOrdreEtLeModele(): void
    {
        $acc = new TokenUsageAccumulator($this->createMock(LoggerInterface::class));
        $acc->record('gpt-5-nano', 'sommaire', $this->usage(100, 50));
        $acc->record('gpt-5-mini', 'plan', $this->usage(200, 80));

        $breakdown = $acc->breakdown();

        $this->assertCount(2, $breakdown);
        $this->assertSame('sommaire', $breakdown[0]['step']);
        $this->assertSame('gpt-5-nano', $breakdown[0]['model']);
        $this->assertSame('plan', $breakdown[1]['step']);
        $this->assertSame('gpt-5-mini', $breakdown[1]['model']);
    }

    public function testUnUsageVideCompteZeroSansInvaliderLesAutres(): void
    {
        $logger = $this->createMock(LoggerInterface::class);
        $logger->expects($this->once())->method('warning');

        $acc = new TokenUsageAccumulator($logger);
        $acc->record('gpt-5-mini', 'plan', $this->usage(1000, 500));
        $acc->record('gpt-5-mini', 'contenu-1', []);

        $this->assertSame(1500, $acc->total());
        $this->assertSame(2, $acc->callCount());
        $this->assertTrue($acc->hasUsage());
    }

    public function testHasUsageResteFauxSiAucunAppelNaRenvoyeDUsage(): void
    {
        $acc = new TokenUsageAccumulator($this->createMock(LoggerInterface::class));
        $acc->record('gpt-5-mini', 'plan', []);

        $this->assertFalse($acc->hasUsage());
        $this->assertSame(0, $acc->total());
    }
}
```

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

```bash
php bin/phpunit tests/Unit/Service/Shared/AI/TokenUsageAccumulatorTest.php --testdox
```

Attendu : ÉCHEC — classe introuvable.

- [ ] **Étape 3 : écrire l'accumulateur**

Créer `src/Service/Shared/AI/TokenUsageAccumulator.php` :

```php
<?php

declare(strict_types=1);

namespace App\Service\Shared\AI;

use Psr\Log\LoggerInterface;

/**
 * Cumule la consommation de tokens d'une génération multi-appels.
 *
 * Une génération Slidia enchaîne plusieurs appels — condensation du document, plan,
 * un appel par lot de slides, notes d'intervenant — potentiellement sur des modèles
 * différents. L'accumulateur les collecte tous pour un débit unique en fin de
 * génération : ajouter un appel sans le compter devient une omission visible.
 *
 * Règle de comptage : on se fonde sur l'usage réel renvoyé par OpenAI, sans
 * coefficient par modèle. Les tokens de raisonnement sont DÉJÀ inclus dans
 * output_tokens ; ils sont donc facturés une fois, et la clé « reasoning » ne sert
 * qu'à la ventilation dans les logs.
 *
 * Cet objet est à état : en instancier un par génération, jamais en service partagé.
 */
final class TokenUsageAccumulator
{
    /** @var list<array{step: string, model: string, input: int, cached: int, output: int, reasoning: int, total: int}> */
    private array $calls = [];

    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    /**
     * Enregistre la consommation d'un appel.
     *
     * @param string $step  Étape métier, pour la ventilation (ex. « plan », « contenu-2 »)
     * @param array  $usage Résultat de OpenAIResponseParser::extractUsage()
     */
    public function record(string $model, string $step, array $usage): void
    {
        $total = (int) ($usage['total'] ?? 0);

        if ($total === 0) {
            // Anomalie : on ne pénalise pas l'utilisateur, mais on veut la trace.
            $this->logger->warning('Appel IA sans usage exploitable', [
                'model' => $model,
                'step'  => $step,
            ]);
        }

        $this->calls[] = [
            'step'      => $step,
            'model'     => $model,
            'input'     => (int) ($usage['input'] ?? 0),
            'cached'    => (int) ($usage['cached'] ?? 0),
            'output'    => (int) ($usage['output'] ?? 0),
            'reasoning' => (int) ($usage['reasoning'] ?? 0),
            'total'     => $total,
        ];
    }

    /** Tokens à débiter pour l'ensemble de la génération. */
    public function total(): int
    {
        return array_sum(array_column($this->calls, 'total'));
    }

    /**
     * Détail par appel, dans l'ordre d'exécution — destiné aux logs.
     *
     * @return list<array{step: string, model: string, input: int, cached: int, output: int, reasoning: int, total: int}>
     */
    public function breakdown(): array
    {
        return $this->calls;
    }

    public function callCount(): int
    {
        return count($this->calls);
    }

    /** Faux si aucun appel n'a renvoyé d'usage : l'appelant applique alors son plancher. */
    public function hasUsage(): bool
    {
        return $this->total() > 0;
    }
}
```

- [ ] **Étape 4 : lancer le test et vérifier qu'il passe**

```bash
php bin/phpunit tests/Unit/Service/Shared/AI/TokenUsageAccumulatorTest.php --testdox
```

Attendu : 6 tests verts.

- [ ] **Étape 5 : vérifier l'ensemble de la couche IA**

```bash
php bin/phpunit tests/Unit/Service/Shared/ --testdox
php bin/console lint:container
```

- [ ] **Étape 6 : commit**

```bash
git add src/Service/Shared/AI/TokenUsageAccumulator.php tests/Unit/Service/Shared/AI/TokenUsageAccumulatorTest.php
git commit -m "feat(ai): accumulateur de consommation de tokens pour les générations multi-appels"
```

---

## Task 8 — molécule `search-input`

**Files:**
- Create: `templates/_molecules/search-input/search-input.html.twig`
- Modify: `templates/_molecules/search-bar/search-bar.html.twig` (en-tête uniquement)
- Create: `templates/admin/design-system/molecules/search-input.html.twig`
- Modify: `src/Controller/Admin/DesignSystemController.php`
- Modify: `templates/admin/design-system/index.html.twig`

**Interfaces:**
- Produces: composant Twig appelé par `{% include '_molecules/search-input/search-input.html.twig' with {...} only %}`, paramètres `placeholder`, `value`, `name`, `id`, `action`, `class`.

**Source du markup :** `templates/scormia/index.html.twig` lignes 104-115, reprises à l'identique. **Ne pas modifier ce fichier.**

- [ ] **Étape 1 : créer la molécule**

Créer `templates/_molecules/search-input/search-input.html.twig` :

```twig
{#
 # ============================================================================
 # MOLECULE: SEARCH INPUT
 # ============================================================================
 #
 # Champ de recherche pill des bibliothèques d'outils (Moodia, Scormia, Slidia).
 # Sans comportement propre : le pilotage passe par `action`, qui pointe vers un
 # contrôleur Stimulus (voir shared--library-search).
 #
 # ============================================================================
 # COMPOSITION (Atomes utilisés)
 # ============================================================================
 #
 # - _atoms/icon/icon.html.twig (search)
 #
 # ============================================================================
 # PARAMÈTRES
 # ============================================================================
 #
 # | Paramètre   | Type   | Défaut         | Description                        |
 # |-------------|--------|----------------|------------------------------------|
 # | id          | string | (requis)       | ID stable, exigé par turbo-permanent|
 # | placeholder | string | 'Rechercher…'  | Texte du placeholder               |
 # | value       | string | ''             | Valeur courante (rendue serveur)   |
 # | name        | string | 'q'            | Nom du champ                       |
 # | action      | string | null           | Valeur de data-action (Stimulus)   |
 # | class       | string | ''             | Classes du conteneur               |
 #
 # ============================================================================
 # EXEMPLES D'UTILISATION
 # ============================================================================
 #
 # {% include '_molecules/search-input/search-input.html.twig' with {
 #     id: 'slidia-search',
 #     placeholder: 'Rechercher une présentation…',
 #     value: search,
 #     action: 'input->shared--library-search#search'
 # } only %}
 #
 # ============================================================================
 #}

{% set placeholder = placeholder ?? 'Rechercher…' %}
{% set value = value ?? '' %}
{% set name = name ?? 'q' %}
{% set action = action ?? null %}
{% set class = class ?? 'relative flex-1 md:max-w-md md:ml-auto' %}

<div class="{{ class }}">
    <span class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
        {% include '_atoms/icon/icon.html.twig' with { name: 'search', size: 'sm', color: 'current' } only %}
    </span>
    {# data-turbo-permanent : le champ garde le focus/valeur pendant la frappe malgré les visites Turbo. #}
    <input type="search"
           id="{{ id }}"
           name="{{ name }}"
           value="{{ value }}"
           placeholder="{{ placeholder }}"
           autocomplete="off"
           data-turbo-permanent
           {% if action %}data-action="{{ action }}"{% endif %}
           class="w-full h-12 md:h-full pl-11 pr-4 rounded-full bg-white border border-black/[0.08] shadow-[0_2px_8px_rgba(0,0,0,0.04)] text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:border-gray-300 focus:ring-4 focus:ring-gray-100 transition">
</div>
```

- [ ] **Étape 2 : déconseiller l'ancienne molécule**

Dans `templates/_molecules/search-bar/search-bar.html.twig`, ajouter juste après la ligne `# MOLECULE: SEARCH BAR` de l'en-tête :

```twig
 #
 # ⚠️ DÉCONSEILLÉ — n'est utilisé que par la page de démonstration du design system.
 # Ce composant embarque un <script> inline avec un id aléatoire, incompatible avec
 # la navigation Turbo. Pour les bibliothèques d'outils, utiliser
 # _molecules/search-input/ + le contrôleur Stimulus shared--library-search.
```

- [ ] **Étape 3 : créer la page de démonstration**

Créer `templates/admin/design-system/molecules/search-input.html.twig`. Ce fichier sert de **gabarit aux pages de démonstration des tâches 9 à 12** : reprendre exactement cette structure en changeant le titre, le composant rendu et le tableau de paramètres.

```twig
{% extends 'base.html.twig' %}

{% block title %}Design System — Search Input{% endblock %}

{% block body %}
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
    <a href="{{ path('app_admin_design_system') }}" class="text-sm text-gray-500 hover:text-gray-700">← Design System</a>

    <h1 class="text-3xl font-bold text-gray-900 font-secondary mt-4 mb-2">Search Input</h1>
    <p class="text-gray-500 mb-8">Champ de recherche des bibliothèques d'outils. Sans comportement propre : le pilotage passe par <code class="bg-gray-100 px-1 rounded">action</code>.</p>

    <h2 class="text-xl font-semibold text-gray-900 mb-4">Aperçu</h2>
    <div class="bg-white rounded-2xl border border-gray-100 p-8 mb-8">
        <div class="flex flex-col md:flex-row md:items-stretch gap-4">
            {% include '_molecules/search-input/search-input.html.twig' with {
                id: 'demo-search',
                placeholder: 'Rechercher un module…'
            } only %}
        </div>
    </div>

    <h2 class="text-xl font-semibold text-gray-900 mb-4">Avec une valeur pré-remplie</h2>
    <div class="bg-white rounded-2xl border border-gray-100 p-8 mb-8">
        <div class="flex flex-col md:flex-row md:items-stretch gap-4">
            {% include '_molecules/search-input/search-input.html.twig' with {
                id: 'demo-search-filled',
                placeholder: 'Rechercher une présentation…',
                value: 'rapport annuel'
            } only %}
        </div>
    </div>

    <h2 class="text-xl font-semibold text-gray-900 mb-4">Paramètres</h2>
    <div class="bg-white rounded-2xl border border-gray-100 overflow-hidden mb-8">
        <table class="w-full text-sm">
            <thead class="bg-gray-50 text-left text-gray-500">
                <tr><th class="px-6 py-3">Paramètre</th><th class="px-6 py-3">Type</th><th class="px-6 py-3">Défaut</th><th class="px-6 py-3">Description</th></tr>
            </thead>
            <tbody class="divide-y divide-gray-100">
                <tr><td class="px-6 py-3 font-mono">id</td><td class="px-6 py-3">string</td><td class="px-6 py-3">requis</td><td class="px-6 py-3">ID stable, exigé par turbo-permanent</td></tr>
                <tr><td class="px-6 py-3 font-mono">placeholder</td><td class="px-6 py-3">string</td><td class="px-6 py-3">'Rechercher…'</td><td class="px-6 py-3">Texte du placeholder</td></tr>
                <tr><td class="px-6 py-3 font-mono">value</td><td class="px-6 py-3">string</td><td class="px-6 py-3">''</td><td class="px-6 py-3">Valeur courante</td></tr>
                <tr><td class="px-6 py-3 font-mono">name</td><td class="px-6 py-3">string</td><td class="px-6 py-3">'q'</td><td class="px-6 py-3">Nom du champ</td></tr>
                <tr><td class="px-6 py-3 font-mono">action</td><td class="px-6 py-3">string</td><td class="px-6 py-3">null</td><td class="px-6 py-3">Valeur de data-action (Stimulus)</td></tr>
                <tr><td class="px-6 py-3 font-mono">class</td><td class="px-6 py-3">string</td><td class="px-6 py-3">conteneur par défaut</td><td class="px-6 py-3">Classes du conteneur</td></tr>
            </tbody>
        </table>
    </div>

    <h2 class="text-xl font-semibold text-gray-900 mb-4">Code</h2>
    <div class="bg-gray-900 rounded-2xl p-6 overflow-x-auto">
        <pre class="text-sm text-gray-100"><code>{% verbatim %}{% include '_molecules/search-input/search-input.html.twig' with {
    id: 'slidia-search',
    placeholder: 'Rechercher une présentation…',
    value: search,
    action: 'input->shared--library-search#search'
} only %}{% endverbatim %}</code></pre>
    </div>

    <p class="text-sm text-gray-500 mt-4">Fichier : <code class="bg-gray-100 px-1 rounded">templates/_molecules/search-input/search-input.html.twig</code></p>
</div>
{% endblock %}
```

Vérifier au préalable le nom exact de la route d'index du design system :

```bash
php bin/console debug:router | grep design_system | head -3
```

et ajuster `path('app_admin_design_system')` si le nom diffère.

- [ ] **Étape 4 : déclarer la route**

Dans `src/Controller/Admin/DesignSystemController.php`, ajouter une méthode sur le modèle des existantes :

```php
    #[Route('/molecules/search-input', name: 'app_admin_design_system_search_input')]
    public function searchInput(): Response
    {
        return $this->render('admin/design-system/molecules/search-input.html.twig');
    }
```

Ajouter l'entrée correspondante dans `templates/admin/design-system/index.html.twig`, dans la liste des molécules.

- [ ] **Étape 5 : vérifier**

```bash
php bin/console lint:twig templates/_molecules/search-input templates/admin/design-system
php bin/console debug:router | grep design_system_search_input
```

Attendu : `[OK] All 2 Twig files contain valid syntax.` et la route listée.

- [ ] **Étape 6 : commit**

```bash
git add templates/_molecules/search-input templates/_molecules/search-bar templates/admin/design-system src/Controller/Admin/DesignSystemController.php
git commit -m "feat(design-system): molécule search-input extraite de la bibliothèque Scormia"
```

---

## Task 9 — organisme `tool-header`

**Files:**
- Create: `templates/_organisms/tool-header/tool-header.html.twig`
- Create: `templates/admin/design-system/organisms/tool-header.html.twig`
- Modify: `src/Controller/Admin/DesignSystemController.php`
- Modify: `templates/admin/design-system/index.html.twig`

**Interfaces:**
- Produces: composant Twig utilisé via `{% embed '_organisms/tool-header/tool-header.html.twig' with {...} only %}` avec un bloc `actions` optionnel. Paramètres : `icon`, `title`, `description`, `color`, `badge`, `cost`, `primaryAction`, `class`.

**Source du markup :** `templates/scormia/index.html.twig` lignes 33-65 pour la structure, `templates/moodia/dashboard.html.twig` lignes 58-67 pour l'affichage du coût médian.

- [ ] **Étape 1 : créer l'organisme**

Créer `templates/_organisms/tool-header/tool-header.html.twig` :

```twig
{#
 # ============================================================================
 # ORGANISM: TOOL HEADER
 # ============================================================================
 #
 # En-tête de la bibliothèque d'un outil : pastille produit, titre, badge,
 # description, coût médian et actions.
 #
 # S'utilise avec {% embed %} pour surcharger le bloc `actions` quand un outil a
 # plusieurs boutons ; un simple `primaryAction` suffit dans le cas courant.
 #
 # ============================================================================
 # COMPOSITION (Atomes utilisés)
 # ============================================================================
 #
 # - _atoms/icon/icon.html.twig
 # - _atoms/badge/badge.html.twig
 # - _atoms/button/button.html.twig
 #
 # ============================================================================
 # PARAMÈTRES
 # ============================================================================
 #
 # | Paramètre     | Type   | Défaut   | Description                              |
 # |---------------|--------|----------|------------------------------------------|
 # | icon          | string | (requis) | Nom d'atome (ex. 'bubul-slidia')         |
 # | title         | string | (requis) | Nom de l'outil                           |
 # | description   | string | null     | Phrase de présentation                   |
 # | color         | object | null     | Entrée de tool_color_details             |
 # | badge         | object | null     | {label, variant}                         |
 # | cost          | object | null     | {value, unit} — coût médian en tokens    |
 # | primaryAction | object | null     | {label, href, icon, disabled}            |
 # | class         | string | 'mb-8'   | Classes du <header>                      |
 #
 # ============================================================================
 # BLOCS
 # ============================================================================
 #
 # | Bloc    | Description                                                     |
 # |---------|-----------------------------------------------------------------|
 # | actions | Zone de droite. Rend primaryAction par défaut.                  |
 #
 # ============================================================================
 # EXEMPLES D'UTILISATION
 # ============================================================================
 #
 # {% embed '_organisms/tool-header/tool-header.html.twig' with {
 #     icon: 'bubul-slidia', title: 'Slidia', color: tool_color_details['slidia'],
 #     description: 'Transformez vos idées en présentations.',
 #     primaryAction: { label: 'Créer une présentation', href: path('app_slidia_new'), icon: 'plus' }
 # } only %}
 #     {% block actions %}
 #         {# plusieurs boutons ici #}
 #     {% endblock %}
 # {% endembed %}
 #
 # ============================================================================
 #}

{% set class = class ?? 'mb-8' %}
{% set color = color ?? null %}
{% set badge = badge ?? null %}
{% set cost = cost ?? null %}
{% set description = description ?? null %}
{% set primaryAction = primaryAction ?? null %}

<header class="{{ class }}">
    <div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-5">
        <div class="flex items-center gap-5">
            <div class="shrink-0 w-16 h-16 flex items-center justify-center rounded-2xl border{% if not color %} bg-gradient-to-br from-primary/10 to-primary/5 border-primary/10 text-primary{% endif %}"
                 {% if color %}style="background: linear-gradient(135deg, {{ color.bg }}, color-mix(in srgb, {{ color.primary }} 6%, white)); border-color: {{ color.border }}; color: {{ color.primary }};"{% endif %}>
                {% include '_atoms/icon/icon.html.twig' with { name: icon, size: '3xl', colorPrimary: 'current', colorSecondary: 'current' } only %}
            </div>
            <div class="flex flex-col gap-1">
                <div class="flex items-center gap-2.5">
                    <h1 class="text-2xl sm:text-3xl font-bold text-black font-secondary tracking-tight">{{ title }}</h1>
                    {% if badge %}
                        {% include '_atoms/badge/badge.html.twig' with {
                            label: badge.label,
                            variant: badge.variant|default('warning'),
                            customColor: color ? color.primary : null,
                            size: 'sm'
                        } only %}
                    {% endif %}
                </div>
                {% if description %}
                    <p class="text-sm text-gray-500">{{ description }}</p>
                {% endif %}
                {% if cost and cost.value > 0 %}
                    <span class="hidden lg:inline-flex items-center gap-1 text-xs text-gray-400/70 truncate">
                        <span class="inline-flex items-center gap-0.5">
                            {% include '_atoms/icon/icon.html.twig' with { name: 'token', size: 'xs', color: 'current' } only %}
                            ~{{ cost.value|format_tokens }}
                        </span>
                        <span class="text-gray-300">/</span><span>{{ cost.unit }}</span>
                    </span>
                {% endif %}
            </div>
        </div>

        <div class="flex items-center gap-3 flex-shrink-0">
            {% block actions %}
                {% if primaryAction %}
                    {% include '_atoms/button/button.html.twig' with {
                        label: primaryAction.label,
                        variant: color ? 'custom-gradient' : 'primary',
                        gradientFrom: color ? color.primary : null,
                        gradientTo: color ? color.secondary : null,
                        size: 'lg',
                        icon: primaryAction.icon|default('plus'),
                        iconSize: 'lg',
                        href: primaryAction.href|default(null),
                        disabled: primaryAction.disabled|default(false),
                        radius: 'lg'
                    } only %}
                {% endif %}
            {% endblock %}
        </div>
    </div>
</header>
```

- [ ] **Étape 2 : créer la page de démonstration et la route**

Créer `templates/admin/design-system/organisms/tool-header.html.twig` en reprenant **le gabarit complet donné en tâche 8, étape 3**, avec **trois** rendus au lieu de deux : sans couleur d'outil (variante Moodia), avec couleur (variante Scormia), et avec badge + coût. Les rendus utilisent `{% embed %}` sans surcharger `actions`, plus un quatrième exemple qui surcharge `actions` avec deux boutons. Le tableau des paramètres reprend celui de l'en-tête du composant.

Ajouter dans `src/Controller/Admin/DesignSystemController.php` :

```php
    #[Route('/organisms/tool-header', name: 'app_admin_design_system_tool_header')]
    public function toolHeader(): Response
    {
        return $this->render('admin/design-system/organisms/tool-header.html.twig');
    }
```

Ajouter l'entrée dans `templates/admin/design-system/index.html.twig`.

- [ ] **Étape 3 : vérifier**

```bash
php bin/console lint:twig templates/_organisms/tool-header templates/admin/design-system
```

Attendu : syntaxe valide.

- [ ] **Étape 4 : commit**

```bash
git add templates/_organisms/tool-header templates/admin/design-system src/Controller/Admin/DesignSystemController.php
git commit -m "feat(design-system): organisme tool-header partagé entre les outils"
```

---

## Task 10 — organisme `tool-toolbar`

**Files:**
- Create: `templates/_organisms/tool-toolbar/tool-toolbar.html.twig`
- Create: `templates/admin/design-system/organisms/tool-toolbar.html.twig`
- Modify: `src/Controller/Admin/DesignSystemController.php`
- Modify: `templates/admin/design-system/index.html.twig`

**Interfaces:**
- Consumes: `_molecules/search-input/` (tâche 8), `_molecules/filter-tabs/` (existant)
- Produces: composant Twig utilisé via `{% embed %}` avec les blocs `leading` et `trailing`, tous deux optionnels. Paramètres : `filters`, `active`, `name`, `search`, `class`.

**Source du markup :** `templates/scormia/index.html.twig` lignes 93-116.

- [ ] **Étape 1 : créer l'organisme**

Créer `templates/_organisms/tool-toolbar/tool-toolbar.html.twig` :

```twig
{#
 # ============================================================================
 # ORGANISM: TOOL TOOLBAR
 # ============================================================================
 #
 # Ligne de filtres et de recherche des bibliothèques d'outils.
 #
 # Les deux zones sont indépendantes et facultatives : c'est ce qui permet à chaque
 # outil d'avoir des filtres plus ou moins riches sans dupliquer la barre. Moodia y
 # met des onglets et une recherche ; Slidia une recherche et un filtre de masque.
 #
 # items-stretch : la recherche prend EXACTEMENT la hauteur des onglets (h-full),
 # quel que soit le padding interne de la molécule.
 #
 # ============================================================================
 # COMPOSITION
 # ============================================================================
 #
 # - _molecules/filter-tabs/filter-tabs.html.twig (si `filters`)
 # - _molecules/search-input/search-input.html.twig (si `search`)
 #
 # ============================================================================
 # PARAMÈTRES
 # ============================================================================
 #
 # | Paramètre | Type   | Défaut    | Description                                 |
 # |-----------|--------|-----------|---------------------------------------------|
 # | filters   | array  | null      | Transmis à filter-tabs                      |
 # | active    | string | null      | Filtre actif                                |
 # | name      | string | 'filter'  | Nom du groupe de filtres                    |
 # | search    | object | null      | Transmis à search-input (id, placeholder, …)|
 # | class     | string | (voir ci-dessous) | Classes du conteneur                |
 #
 # ============================================================================
 # BLOCS
 # ============================================================================
 #
 # | Bloc     | Défaut                    | Description                        |
 # |----------|---------------------------|------------------------------------|
 # | leading  | filter-tabs si `filters`  | Zone de gauche                     |
 # | trailing | search-input si `search`  | Zone de droite                     |
 #
 # ============================================================================
 # EXEMPLES D'UTILISATION
 # ============================================================================
 #
 # {% embed '_organisms/tool-toolbar/tool-toolbar.html.twig' with {
 #     filters: [{ id: 'all', label: 'Tous', count: 12, href: path('app_moodia') }],
 #     active: typeFilter, name: 'generationType',
 #     search: { id: 'moodia-search', placeholder: 'Rechercher un cours…', value: search,
 #               action: 'input->shared--library-search#search' }
 # } only %}{% endembed %}
 #
 # ============================================================================
 #}

{% set filters = filters ?? null %}
{% set active = active ?? null %}
{% set name = name ?? 'filter' %}
{% set search = search ?? null %}
{% set class = class ?? 'flex flex-col md:flex-row md:items-stretch gap-3 md:gap-4 mb-6' %}

<div class="{{ class }}">
    {% block leading %}
        {% if filters %}
            <div class="shrink-0">
                {% include '_molecules/filter-tabs/filter-tabs.html.twig' with {
                    filters: filters,
                    active: active,
                    name: name
                } only %}
            </div>
        {% endif %}
    {% endblock %}

    {% block trailing %}
        {% if search %}
            {% include '_molecules/search-input/search-input.html.twig' with {
                id: search.id,
                placeholder: search.placeholder|default('Rechercher…'),
                value: search.value|default(''),
                name: search.name|default('q'),
                action: search.action|default(null)
            } only %}
        {% endif %}
    {% endblock %}
</div>
```

- [ ] **Étape 2 : créer la page de démonstration et la route**

Créer `templates/admin/design-system/organisms/tool-toolbar.html.twig` en reprenant **le gabarit complet donné en tâche 8, étape 3**, avec trois rendus : onglets + recherche, recherche seule, onglets seuls. Le tableau des paramètres reprend celui de l'en-tête du composant, et le code d'exemple utilise `{% embed %}`.

Ajouter dans `src/Controller/Admin/DesignSystemController.php` :

```php
    #[Route('/organisms/tool-toolbar', name: 'app_admin_design_system_tool_toolbar')]
    public function toolToolbar(): Response
    {
        return $this->render('admin/design-system/organisms/tool-toolbar.html.twig');
    }
```

Ajouter l'entrée dans l'index du design system.

- [ ] **Étape 3 : vérifier**

```bash
php bin/console lint:twig templates/_organisms/tool-toolbar templates/admin/design-system
```

- [ ] **Étape 4 : commit**

```bash
git add templates/_organisms/tool-toolbar templates/admin/design-system src/Controller/Admin/DesignSystemController.php
git commit -m "feat(design-system): organisme tool-toolbar (filtres et recherche)"
```

---

## Task 11 — organisme `choice-page`

**Files:**
- Create: `templates/_organisms/choice-page/choice-page.html.twig`
- Create: `templates/admin/design-system/organisms/choice-page.html.twig`
- Modify: `src/Controller/Admin/DesignSystemController.php`
- Modify: `templates/admin/design-system/index.html.twig`

**Interfaces:**
- Consumes: `_organisms/choice-card/choice-card.html.twig` (existant), `_atoms/button/button.html.twig` (variante `back`)
- Produces: composant Twig appelé par `{% include %}`. Paramètres : `icon`, `color`, `title`, `subtitle`, `choices`, `backLabel`, `backHref`, `class`.

**Source du markup :** `templates/scormia/new.html.twig` lignes 1-93 et `templates/moodia/new.html.twig` lignes 37-54.

Chaque entrée de `choices` est un jeu de paramètres de `choice-card` : `title`, `description`, `icon`, `color`, `colorHex`, `features`, `href`, `action`, `disabled`, `badge`, `badgeIcon`, `badgeHref`, `badgeVariant`.

- [ ] **Étape 1 : créer l'organisme**

Créer `templates/_organisms/choice-page/choice-page.html.twig` :

```twig
{#
 # ============================================================================
 # ORGANISM: CHOICE PAGE
 # ============================================================================
 #
 # Page « comment voulez-vous commencer ? » : en-tête centré, cartes de méthode,
 # bouton de retour. Gabarit commun aux pages de création des outils.
 #
 # ============================================================================
 # COMPOSITION
 # ============================================================================
 #
 # - _atoms/icon/icon.html.twig
 # - _organisms/choice-card/choice-card.html.twig
 # - _atoms/button/button.html.twig (variant: back)
 #
 # ============================================================================
 # PARAMÈTRES
 # ============================================================================
 #
 # | Paramètre | Type   | Défaut    | Description                                  |
 # |-----------|--------|-----------|----------------------------------------------|
 # | icon      | string | (requis)  | Nom d'atome de l'outil                       |
 # | color     | object | null      | Entrée de tool_color_details                 |
 # | title     | string | (requis)  | Titre de la page                             |
 # | subtitle  | string | null      | Sous-titre                                   |
 # | choices   | array  | (requis)  | Jeux de paramètres de choice-card            |
 # | backLabel | string | 'Retour'  | Libellé du bouton de retour                  |
 # | backHref  | string | (requis)  | Cible du bouton de retour                    |
 # | class     | string | (voir ci-dessous) | Classes du conteneur                 |
 #
 # ============================================================================
 # EXEMPLES D'UTILISATION
 # ============================================================================
 #
 # {% include '_organisms/choice-page/choice-page.html.twig' with {
 #     icon: 'bubul-slidia', color: tool_color_details['slidia'],
 #     title: 'Créez votre présentation',
 #     subtitle: 'Choisissez votre méthode.',
 #     choices: [
 #         { title: 'Importer un document', description: '…', icon: 'document', color: 'primary',
 #           features: [{ text: 'Structure automatique' }], href: path('app_slidia_configure', {method: 'pdf'}) }
 #     ],
 #     backLabel: 'Retour à mes présentations', backHref: path('app_slidia')
 # } only %}
 #
 # ============================================================================
 #}

{% set color = color ?? null %}
{% set subtitle = subtitle ?? null %}
{% set backLabel = backLabel ?? 'Retour' %}
{% set class = class ?? 'min-h-screen max-w-6xl mx-auto px-4 sm:px-6 py-4 lg:py-8' %}
{% set columns = choices|length >= 3 ? 'lg:grid-cols-3' : (choices|length == 2 ? 'lg:grid-cols-2' : 'lg:grid-cols-1') %}

<div class="{{ class }}">
    <header class="text-center mb-12">
        <div class="inline-flex items-center justify-center w-20 h-20 mb-6 rounded-3xl border shadow-lg{% if not color %} bg-gradient-to-br from-primary/10 via-primary/5 to-transparent border-primary/10 shadow-primary/5 text-primary{% else %} border-gray-100{% endif %}"
             {% if color %}style="background: {{ color.bg }}; color: {{ color.primary }};"{% endif %}>
            {% include '_atoms/icon/icon.html.twig' with { name: icon, size: '3xl', colorPrimary: 'current', colorSecondary: 'current' } only %}
        </div>
        <h1 class="text-3xl lg:text-4xl font-bold text-gray-900 font-secondary mb-3">{{ title }}</h1>
        {% if subtitle %}
            <p class="text-lg text-gray-500 max-w-xl mx-auto">{{ subtitle }}</p>
        {% endif %}
    </header>

    <div class="grid grid-cols-1 {{ columns }} gap-8">
        {% for choice in choices %}
            {% include '_organisms/choice-card/choice-card.html.twig' with choice only %}
        {% endfor %}
    </div>

    <div class="flex justify-center mt-12">
        {% include '_atoms/button/button.html.twig' with {
            label: backLabel,
            variant: 'back',
            href: backHref
        } only %}
    </div>
</div>
```

- [ ] **Étape 2 : créer la page de démonstration et la route**

Créer `templates/admin/design-system/organisms/choice-page.html.twig` en reprenant **le gabarit complet donné en tâche 8, étape 3**, avec un rendu à trois choix et un rendu à deux choix, pour montrer l'adaptation de la grille. Le tableau des paramètres reprend celui de l'en-tête du composant.

Ajouter dans `src/Controller/Admin/DesignSystemController.php` :

```php
    #[Route('/organisms/choice-page', name: 'app_admin_design_system_choice_page')]
    public function choicePage(): Response
    {
        return $this->render('admin/design-system/organisms/choice-page.html.twig');
    }
```

Ajouter l'entrée dans l'index du design system.

- [ ] **Étape 3 : vérifier**

```bash
php bin/console lint:twig templates/_organisms/choice-page templates/admin/design-system
```

- [ ] **Étape 4 : commit**

```bash
git add templates/_organisms/choice-page templates/admin/design-system src/Controller/Admin/DesignSystemController.php
git commit -m "feat(design-system): organisme choice-page pour les pages de création"
```

---

## Task 12 — organisme `entity-card`

**Files:**
- Create: `templates/_organisms/entity-card/entity-card.html.twig`
- Create: `templates/admin/design-system/organisms/entity-card.html.twig`
- Modify: `src/Controller/Admin/DesignSystemController.php`
- Modify: `templates/admin/design-system/index.html.twig`

**Interfaces:**
- Produces: composant Twig utilisé via `{% embed %}` avec les blocs `badge`, `body`, `footerLeft`, `footerRight`, `menu`. Paramètres : `href`, `accent`, `attr`, `class`.

**Source du markup :** `templates/scormia/_module_card.html.twig` lignes 15-45, dont les classes sont identiques à celles de la carte inline de `templates/moodia/dashboard.html.twig` lignes 168-176.

**Aucun appelant dans cette étape** : le premier consommateur sera la carte de présentation de Slidia, à l'étape 3. La page de démonstration sert de vérification.

- [ ] **Étape 1 : créer l'organisme**

Créer `templates/_organisms/entity-card/entity-card.html.twig` :

```twig
{#
 # ============================================================================
 # ORGANISM: ENTITY CARD
 # ============================================================================
 #
 # Coque de carte des bibliothèques d'outils : liseré d'accent au survol, corps,
 # pied daté, emplacement du menu contextuel.
 #
 # Le markup était dupliqué à l'identique entre la carte Moodia et la carte
 # Scormia ; ce composant en est l'extraction. Chaque outil ne fournit plus que
 # son contenu, via les blocs.
 #
 # ============================================================================
 # PARAMÈTRES
 # ============================================================================
 #
 # | Paramètre | Type   | Défaut   | Description                                 |
 # |-----------|--------|----------|---------------------------------------------|
 # | href      | string | (requis) | Cible de la carte                           |
 # | accent    | string | null     | Couleur hex du liseré et du titre au survol |
 # | attr      | object | {}       | Attributs data du conteneur                 |
 # | class     | string | ''       | Classes additionnelles du conteneur         |
 #
 # ============================================================================
 # BLOCS
 # ============================================================================
 #
 # | Bloc        | Description                                    |
 # |-------------|------------------------------------------------|
 # | badge       | Pastille de type, en haut du corps             |
 # | body        | Titre et métadonnées                           |
 # | footerLeft  | Pied, à gauche (date en général)               |
 # | footerRight | Pied, à droite (tokens, énergie)               |
 # | menu        | Menu contextuel, positionné en absolu          |
 #
 # ============================================================================
 # EXEMPLES D'UTILISATION
 # ============================================================================
 #
 # {% embed '_organisms/entity-card/entity-card.html.twig' with {
 #     href: path('app_slidia_editor', {uuid: presentation.uuid}),
 #     accent: '#F59E0B',
 #     attr: { 'data-presentation-id': presentation.id }
 # } only %}
 #     {% block body %}<h3>…</h3>{% endblock %}
 # {% endembed %}
 #
 # ============================================================================
 #}

{% set accent = accent ?? null %}
{% set attr = attr ?? {} %}
{% set class = class ?? '' %}

<div class="relative h-full {{ class }}"
     {% if accent %}style="--acc: {{ accent }};"{% endif %}
     {% for key, val in attr %}{{ key }}="{{ val }}" {% endfor %}>
    <a href="{{ href }}"
       class="group relative flex flex-col h-full bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden min-h-[320px] transition-all duration-300 ease-out hover:shadow-lg hover:shadow-gray-200/50 hover:-translate-y-1 hover:border-gray-200">

        {# Liseré d'accent, révélé au survol. #}
        <div class="absolute top-0 left-0 right-0 h-1 scale-x-0 group-hover:scale-x-100 origin-left transition-transform duration-500 ease-out"
             style="background: {{ accent ? 'var(--acc)' : 'var(--color-primary)' }};"></div>

        <div class="flex-grow p-8 pb-6">
            {% block badge %}{% endblock %}
            {% block body %}{% endblock %}
        </div>

        <div class="flex items-center justify-between px-8 py-5 border-t border-gray-100 bg-gray-50/30 mt-auto">
            <span class="text-xs text-gray-400">{% block footerLeft %}{% endblock %}</span>
            <span class="flex items-center gap-4">{% block footerRight %}{% endblock %}</span>
        </div>
    </a>

    {% block menu %}{% endblock %}
</div>
```

- [ ] **Étape 2 : créer la page de démonstration et la route**

Créer `templates/admin/design-system/organisms/entity-card.html.twig` en reprenant **le gabarit complet donné en tâche 8, étape 3**. L'aperçu est une grille `grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6` de trois cartes remplies via `{% embed %}`, chacune avec un accent différent (`#0C81E4`, `#1E40AF`, `#F59E0B`) et les cinq blocs renseignés, pour prouver que chacun fonctionne. Le tableau des paramètres reprend celui de l'en-tête du composant, complété d'un second tableau listant les blocs.

Ajouter dans `src/Controller/Admin/DesignSystemController.php` :

```php
    #[Route('/organisms/entity-card', name: 'app_admin_design_system_entity_card')]
    public function entityCard(): Response
    {
        return $this->render('admin/design-system/organisms/entity-card.html.twig');
    }
```

Ajouter l'entrée dans l'index du design system.

- [ ] **Étape 3 : vérifier**

```bash
php bin/console lint:twig templates/_organisms/entity-card templates/admin/design-system
```

- [ ] **Étape 4 : commit**

```bash
git add templates/_organisms/entity-card templates/admin/design-system src/Controller/Admin/DesignSystemController.php
git commit -m "feat(design-system): organisme entity-card, coque de carte partagée"
```

---

## Task 13 — contrôleur Stimulus de recherche

**Files:**
- Create: `assets/controllers/shared/library_search_controller.js`

**Interfaces:**
- Produces: contrôleur Stimulus `shared--library-search`, action `search`, valeurs `param` (défaut `'q'`) et `delay` (défaut `300`).

**Source du comportement :** `assets/controllers/scormia/library_controller.js` méthode `search()` (lignes 36-54). **Ne pas modifier ce fichier** : Scormia conserve son implémentation.

**Enregistrement :** aucun. `assets/bootstrap.js` auto-enregistre récursivement `assets/controllers/**`, le sous-dossier donnant l'identifiant `shared--library-search`.

- [ ] **Étape 1 : créer le contrôleur**

Créer `assets/controllers/shared/library_search_controller.js` :

```javascript
import { Controller } from '@hotwired/stimulus';

/**
 * Recherche serveur des bibliothèques d'outils.
 *
 * Reconstruit l'URL courante avec le paramètre de recherche et navigue via Turbo
 * en mode « replace » : l'historique n'est pas pollué par chaque frappe.
 *
 * Le champ doit porter data-turbo-permanent pour conserver focus et valeur
 * pendant la visite.
 *
 * Usage :
 *   <div data-controller="shared--library-search">
 *     <input data-action="input->shared--library-search#search">
 */
export default class extends Controller {
    static values = {
        param: { type: String, default: 'q' },
        delay: { type: Number, default: 300 },
    };

    disconnect() {
        clearTimeout(this.timer);
    }

    search(event) {
        clearTimeout(this.timer);

        const term = (event.target.value || '').trim();

        this.timer = setTimeout(() => {
            const url = new URL(window.location.href);

            // Une nouvelle recherche repart de la première page : rester sur la
            // page 4 d'un résultat qui n'en compte qu'une afficherait du vide.
            url.searchParams.delete('page');

            if (term === '') {
                url.searchParams.delete(this.paramValue);
            } else {
                url.searchParams.set(this.paramValue, term);
            }

            if (window.Turbo) {
                window.Turbo.visit(url.toString(), { action: 'replace' });
            } else {
                window.location.assign(url.toString());
            }
        }, this.delayValue);
    }
}
```

- [ ] **Étape 2 : compiler les assets**

```bash
npm run build
```

Attendu : compilation sans erreur.

- [ ] **Étape 3 : vérifier l'absence de régression sur Scormia**

```bash
git status --short assets/controllers/scormia/
```

Attendu : aucune modification.

- [ ] **Étape 4 : commit**

```bash
git add assets/controllers/shared/library_search_controller.js
git commit -m "feat(assets): contrôleur Stimulus de recherche serveur partagé"
```

---

## Task 14 — vérification de bout en bout

**Files:** aucun (vérification seule)

- [ ] **Étape 1 : suite de tests complète**

```bash
php bin/phpunit --testdox
```

Attendu : aucune régression par rapport à l'état de départ de la branche. Si des tests fonctionnels échouent faute de base de données, le noter explicitement et vérifier au minimum :

```bash
php bin/phpunit tests/Unit/ --testdox
```

- [ ] **Étape 2 : conteneur et templates**

```bash
php bin/console lint:container
php bin/console lint:twig templates/
```

Attendu : les deux OK.

- [ ] **Étape 3 : vérifier qu'aucun fichier Scormia n'a été modifié**

```bash
git diff --name-only main...HEAD | grep -i scormia
```

Attendu : **aucun résultat**. Si un fichier apparaît, le restaurer.

- [ ] **Étape 4 : vérifier l'absence de constantes mortes**

```bash
grep -rn "PRESENTATIONS_PER_PAGE\|MOODIA_COURSES_PER_PAGE" src/ templates/ assets/ tests/
grep -rn "App.Exception.Slidia" src/ tests/
```

Attendu : aucun résultat pour les deux.

- [ ] **Étape 5 : compilation des assets**

```bash
npm run build
```

- [ ] **Étape 6 : commit final**

```bash
git commit --allow-empty -m "chore(slidia): socle de l'étape 1 vérifié de bout en bout"
```

---

## Ce que l'étape 1 laisse volontairement inachevé

Ces points ne sont **pas** des oublis. Ils relèvent des étapes suivantes :

- `SlidiaPresentationRepository::findByUserPaginated()` n'a pas encore d'appelant — le contrôleur Slidia est branché à l'étape 3, qui refond la page.
- `TokenUsageAccumulator` n'a pas encore d'appelant — il est câblé à l'étape 2, avec la génération multi-appels.
- Le champ de recherche de Moodia n'est pas affiché — le contrôleur lit déjà `?q=`, le template l'affiche à l'étape 3.
- Les composants Twig n'ont aucun consommateur en dehors du design system — les pages d'outils les adoptent à l'étape 3.
- La suppression des catégories et de l'épinglage de Slidia se fait à l'étape 3, dans une migration unique.
