# Page publique /ateliers (DA 2.0) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Créer la page vitrine publique `/ateliers` affichant les ateliers actifs et leurs prochains créneaux avec places restantes, CTA contact en intérim (spec : `docs/superpowers/specs/2026-07-20-page-ateliers-design.md`).

**Architecture:** Server-side pur — nouveau controller public `App\Controller\AtelierController` + méthode repository fetch-join anti-N+1 + template Twig DA 2.0 réutilisant le langage visuel existant (`v2-phero`, `pres-split`, `nhp-chip`, `nhp-cta-band`). Le controller prépare des données présentables (dates FR, durée, prix, places) ; le template reste bête. Aucune écriture DB, aucun endpoint JSON.

**Tech Stack:** Symfony (attributes routing), Doctrine ORM (QueryBuilder DQL), Twig, PHPUnit WebTestCase (MySQL réel + TRUNCATE), CSS vanilla tokens `--v2-*`.

## Global Constraints

- Toute UI en français, cohérente DA 2.0 (tokens `--v2-*`, classes `nhp-*`/`v2-*` réutilisées, nouvelles classes préfixées `.atl-`).
- Page 100 % lecture seule : aucun formulaire, aucune écriture DB.
- Pas d'endpoint JSON créneaux (YAGNI — sous-projet 2).
- Suite PHPUnit existante (137 tests) doit rester verte à chaque commit.
- Prérequis exécution tests : MySQL local démarré (`mysqld`), mailer null en test.
- Commande tests : `php vendor/phpunit/phpunit/phpunit` (Windows-safe).
- Commits atomiques par tâche, messages français style repo (`feat:`, `fix:`, `test:`).
- Fichiers modifiés partagés avec travail non commité en cours (`base.html.twig`, `ds-v2-pages.css`) : éditions chirurgicales, `git add` ciblé par fichier autorisé car chaque commit n'ajoute que les fichiers de sa tâche.

## File Structure

| Fichier | Action | Responsabilité |
|---|---|---|
| `src/Repository/AtelierRepository.php` | Modifier | + `findActifsAvecCreneauxAVenir()` — requête unique fetch-join |
| `src/Controller/AtelierController.php` | Créer | Route `GET /ateliers`, préparation données présentables |
| `templates/ateliers/index.html.twig` | Créer | Page complète DA 2.0 (hero, sections, créneaux, états vides, bandeau) |
| `templates/base.html.twig` | Modifier | Entrée nav « Ateliers » desktop (~l.184) + mobile (~l.402) |
| `public/styles/ds-v2-pages.css` | Modifier | Section `.atl-*` (append fin de fichier) |
| `tests/Gestion/AtelierPublicTest.php` | Créer | 8 tests fonctionnels page publique |

---

### Task 1: Données + controller + template sémantique

**Files:**
- Modify: `src/Repository/AtelierRepository.php`
- Create: `src/Controller/AtelierController.php`
- Create: `templates/ateliers/index.html.twig`
- Test: `tests/Gestion/AtelierPublicTest.php`

**Interfaces:**
- Consomme : entités existantes `Atelier`, `AtelierCreneau::getPlacesRestantes()`, `CreneauStatut::OUVERT`, trait `GestionTestHelper::purgeGestionTables()`.
- Produit : route `app_ateliers` (`GET /ateliers`) ; `AtelierRepository::findActifsAvecCreneauxAVenir(): array` (Atelier[] avec collections `creneaux` filtrées/triées et `reservations` hydratées) ; template avec marqueurs `data-atl-atelier`, `data-atl-slot`, `data-atl-places`, `data-atl-nodates`, `data-atl-empty` (utilisés par les tests et T3).

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

Créer `tests/Gestion/AtelierPublicTest.php` :

