diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 246fdeb..b4480d0 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -29,6 +29,34 @@ Chaque entrée suit ce format :
---
+## [Unreleased] — 2026-04-26 — Sprint 6c — Frontend T2 Live UI
+
+### Added
+
+- `t2-machine.ts` — state machine pure T2 Live : 9 états (`idle` → `preparing` → `connecting` → `ready` → `speaking` ↔ `listening` → `processing` → `ended` / `error`), 8 events. 21 tests. Résout FTD-09.
+- `useT2LiveSession.ts` — hook orchestrateur : WebSocket + state machine + hooks audio (capture/playback/recording). Parse format Gemini natif (`serverContent.modelTurn`) + messages applicatifs backend (`warning`/`report`/`error`). Close codes 1000/4001/4003/4004. Timer dialogue 210 s. Ping 30 s keep-alive.
+- `T2LiveContext.tsx` — Provider léger pour partager le sujet sélectionné entre les pages T2.
+- `T2SujetsPage.tsx` — grille de sélection des sujets T2 (`GET /sujets?mode=EO&tache=2`).
+- `T2PreparationPage.tsx` — timer 2 min, consigne affichée, zone de notes locale, bouton « Suggestions d'idées » (DeepSeek, actif immédiatement), bouton « Je suis prêt », pré-warm micro via `getUserMedia`. Transition auto vers dialogue à 0:00.
+- `T2DialoguePage.tsx` — timer 3:30, indicateur d'état IA, waveform, bouton « Terminer ». Écran terminal (state `ended`) : bouton « Télécharger l'audio » (WAV mono 24 kHz) + bouton « Voir le rapport » (→ `/rapport/:id`).
+- 3 routes : `/simulation/eo/t2`, `/simulation/eo/t2/preparation`, `/simulation/eo/t2/dialogue` sous `T2LiveLayout`.
+
+### Changed
+
+- `TaskSelector.tsx` — carte EO T2 Live déverrouillée via `hasAccess(plan, 'oral_t2_live')` + prop `onT2LiveSelect`. Résout FTD-33.
+- `SimulationEOPage.tsx` — branche `onT2LiveSelect` vers `/simulation/eo/t2`.
+- `entities/production/` — `Tache` type, labels, `mapTacheToSujetParams`, config étendus avec `EO_T2_LIVE`.
+- `features/historique/` — `TACHE_NUMBER` étendu.
+
+### Notes
+
+- Tests frontend : 238 → 259 verts (+21 — tous sur t2-machine).
+- FTD-09 résolue (state machine testée).
+- FTD-33 résolue (carte déverrouillée via hasAccess).
+- `useT2LiveSession` non testé en unit (WebSocket non supporté jsdom) — validation manuelle prévue.
+
+---
+
## [Unreleased] — 2026-04-26 — Sprint 6b — Frontend audio (T2 Live)
### Added
diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md
index 0cfebe2..c2ec53e 100644
--- a/docs/TECH_DEBT.md
+++ b/docs/TECH_DEBT.md
@@ -1,6 +1,6 @@
# TECH_DEBT.md — Expria Frontend
-> **Document de référence — Version 1.27**
+> **Document de référence — Version 1.28**
> Ce document recense les décisions techniques prises par pragmatisme qui devront être revisitées, les stubs temporaires, et les fonctionnalités reportées.
> À mettre à jour après chaque session de développement.
>
@@ -421,29 +421,7 @@ Frontend :
---
-### FTD-09 — Tests de la state machine T2 Live non implémentés
-
-**Priorité :** 🟡 Important
-**Statut :** Gelé — Sprint 5.5 (2026-04-26)
-**Estimation de session :** 3h
-**Description :** La state machine T2 Live (`src/features/t2-live/state/t2-machine.ts`) n'existe pas encore. Quand elle sera créée, elle devra être testée de manière exhaustive (6+ tests couvrant les transitions d'états et les cas d'erreur).
-
-**Motif de gel :** Gelé — le code n'existe pas encore, sera créé et testé au Sprint 6.
-
-**Condition de résolution :** fin Sprint 6 (T2 Live).
-
----
-
-### FTD-33 — Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`)
-
-**Priorité :** 🟢 Mineur
-**Statut :** Gelé — Sprint 5.5 (2026-04-26)
-**Estimation de session :** 0,5 jour
-**Description :** Dans `TaskSelector`, la carte EO_T2_LIVE a `tache: null` ce qui la rend inactive pour tous les plans, indépendamment de `hasAccess(plan, 'oral_t2_live')`. C'est volontaire tant que T2 Live n'est pas livré (Sprint 6) — un utilisateur Premium ne doit pas accéder à une feature non implémentée. À nettoyer dès que T2 Live est wired pour respecter strictement la Règle D.
-
-**Motif de gel :** Gelé — condition de résolution = Sprint 6 T2 Live.
-
-**Condition de résolution :** lancement de T2 Live (Sprint 6).
+> FTD-09 et FTD-33 résolues au Sprint 6c (2026-04-26) — voir §5 Historique.
---
@@ -534,6 +512,8 @@ Frontend :
| FTD-24 | Pas de polling automatique pour exercices / modèle `pending` | 2026-04-23 | Polling conditionnel dans `useRapport` via `refetchInterval: 3000` tant que `exercices_status === 'pending' \|\| modele_status === 'pending'`. Arrêt automatique dès que les deux sortent de pending (ready ou error). Timeout global 2 min → `hasTimedOut = true` + bouton « Réessayer » dans `JobStatusFallback` (primitive `@/shared/ui/Button`). `refetch()` réinitialise le flag et relance le polling. `staleTime: Infinity` conservé. 5 tests nouveaux dans `useRapport.test.tsx`. |
| FTD-25 | Mise à jour ARCHITECTURE.md §3 (arborescence réelle) | 2026-04-25 | §3 réécrite : `app/` documenté avec entry points + layout (AppLayout, Sidebar, Topbar, BottomNav, MaintenancePage) ; ajout `entities/{patterns,presentation,transcription}` ; ajout `features/{historique,progression,design-system}` ; extension `simulations/` (pages EO, components/rapport/, lib/, state/) ; mise à jour `shared/`. `t2-live/` et `billing/` retirés (non implémentés — voir ROADMAP). Note explicative ajoutée sous `app/`. Bump doc v1.1. |
| FTD-26 | Clarifier cohabitation `shared/ui/` vs `shared/components/ui/` | 2026-04-25 | Section dédiée ajoutée dans ARCHITECTURE.md §3 : tableau de distinction (PascalCase wrappers Expria vs kebab-case primitives shadcn) + règle d'évolution (toute nouvelle primitive Expria va dans `shared/ui/`, `shared/components/ui/` réservé à la CLI shadcn). Aucun fichier déplacé — documentation uniquement. |
+| FTD-09 | Tests de la state machine T2 Live non implémentés | 2026-04-26 | Sprint 6c — State machine pure créée (`src/features/t2-live/state/t2-machine.ts`, 9 états × 8 events) + 21 tests Vitest couvrant transitions nominales, END_REQUESTED depuis tout état actif, ERROR terminal, événements invalides ignorés. Dégelée et fermée. |
+| FTD-33 | Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`) | 2026-04-26 | Sprint 6c — Carte EO_T2_LIVE déverrouillée via `hasAccess(plan, 'oral_t2_live')` + nouvelle prop `onT2LiveSelect` dans `TaskSelector`. Si plan donne accès, clic navigue vers `/simulation/eo/t2` (la production est créée par le backend en fin de session, pas au clic). Sinon, carte reste verrouillée avec lockLabel « Exclusivité Premium ». Dégelée et fermée. |
| FTD-14 | Anti-FOUC thème : script inline manquant dans `
` | 2026-04-26 | Sprint 5.5 — Script `.light` déjà en place dans `index.html` (lignes 14-20), conforme DESIGN_SYSTEM v2.0. L'exemple `.dark` documenté dans la fiche FTD-14 datait de la DA Boréal v1.0 (obsolète). Aucune action code requise — FTD fermée comme déjà résolue. |
| FTD-35 | `PresentationGenereeT1Page` : refresh sans simulation active | 2026-04-26 | Sprint 5.5 — Subsumée par FTD-41 : la résolution de FTD-41 (persistance T1 en BDD) élimine le problème de FTD-35 (localStorage instable). Aucune action propre. |
| FTD-38 | `useAudioRecorder` : mise à jour de ref pendant le render | 2026-04-26 | Sprint 5.5 — Refactor `optionsRef.current = options` (assignation pendant render + eslint-disable) en `useEffect(() => { optionsRef.current = options })`. Sémantique préservée : effet sans deps run après chaque commit, donc avant le prochain render qui lit la ref. eslint-disable retiré. 195 lignes de tests `useAudioRecorder.test.ts` toujours vertes (219/219). |
@@ -574,3 +554,4 @@ Frontend :
| 1.25 | 2026-04-25 | Sprint 4.5 — Ajout FTD-40 🟡 (conclusion `conseil_nclc` backend incohérente quand NCLC atteint > cible — patch frontend en place dans `ConseilNclcCallout`) et FTD-41 🔴 (persistance présentation EO T1 en BDD — résout FTD-35). **19 FTD actives — cap 15 dépassé de 4. Résorption obligatoire au Sprint 5.5 avant toute nouvelle FTD.** |
| 1.26 | 2026-04-26 | Sprint 5e (clean Sprint 5 Billing) — Ajout FTD-42 🟡 (modal prorata Standard→Premium avec montant exact — divergence PARCOURS_UTILISATEURS §3, actuellement Customer Portal natif sans preview in-app) et FTD-43 🟢 (race condition webhook post-redirect Stripe — `usePlan()` peut retourner ancien plan brièvement). **21 FTD actives — cap 15 dépassé de 6. Résorption FTD critique au Sprint 5.5 avant Sprint 6.** |
| 1.27 | 2026-04-26 | Sprint 5.5 Clean — FTD-09, FTD-33, FTD-42 gelées. FTD-35 fermée (subsumée par FTD-41). FTD-14, FTD-38, FTD-39 résolues. **14 FTD actives** (cap 15 respecté). |
+| 1.28 | 2026-04-26 | Sprint 6c — FTD-09 et FTD-33 résolues (dégelées → fermées). **14 FTD actives** (inchangé — les gelées ne comptaient pas dans le cap). |
diff --git a/src/app/router.tsx b/src/app/router.tsx
index 1bde52d..5fcabcb 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -20,6 +20,10 @@ import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
import { PricingPage } from '@/features/billing/pages/PricingPage'
import { ParametresPage } from '@/features/account/pages/ParametresPage'
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
+import { T2LiveProvider } from '@/features/t2-live/state/T2LiveContext'
+import { T2SujetsPage } from '@/features/t2-live/pages/T2SujetsPage'
+import { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage'
+import { T2DialoguePage } from '@/features/t2-live/pages/T2DialoguePage'
import { AppLayout } from './AppLayout'
const DesignSystemPage = import.meta.env.DEV
@@ -53,6 +57,14 @@ function SimulationFlowLayout() {
)
}
+function T2LiveLayout() {
+ return (
+
+
+
+ )
+}
+
export function AppRouter() {
return (
@@ -86,6 +98,13 @@ export function AppRouter() {
} />
+ {/* Sprint 6c — T2 Live (Premium) : sélection sujet → prépa 2min → dialogue 3:30 */}
+ }>
+ } />
+ } />
+ } />
+
+
{/* Autres sections — Sprint 4+ */}
} />
} />
diff --git a/src/entities/production/api.ts b/src/entities/production/api.ts
index ceebca1..ab845cc 100644
--- a/src/entities/production/api.ts
+++ b/src/entities/production/api.ts
@@ -78,7 +78,10 @@ export async function updateSujet(id: string, sujetId: string): Promise {
/**
* Mappe une Tache vers les paramètres de la route `GET /sujets`.
* Retourne `null` pour les tâches sans catalogue de sujets côté base
- * (EO_T1 : sujet fixe connu, EO_T2_LIVE : interaction sans sujet).
+ * (EO_T1 : sujet fixe connu).
+ *
+ * Sprint 6c : `EO_T2_LIVE` mappe vers (mode='EO', tache=2) pour récupérer
+ * la grille de sujets T2 (rôle + contexte alimentés en BDD).
*/
function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null {
switch (tache) {
@@ -88,6 +91,8 @@ function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber:
return { mode: 'EE', tacheNumber: 2 }
case 'EE_T3':
return { mode: 'EE', tacheNumber: 3 }
+ case 'EO_T2_LIVE':
+ return { mode: 'EO', tacheNumber: 2 }
case 'EO_T3':
return { mode: 'EO', tacheNumber: 3 }
case 'EO_T1':
diff --git a/src/entities/production/lib.ts b/src/entities/production/lib.ts
index bf44c17..47edaba 100644
--- a/src/entities/production/lib.ts
+++ b/src/entities/production/lib.ts
@@ -10,6 +10,7 @@ const TACHE_LABELS: Record = {
EE_T2: 'Expression Écrite — Tâche 2',
EE_T3: 'Expression Écrite — Tâche 3',
EO_T1: 'Expression Orale — Tâche 1',
+ EO_T2_LIVE: 'Expression Orale — Tâche 2 Live',
EO_T3: 'Expression Orale — Tâche 3',
}
diff --git a/src/entities/production/types.ts b/src/entities/production/types.ts
index 7946edc..0cc4806 100644
--- a/src/entities/production/types.ts
+++ b/src/entities/production/types.ts
@@ -11,8 +11,11 @@
* Ne jamais les injecter comme HTML — passer par react-markdown dans les composants.
*/
-/** Identifiants des tâches disponibles en mode simulation (hors T2 Live). */
-export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3'
+/**
+ * Identifiants des tâches disponibles en mode simulation.
+ * `EO_T2_LIVE` désigne la T2 EO en dialogue live (Sprint 6).
+ */
+export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T2_LIVE' | 'EO_T3'
/** Mode de la simulation — examen uniquement accessible au plan Premium. */
export type Mode = 'entrainement' | 'examen'
diff --git a/src/features/historique/lib/historique.ts b/src/features/historique/lib/historique.ts
index 0230b63..ed5d5f8 100644
--- a/src/features/historique/lib/historique.ts
+++ b/src/features/historique/lib/historique.ts
@@ -127,6 +127,7 @@ const TACHE_NUMBER: Record = {
EE_T2: 'EE · Tâche 2',
EE_T3: 'EE · Tâche 3',
EO_T1: 'EO · Tâche 1',
+ EO_T2_LIVE: 'EO · Tâche 2 Live',
EO_T3: 'EO · Tâche 3',
}
diff --git a/src/features/simulations/components/TaskSelector.tsx b/src/features/simulations/components/TaskSelector.tsx
index 2dd7248..a27db91 100644
--- a/src/features/simulations/components/TaskSelector.tsx
+++ b/src/features/simulations/components/TaskSelector.tsx
@@ -10,7 +10,7 @@
*/
import { Lock, Loader2 } from 'lucide-react'
-import { canSimulate } from '@/entities/user/lib'
+import { canSimulate, hasAccess } from '@/entities/user/lib'
import { cn } from '@/shared/lib/utils'
import { Card } from '@/shared/ui/Card'
import { Badge } from '@/shared/ui/Badge'
@@ -25,6 +25,14 @@ interface Props {
simulationsUsed: number
isLoading: boolean
onSelect: (payload: CreateSimulationPayload) => void
+ /**
+ * Sprint 6c — handler dédié pour la carte EO_T2_LIVE. Si fourni ET que
+ * l'utilisateur a accès (`oral_t2_live`), un clic appelle ce handler au
+ * lieu d'`onSelect` (la production T2 Live est créée en backend en fin
+ * de session, pas au clic). Si absent OU plan insuffisant, la carte
+ * reste verrouillée.
+ */
+ onT2LiveSelect?: () => void
}
interface TaskCard {
@@ -46,17 +54,25 @@ const EO_CARDS: readonly TaskCard[] = [
{ key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
{
key: 'EO_T2_LIVE',
- tache: null,
+ tache: 'EO_T2_LIVE',
label: 'Expression Orale',
sublabel: 'Tâche 2 — Live',
lockLabel: 'Exclusivité Premium',
},
]
-export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect }: Props) {
+export function TaskSelector({
+ type,
+ plan,
+ simulationsUsed,
+ isLoading,
+ onSelect,
+ onT2LiveSelect,
+}: Props) {
const simulationCheck = canSimulate(plan, simulationsUsed)
const quotaBlocked = !simulationCheck.allowed
const cards = type === 'EE' ? EE_CARDS : EO_CARDS
+ const t2LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT2LiveSelect)
return (
+ {(sujet as { contexte?: string | null }).contexte}
+
+ >
+ )}
+
+
+
+
+ Comment ça se passe : c'est à vous de
+ prendre la parole en premier pour initier la conversation, comme à l'examen réel.
+ L'examinateur IA attend que vous lui posiez vos questions.
+
+
+
+
+
+
+
+ {micWarmed === false && (
+
+ Accès au micro refusé. Activez-le dans les paramètres du navigateur avant de démarrer le
+ dialogue.
+
+ )
+}
diff --git a/src/features/t2-live/pages/T2SujetsPage.tsx b/src/features/t2-live/pages/T2SujetsPage.tsx
new file mode 100644
index 0000000..df6b531
--- /dev/null
+++ b/src/features/t2-live/pages/T2SujetsPage.tsx
@@ -0,0 +1,109 @@
+/**
+ * Page /simulation/eo/t2 — sélection d'un sujet T2 EO Live (Sprint 6c).
+ *
+ * Pattern emprunté à SujetsEOPage : grille de sujets + sujet aléatoire.
+ * Différence clé : le sujet est stocké dans T2LiveContext (pas SimulationFlowProvider)
+ * — la production sera créée par le backend en fin de session, pas au clic.
+ */
+
+import { useNavigate } from 'react-router-dom'
+import { Shuffle } from 'lucide-react'
+import { Button } from '@/shared/ui/Button'
+import { useSujets } from '@/features/simulations/hooks/useSujets'
+import { SujetCard } from '@/features/simulations/components/SujetCard'
+import type { SujetData } from '@/entities/production/types'
+import { useT2LiveContext } from '../state/T2LiveContext'
+
+function SujetsSkeleton() {
+ return (
+