```php
<?php

namespace App\Tests\Gestion;

use App\Entity\Atelier;
use App\Entity\AtelierCreneau;
use App\Entity\AtelierReservation;
use App\Enum\CreneauStatut;
use App\Enum\ReservationStatut;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

/**
 * Page publique /ateliers : vitrine des ateliers actifs, créneaux à venir
 * avec places restantes, états vides.
 */
class AtelierPublicTest extends WebTestCase
{
    use GestionTestHelper;

    private KernelBrowser $client;
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->client = static::createClient();
        $this->em = static::getContainer()->get('doctrine')->getManager();
        $this->purgeGestionTables($this->em);
    }

    private function makeAtelier(string $titre = 'Création de couteau', bool $actif = true, ?string $description = null): Atelier
    {
        $atelier = (new Atelier())
            ->setTitre($titre)
            ->setDescription($description)
            ->setPrix('120.00')
            ->setDureeMinutes(180)
            ->setCapaciteDefaut(6)
            ->setActif($actif);
        $this->em->persist($atelier);
        $this->em->flush();

        return $atelier;
    }

    private function makeCreneau(
        Atelier $atelier,
        string $start = '+7 days 10:00',
        int $capacite = 6,
        CreneauStatut $statut = CreneauStatut::OUVERT,
    ): AtelierCreneau {
        $creneau = (new AtelierCreneau())
            ->setAtelier($atelier)
            ->setStartAt(new \DateTimeImmutable($start))
            ->setCapacite($capacite)
            ->setStatut($statut);
        $this->em->persist($creneau);
        $this->em->flush();

        return $creneau;
    }

    private function makeReservation(
        AtelierCreneau $creneau,
        int $places = 2,
        ReservationStatut $statut = ReservationStatut::CONFIRMEE,
    ): AtelierReservation {
        $reservation = (new AtelierReservation())
            ->setCreneau($creneau)
            ->setNom('Testeur')
            ->setPrenom('Marie')
            ->setPlaces($places)
            ->setStatut($statut);
        $this->em->persist($reservation);
        $this->em->flush();

        return $reservation;
    }

    public function testPageAccessibleEtAtelierActifVisible(): void
    {
        $this->makeAtelier('Création de couteau unique', true, "Forgez votre lame.\nRepartez avec votre couteau.");

        $crawler = $this->client->request('GET', '/ateliers');

        $this->assertResponseIsSuccessful();
        $this->assertCount(1, $crawler->filter('[data-atl-atelier]'));
        $section = $crawler->filter('[data-atl-atelier]')->text();
        $this->assertStringContainsString('Création de couteau unique', $section);
        $this->assertStringContainsString('Forgez votre lame.', $section);
        $this->assertStringContainsString('3h', $section);    // 180 minutes formatées
        $this->assertStringContainsString('120 €', $section); // "120.00" formaté
    }

    public function testAtelierInactifAbsent(): void
    {
        $this->makeAtelier('Atelier privé', false);

        $crawler = $this->client->request('GET', '/ateliers');

        $this->assertResponseIsSuccessful();
        $this->assertCount(0, $crawler->filter('[data-atl-atelier]'));
        $this->assertStringNotContainsString('Atelier privé', $this->client->getResponse()->getContent());
    }

    public function testCreneauFuturAffichePlacesRestantes(): void
    {
        $atelier = $this->makeAtelier();
        $creneau = $this->makeCreneau($atelier, '+7 days 10:00', 6);
        $this->makeReservation($creneau, 2);
        $this->makeReservation($creneau, 1, ReservationStatut::ANNULEE); // ne compte pas

        $crawler = $this->client->request('GET', '/ateliers');

        $this->assertCount(1, $crawler->filter('[data-atl-slot]'));
        $this->assertSame('4 places', trim($crawler->filter('[data-atl-places]')->text()));
    }

    public function testCreneauxPassesFermesAnnulesAbsents(): void
    {
        $atelier = $this->makeAtelier();
        $this->makeCreneau($atelier, '-2 days 10:00');
        $this->makeCreneau($atelier, '+3 days 10:00', 6, CreneauStatut::FERME);
        $this->makeCreneau($atelier, '+4 days 10:00', 6, CreneauStatut::ANNULE);

        $crawler = $this->client->request('GET', '/ateliers');

        $this->assertCount(0, $crawler->filter('[data-atl-slot]'));
        $this->assertCount(1, $crawler->filter('[data-atl-nodates]'));
    }

    public function testCreneauCompletBadge(): void
    {
        $atelier = $this->makeAtelier();
        $creneau = $this->makeCreneau($atelier, '+7 days 10:00', 4);
        $this->makeReservation($creneau, 4);

        $crawler = $this->client->request('GET', '/ateliers');

        $this->assertSame('Complet', trim($crawler->filter('[data-atl-places]')->text()));
        $this->assertCount(1, $crawler->filter('.atl-slot--full'));
    }

    public function testMaxCinqCreneauxAffiches(): void
    {
        $atelier = $this->makeAtelier();
        for ($i = 1; $i <= 7; ++$i) {
            $this->makeCreneau($atelier, sprintf('+%d days 10:00', $i));
        }

        $crawler = $this->client->request('GET', '/ateliers');

        $this->assertCount(5, $crawler->filter('[data-atl-slot]'));
    }

    public function testEtatVideSansAtelier(): void
    {
        $crawler = $this->client->request('GET', '/ateliers');

        $this->assertResponseIsSuccessful();
        $this->assertCount(1, $crawler->filter('[data-atl-empty]'));
    }
}
```

- [ ] **Step 2 : Vérifier l'échec**

Run : `php vendor/phpunit/phpunit/phpunit tests/Gestion/AtelierPublicTest.php`
Attendu : FAIL — les 7 tests en erreur 404 (route `/ateliers` inexistante).

- [ ] **Step 3 : Méthode repository**

Dans `src/Repository/AtelierRepository.php`, ajouter l'import et la méthode :

```php
use App\Enum\CreneauStatut;
```

```php
/**
 * Ateliers actifs avec leurs créneaux OUVERT à venir (triés) et les
 * réservations de ces créneaux — une seule requête, aucun N+1.
 * Les collections `creneaux` sont partiellement hydratées (créneaux
 * futurs ouverts uniquement) : ne pas réutiliser pour de l'admin.
 *
 * @return Atelier[]
 */
public function findActifsAvecCreneauxAVenir(): array
{
    return $this->createQueryBuilder('a')
        ->select('a', 'c', 'r')
        ->leftJoin('a.creneaux', 'c', 'WITH', 'c.statut = :ouvert AND c.startAt > :now')
        ->leftJoin('c.reservations', 'r')
        ->andWhere('a.actif = true')
        ->orderBy('a.createdAt', 'ASC')
        ->addOrderBy('c.startAt', 'ASC')
        ->setParameter('ouvert', CreneauStatut::OUVERT)
        ->setParameter('now', new \DateTimeImmutable())
        ->getQuery()
        ->getResult();
}
```

- [ ] **Step 4 : Controller**

Créer `src/Controller/AtelierController.php` :

```php
<?php

namespace App\Controller;

use App\Entity\AtelierCreneau;
use App\Repository\AtelierRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Page publique « Ateliers & stages » (DA 2.0) : vitrine des ateliers actifs
 * et de leurs prochains créneaux. La réservation en ligne arrivera avec la
 * refonte du panier — le CTA renvoie vers le contact en attendant.
 */
class AtelierController extends AbstractController
{
    /** Nombre maximal de créneaux à venir affichés par atelier. */
    private const MAX_CRENEAUX = 5;

    private const MOIS_ABREGES = [1 => 'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];

    #[Route('/ateliers', name: 'app_ateliers')]
    public function index(AtelierRepository $ateliers): Response
    {
        $items = [];
        foreach ($ateliers->findActifsAvecCreneauxAVenir() as $atelier) {
            $items[] = [
                'atelier' => $atelier,
                'duree' => $this->formatDuree($atelier->getDureeMinutes()),
                'prix' => $this->formatPrix((string) $atelier->getPrix()),
                'creneaux' => array_map(
                    fn (AtelierCreneau $c) => [
                        'jour' => $c->getStartAt()->format('d'),
                        'mois' => self::MOIS_ABREGES[(int) $c->getStartAt()->format('n')],
                        'heure' => $c->getStartAt()->format('H\hi'),
                        'placesRestantes' => $c->getPlacesRestantes(),
                    ],
                    \array_slice($atelier->getCreneaux()->toArray(), 0, self::MAX_CRENEAUX),
                ),
            ];
        }

        return $this->render('ateliers/index.html.twig', ['items' => $items]);
    }

    /** 180 → « 3h », 90 → « 1h30 », 45 → « 45 min ». */
    private function formatDuree(int $minutes): string
    {
        $h = intdiv($minutes, 60);
        $m = $minutes % 60;
        if (0 === $h) {
            return $m.' min';
        }

        return $m > 0 ? sprintf('%dh%02d', $h, $m) : $h.'h';
    }

    /** « 120.00 » → « 120 € », « 45.50 » → « 45,50 € ». */
    private function formatPrix(string $prix): string
    {
        $valeur = number_format((float) $prix, 2, ',', ' ');
        $valeur = str_ends_with($valeur, ',00') ? substr($valeur, 0, -3) : $valeur;

        return $valeur.' €';
    }
}
```

- [ ] **Step 5 : Template sémantique**

Créer `templates/ateliers/index.html.twig` (structure complète, le polish CSS vient en T3 — les classes sont déjà posées) :

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

{% block title %}Ateliers & stages de coutellerie — Vulcan' Affûtage{% endblock %}
{% block metaDesc %}Participez à un atelier de coutellerie à Clermont-Ferrand : création de couteau et initiation à l'affûtage en petit groupe, guidé par un artisan depuis 1981.{% endblock %}
{% block canonical %}ateliers{% endblock %}

{% block headerAteliers %}gColor{% endblock %}

{% block stylesheets %}
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..700;1,9..144,400..600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="{{ asset('styles/ds-v2.css') }}">
    <link rel="stylesheet" href="{{ asset('styles/ds-v2-pages.css') }}">
{% endblock %}

{% block body %}
<main class="nhp-main">

    {# ============================ HERO ============================ #}
    <section class="v2-phero v2-phero--tall" data-v2-hero>
        <div class="v2-phero-bg"><img src="/assets/photo_boutique.webp" alt="" aria-hidden="true"></div>
        <div class="nhp-wrap">
            <span class="nhp-eyebrow">Ateliers & stages · Clermont-Ferrand</span>
            <h1 class="v2-phero-title">Forgez votre propre <em>lame</em>.</h1>
            <p class="v2-phero-sub">Apprenez les gestes de la coutellerie aux côtés d'un artisan : des ateliers en petit groupe, au cœur d'un atelier en activité depuis 1981.</p>
        </div>
    </section>

    {% if items is empty %}
    {# ========================= ÉTAT VIDE ========================= #}
    <section class="nhp-section nhp-section--light">
        <div class="nhp-wrap">
            <div class="atl-empty nhp-reveal" data-atl-empty>
                <span class="nhp-eyebrow">Bientôt</span>
                <h2 class="nhp-h2">Les prochains ateliers arrivent.</h2>
                <div class="v2-prose" style="margin-top:16px;max-width:60ch;margin-inline:auto;">
                    <p>Les prochaines sessions sont en préparation. Contactez-nous pour être informé des dates à venir ou organiser un atelier sur mesure.</p>
                </div>
                <a href="{{ path('app_contact') }}" class="nhp-btn nhp-btn--primary" style="margin-top:22px;">Nous contacter</a>
            </div>
        </div>
    </section>
    {% endif %}

    {# ==================== SECTIONS PAR ATELIER ==================== #}
    {% for item in items %}
    <section class="nhp-section {{ loop.index is odd ? 'nhp-section--light' : 'nhp-section--paper' }}" data-atl-atelier>
        <div class="nhp-wrap">
            <div class="pres-split {{ loop.index is even ? 'pres-split--rev' }}">
                <div class="nhp-reveal">
                    <span class="nhp-eyebrow">Atelier</span>
                    <h2 class="nhp-h2">{{ item.atelier.titre }}</h2>
                    {% if item.atelier.description %}
                    <div class="v2-prose atl-desc"><p>{{ item.atelier.description|nl2br }}</p></div>
                    {% endif %}

                    <ul class="atl-chips" role="list">
                        <li class="nhp-chip">{{ item.duree }}</li>
                        <li class="nhp-chip nhp-chip--dispo">{{ item.prix }} / pers.</li>
                        <li class="nhp-chip">{{ item.atelier.capaciteDefaut }} places max</li>
                        <li class="nhp-chip">Clermont-Ferrand</li>
                    </ul>

                    {% if item.creneaux is not empty %}
                    <div class="atl-slots-head"><span class="nhp-eyebrow">Prochaines dates</span></div>
                    <div class="atl-slots" role="list">
                        {% for c in item.creneaux %}
                        <div class="atl-slot {{ c.placesRestantes == 0 ? 'atl-slot--full' }}" role="listitem" data-atl-slot>
                            <span class="atl-slot-day">{{ c.jour }}</span>
                            <span class="atl-slot-month">{{ c.mois }}</span>
                            <span class="atl-slot-hour">{{ c.heure }}</span>
                            <span class="atl-slot-places {{ c.placesRestantes == 0 ? 'atl-slot-places--full' }}" data-atl-places>
                                {%- if c.placesRestantes == 0 -%}
                                    Complet
                                {%- else -%}
                                    {{ c.placesRestantes }} place{{ c.placesRestantes > 1 ? 's' }}
                                {%- endif -%}
                            </span>
                        </div>
                        {% endfor %}
                    </div>
                    {% else %}
                    <p class="atl-nodates" data-atl-nodates>Prochaines dates à venir — contactez-nous pour être prévenu.</p>
                    {% endif %}

                    <div class="atl-cta">
                        <a href="{{ path('app_contact') }}" class="nhp-btn nhp-btn--primary">Réserver — par téléphone ou message</a>
                        <span class="atl-cta-note">Réservation en ligne bientôt disponible</span>
                    </div>
                </div>
                <div class="pres-split-media nhp-reveal d1">
                    <img src="/assets/{{ loop.index is odd ? 'photo_boutique.webp' : 'home_affutage.webp' }}" alt="L'atelier Vulcan' Affûtage" loading="lazy">
                </div>
            </div>
        </div>
    </section>
    {% endfor %}

    {# ==================== BANDEAU RÉASSURANCE ==================== #}
    <section class="nhp-section nhp-section--affutage">
        <div class="nhp-wrap">
            <div class="nhp-cta-band nhp-reveal">
                <div class="nhp-cta-band-body">
                    <span class="nhp-eyebrow">L'atelier depuis 1981</span>
                    <h2 class="nhp-h2" style="margin:14px 0 12px;">Un moment rare, en petit groupe.</h2>
                    <p class="nhp-lead">Matériel fourni, accompagnement pas à pas par l'artisan — et une lame que vous garderez. Une question, une date, un cadeau à offrir ? Parlons-en.</p>
                    <div style="margin-top:26px;display:flex;gap:12px;flex-wrap:wrap;">
                        <a href="{{ path('app_contact') }}" class="nhp-btn nhp-btn--primary">Nous contacter</a>
                        <a href="{{ path('app_presentation') }}" class="nhp-btn nhp-btn--ghost">Découvrir l'atelier</a>
                    </div>
                </div>
                <div class="nhp-cta-band-media">
                    <img src="/assets/couteaux-presentoire-20p.webp" alt="Couteaux artisanaux Vulcan' Affûtage">
                </div>
            </div>
        </div>
    </section>

</main>
{% endblock %}

{% block other_javascripts %}
    <script src="{{ asset('scripts/ds-v2-app.js') }}" defer></script>
{% endblock %}
```

Note : le block `headerAteliers` n'existe pas encore dans `base.html.twig` — un block Twig inconnu du parent est simplement ignoré, ça ne casse rien ; T2 le crée.

- [ ] **Step 6 : Vérifier le passage**

Run : `php vendor/phpunit/phpunit/phpunit tests/Gestion/AtelierPublicTest.php`
Attendu : `OK (7 tests)`.

- [ ] **Step 7 : Commit**

```bash
git add src/Repository/AtelierRepository.php src/Controller/AtelierController.php templates/ateliers/index.html.twig tests/Gestion/AtelierPublicTest.php
git commit -m "feat: page publique /ateliers — vitrine ateliers et créneaux à venir"
```

---

### Task 2: Entrée navigation « Ateliers »

**Files:**
- Modify: `templates/base.html.twig` (nav desktop ~l.184, nav mobile ~l.402)
- Test: `tests/Gestion/AtelierPublicTest.php` (ajout d'un test)

**Interfaces:**
- Consomme : route `app_ateliers` (Task 1).
- Produit : block Twig `headerAteliers` (le template de Task 1 le surcharge avec `gColor` pour l'état actif).

- [ ] **Step 1 : Test qui échoue**

Ajouter à `tests/Gestion/AtelierPublicTest.php` :

```php
public function testLienNavigationPresent(): void
{
    $crawler = $this->client->request('GET', '/ateliers');

    // Desktop + menu mobile.
    $this->assertGreaterThanOrEqual(2, $crawler->filter('a[href="/ateliers"]')->count());
}
```

Run : `php vendor/phpunit/phpunit/phpunit tests/Gestion/AtelierPublicTest.php --filter testLienNavigationPresent`
Attendu : FAIL (0 lien).

- [ ] **Step 2 : Nav desktop**

Dans `templates/base.html.twig`, après le `</li>` du lien Affûtage (l.180-184), insérer :

```twig
							<li>
								<a href="{{ path('app_ateliers') }}" class="first-a-header-menu ft-s-18 {% block headerAteliers %}dgColor-h{% endblock %}" aria-label="Ateliers et stages de coutellerie">
									Ateliers
								</a>
							</li>
```

(Indentation tabs, identique aux `<li>` voisins.)

- [ ] **Step 3 : Nav mobile**

Dans le menu mobile, après le `<li>` Affûtage (~l.400-402), insérer :

```twig
							<li>
								<a class="c-white ft-s-20" href="{{ path('app_ateliers') }}" aria-label="Ateliers et stages de coutellerie">Ateliers</a>
							</li>
```

- [ ] **Step 4 : Vérifier le passage + non-régression**

Run : `php vendor/phpunit/phpunit/phpunit tests/Gestion/AtelierPublicTest.php`
Attendu : `OK (8 tests)`.

- [ ] **Step 5 : Commit**

```bash
git add templates/base.html.twig tests/Gestion/AtelierPublicTest.php
git commit -m "feat: entrée « Ateliers » dans la navigation (desktop + mobile)"
```

---

### Task 3: Styles DA 2.0 (.atl-)

**Files:**
- Modify: `public/styles/ds-v2-pages.css` (append en fin de fichier)

**Interfaces:**
- Consomme : classes posées par le template Task 1 (`.atl-desc`, `.atl-chips`, `.atl-slots-head`, `.atl-slots`, `.atl-slot`, `.atl-slot-day/-month/-hour/-places`, `.atl-slot--full`, `.atl-slot-places--full`, `.atl-nodates`, `.atl-cta`, `.atl-cta-note`, `.atl-empty`) ; tokens `--v2-*` de `ds-v2.css`.
- Produit : rien de consommé plus loin.

- [ ] **Step 1 : Ajouter la section CSS**

Avant d'écrire : vérifier la valeur du breakpoint mobile utilisé par `.pres-split` (media query autour de `ds-v2-pages.css:676`) et utiliser la même valeur pour `.atl-slots` (ci-dessous supposé `760px` — ajuster si différent).

Append à la fin de `public/styles/ds-v2-pages.css` :

```css
/* ==================================================================
   PAGE ATELIERS (.atl-) — vitrine ateliers & stages
   ================================================================== */
.atl-desc { margin-top: 16px; }

.atl-chips { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; }

.atl-slots-head { margin-top: clamp(22px, 3vw, 30px); }
.atl-slots { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.atl-slot {
    display: flex; flex-direction: column; align-items: center;
    min-width: 92px; padding: 14px 16px 12px;
    background: #fff;
    border: 1px solid rgba(26, 24, 21, 0.08);
    border-radius: var(--v2-r);
    box-shadow: var(--v2-sh-lift);
}
.atl-slot-day { font-family: var(--v2-font-display); font-size: 30px; font-weight: 600; line-height: 1; color: var(--v2-ink); }
.atl-slot-month { font-size: var(--v2-fs-sm); color: var(--v2-ink-soft); margin-top: 2px; }
.atl-slot-hour { font-family: var(--v2-font-mono); font-size: var(--v2-fs-sm); color: var(--v2-ink); margin-top: 6px; }
.atl-slot-places { font-size: 12px; font-weight: 600; color: var(--v2-green-deep); margin-top: 8px; white-space: nowrap; }
.atl-slot--full { opacity: 0.55; }
.atl-slot-places--full { color: var(--v2-ember); }

.atl-nodates { margin-top: clamp(22px, 3vw, 30px); font-size: var(--v2-fs-sm); color: var(--v2-ink-soft); font-style: italic; }

.atl-cta { margin-top: clamp(24px, 3.5vw, 34px); display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.atl-cta-note { font-size: var(--v2-fs-sm); color: var(--v2-ink-soft); }

.atl-empty { text-align: center; padding: clamp(30px, 5vw, 60px) 0; }

@media (max-width: 760px) {
    .atl-slots { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 8px; -webkit-overflow-scrolling: touch; scroll-snap-type: x mandatory; }
    .atl-slot { flex: 0 0 auto; scroll-snap-align: start; }
}
```

- [ ] **Step 2 : Non-régression**

Run : `php vendor/phpunit/phpunit/phpunit tests/Gestion/AtelierPublicTest.php`
Attendu : `OK (8 tests)` (CSS ne change aucun comportement testé).

- [ ] **Step 3 : Commit**

```bash
git add public/styles/ds-v2-pages.css
git commit -m "feat: styles DA 2.0 page ateliers (.atl-)"
```

---

### Task 4: Vérification complète — suite entière + QA visuelle navigateur

**Files:**
- Aucun nouveau ; fixes éventuels sur les fichiers des tâches 1-3.

**Interfaces:**
- Consomme : tout ce qui précède.
- Produit : page vérifiée desktop + mobile, suite verte, screenshots.

- [ ] **Step 1 : Suite PHPUnit complète**

Run : `php vendor/phpunit/phpunit/phpunit`
Attendu : `OK (145 tests)` — 137 existants + 8 nouveaux. Zéro failure/error.

- [ ] **Step 2 : Démarrer le serveur dev**

```bash
php -d variables_order=EGPCS -S 127.0.0.1:8000 -t public
```

(En arrière-plan. `variables_order=EGPCS` obligatoire — cf. mémoire projet.)

- [ ] **Step 3 : Fixtures dev si nécessaire**

Si la base dev n'a aucun atelier actif avec créneaux futurs, en créer via :

```bash
php bin/console dbal:run-sql "INSERT INTO atelier (titre, description, prix, duree_minutes, capacite_defaut, actif, created_at) VALUES ('Création de couteau unique', 'Forgez, émouturez et montez votre propre couteau, guidé pas à pas par l''artisan. Vous repartez avec votre lame, unique, faite de vos mains.', 120.00, 180, 6, 1, NOW())"
php bin/console dbal:run-sql "INSERT INTO atelier_creneau (atelier_id, start_at, capacite, statut) SELECT id, DATE_ADD(NOW(), INTERVAL 10 DAY), 6, 'ouvert' FROM atelier ORDER BY id DESC LIMIT 1"
```

- [ ] **Step 4 : QA visuelle Playwright**

1. Naviguer `http://127.0.0.1:8000/ateliers` en 1440px : screenshot pleine page. Vérifier hero, chips, cartes créneaux, CTA, bandeau final, alternance sections, lien nav « Ateliers » actif (vert).
2. Redimensionner 390px (mobile) : screenshot. Vérifier empilement, scroll horizontal des créneaux, menu burger contient « Ateliers ».
3. Console : zéro erreur JS nouvelle.
4. Vérifier animations reveal au scroll (ds-v2-app.js).
5. Vérifier le rendu du chip `.nhp-chip` nu (dot ::before) — si le dot rend mal hors contexte produit, ajouter `.atl-chips .nhp-chip::before { display: none; }` à la section CSS.

- [ ] **Step 5 : Fixes éventuels + commit final**

Si corrections : re-run tests + re-screenshot, puis :

```bash
git add <fichiers corrigés>
git commit -m "fix: ajustements QA visuelle page ateliers"
```

Si aucune correction : rien à committer.
