diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index e610a63..dc1da9f 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -29,6 +29,28 @@ Chaque entrée suit ce format :
---
+## [Unreleased] — 2026-04-26 — Sprint 5.5 Clean FTD
+
+### Changed
+
+- `StatCards.tsx:90` — `plan === 'free'` remplacé par `!hasAccess(plan, 'dashboard')` (FTD-39, Règle D).
+- `useAudioRecorder.ts:80` — assignation `optionsRef` pendant render refactorée en `useEffect` sans deps, eslint-disable retiré (FTD-38).
+
+### Docs
+
+- `TECH_DEBT.md` v1.26 → v1.27 — triage dette technique :
+ - Gelées : FTD-09 (state machine T2 Live), FTD-33 (carte T2 Live en dur), FTD-42 (modal prorata — Customer Portal suffit).
+ - Fermée : FTD-35 (subsumée par FTD-41).
+ - Résolues : FTD-14 (anti-FOUC déjà en place, conforme DESIGN_SYSTEM v2.0), FTD-38, FTD-39.
+ - 21 → 14 FTD actives (cap 15 respecté).
+
+### Notes
+
+- FTD-14 : le script inline `.light` était déjà présent dans `index.html` (lignes 14-20), conforme à DESIGN_SYSTEM v2.0 (dark = défaut, `.light` = override). L'exemple `.dark` documenté dans la fiche FTD-14 datait de la DA Boréal v1.0.
+- Tests frontend : 219/219 verts (inchangé).
+
+---
+
## [Unreleased] — 2026-04-26 — Sprint 5d — Customer Portal + page Paramètres
### Added
diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md
index bf1f37c..0cfebe2 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.26**
+> **Document de référence — Version 1.27**
> 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.
>
@@ -101,31 +101,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
-### FTD-14 — Anti-FOUC thème : script inline manquant dans `
`
-
-**Priorité :** 🟡 Important
-**Statut :** Ouvert — à faire avant déploiement production
-**Estimation de session :** 30 min
-**Description :** Le `ThemeProvider` applique la classe `.dark` sur `` après l'hydratation React (`useEffect`). Entre le premier paint du navigateur et l'exécution de React, la page s'affiche brièvement en mode clair même si l'utilisateur a choisi le mode sombre — c'est le FOUC (Flash Of Unstyled Content).
-
-**Fix :** ajouter un script inline bloquant dans le `` de `index.html` qui lit `localStorage.getItem('expria-theme')` (et `prefers-color-scheme` en fallback) et applique `.dark` sur `document.documentElement` avant le premier paint. Ce script doit être minifié et inliné (non-async, non-defer) pour garantir l'exécution avant le CSS.
-
-```html
-
-```
-
-**Impact actuel :** visible uniquement pour les utilisateurs en mode sombre — bref flash de fond clair au chargement. Acceptable en dev, indésirable en production.
-
-**Condition de résolution :** avant la première mise en production (Sprint 1 ou avant).
-
----
-
+> FTD-14 résolu au Sprint 5.5 (2026-04-26) — voir §5 Historique des résolutions.
> FTD-17, FTD-18, FTD-19 résolus au Sprint 3.5 (2026-04-22) — voir §5 Historique des résolutions.
---
@@ -173,14 +149,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
-### FTD-35 — `PresentationGenereeT1Page` : refresh sans simulation active
-
-**Priorité :** 🟡 Important
-**Statut :** Ouvert — introduit au Sprint 4c-2
-**Estimation de session :** 0,5 jour
-**Description :** Le hydrate du provider lit `expria_simulation_id` et `expria_eo_t1_presentation` indépendamment. Si l'ID a expiré côté backend (`getSimulationState` rejette en 404) mais que la présentation reste en localStorage, l'utilisateur est redirigé vers `/simulation/eo` au mount du provider, puis la page `PresentationGenereeT1Page` peut être atteinte via reload direct sans `production` valide → la garde `shouldRedirect` envoie vers `/simulation/eo/t1/mode`, qui à son tour redirige vers `/simulation/eo`. Le résultat est correct mais l'utilisateur n'a pas de feedback explicite (« votre simulation a expiré »).
-**À faire :** afficher un toast / bandeau dédié quand la résolution `getSimulationState` échoue avec une présentation localStorage présente, et proposer un bouton « Recommencer » qui efface la présentation également.
-**Condition de résolution :** Sprint 4c-3 ou clean post-MVP.
+> FTD-35 fermée au Sprint 5.5 (2026-04-26) — subsumée par FTD-41. Voir §5 Historique.
---
@@ -209,33 +178,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
-### FTD-38 — `useAudioRecorder` : mise à jour de ref pendant le render
-
-**Priorité :** 🟢 Mineur
-**Statut :** Ouvert — introduit Sprint 4c-1, signalé Sprint 4.5
-**Estimation de session :** 1h
-**Description :** `optionsRef.current = options` est exécuté pendant le render (ligne 73 de `useAudioRecorder.ts`) pour capturer les callbacks inline frais (`onMaxReached`) sans réabonner les effets à chaque render. ESLint signale ce pattern via la règle `react-hooks/refs` (« Cannot update ref during render »). Désactivation locale en place avec commentaire explicatif renvoyant à cette FTD.
-
-**À faire :** refactorer en `useEffect(() => { optionsRef.current = options })` ou équivalent — l'effet s'exécute après commit, donc avant le prochain render qui utilisera la ref. Risque : 195 lignes de tests sur ce hook (`useAudioRecorder.test.ts`) — vérifier que la sémantique reste identique (auto-stop à `maxSeconds`, callback `onMaxReached` invoqué une seule fois, etc.).
-
-**Condition de résolution :** session dédiée — non bloquant pour la prod (le pattern fonctionne, c'est un avertissement de bonnes pratiques React 19).
-
----
-
-### FTD-39 — Règle D violée dans `StatCards.tsx` (`plan === 'free'` en dur)
-
-**Priorité :** 🟡 Important
-**Statut :** Ouvert — introduit Sprint UI Polish (commit `4005673`), signalé Sprint 4.5
-**Estimation de session :** 30 min
-**Description :** [`src/features/dashboard/components/StatCards.tsx:90`](../src/features/dashboard/components/StatCards.tsx) contient `{plan === 'free' && (...)}` — viole la Règle D de `DEVELOPMENT_PRINCIPLES.md` (interdiction de comparer `plan === 'xxx'` en dur dans les composants). Doit passer par `hasAccess(plan, '')` ou `canSimulate(plan, used)` selon le sens du gating.
-
-**À faire :**
-
-- Identifier la sémantique du gating (probablement « afficher le compteur de simulations restantes uniquement aux Free » → utiliser le résultat de `canSimulate(plan, simulationsUsed)`).
-- Remplacer la condition par le helper approprié.
-- Vérifier que les tests Dashboard (Free/Standard/Premium) restent verts.
-
-**Condition de résolution :** session dédiée Dashboard — pas de refacto sans plan validé (Règle A).
+> FTD-38, FTD-39 résolus au Sprint 5.5 (2026-04-26) — voir §5 Historique des résolutions.
---
@@ -261,26 +204,7 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
-### FTD-42 — Modal prorata Standard→Premium avec montant exact
-
-**Priorité :** 🟡 Important
-**Statut :** Ouvert — introduit Sprint 5d (2026-04-26)
-**Estimation de session :** 1 jour
-**Description :** Le flux Standard→Premium passe actuellement par le **Stripe Customer Portal** (Sprint 5d). Le portal natif Stripe affiche bien le montant prorata + confirmation, mais hors de l'app : l'utilisateur quitte expria.app pendant la confirmation. **Divergence avec PARCOURS_UTILISATEURS.md §3** qui prévoit une modal in-app avec :
-
-> « Vous paierez [montant calculé]€ aujourd'hui pour accéder au plan Premium jusqu'au [date de fin]. » [Confirmer] [Annuler]
-
-Endpoint backend déjà disponible : `POST /plans/upgrade-prorata` retourne `{ amount, currency, newPlanExpiry }` calculé par Stripe (preview).
-
-**À faire :**
-
-- Hook `usePreviewProrata()` → consomme `POST /plans/upgrade-prorata` au mount de la modal.
-- Composant `ProrataConfirmationModal` (montant + date affichés + bouton Confirmer).
-- Bouton Confirmer → appel d'un nouvel endpoint backend `POST /plans/upgrade-prorata/confirm` qui réalise effectivement le `subscription.update()` Stripe.
-- Tests : 5+ (preview, confirm, erreurs).
-- Décision côté UX : remplacer entièrement le flux Customer Portal pour Standard→Premium, OU garder le Customer Portal en fallback « Gérer mon abonnement » ailleurs ?
-
-**Condition de résolution :** modal in-app livrée + Standard→Premium ne passe plus par Customer Portal mais par cette modal.
+> FTD-42 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
---
@@ -303,13 +227,7 @@ Endpoint backend déjà disponible : `POST /plans/upgrade-prorata` retourne `{ a
---
-### FTD-33 — Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`)
-
-**Priorité :** 🟢 Mineur
-**Statut :** Ouvert — introduit au Sprint 4c-1
-**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 (pas de logique de plan en dur).
-**Condition de résolution :** lancement de T2 Live (Sprint 6).
+> FTD-33 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
---
@@ -503,6 +421,45 @@ 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-42 — Modal prorata Standard→Premium avec montant exact
+
+**Priorité :** 🟡 Important
+**Statut :** Gelé — Sprint 5.5 (2026-04-26)
+**Estimation de session :** 1 jour
+**Description :** Le flux Standard→Premium passe actuellement par le **Stripe Customer Portal** (Sprint 5d). Le portal natif Stripe affiche le montant prorata + confirmation hors de l'app. Divergence avec PARCOURS_UTILISATEURS.md §3 qui prévoit une modal in-app.
+
+**Motif de gel :** Gelé — Customer Portal Stripe gère nativement le prorata. Modal in-app = confort post-MVP, pas MVP-bloquant.
+
+**Condition de résolution :** post-MVP, si retour utilisateur fait remonter la friction du redirect vers Customer Portal.
+
+---
+
### FTD-15 — Option `'system'` manquante dans ThemeProvider
**Priorité :** 🟢 Mineur
@@ -523,19 +480,7 @@ Frontend :
## 4. Tests à renforcer
-### FTD-09 — Tests de la state machine T2 Live non implémentés
-
-**Priorité :** 🟡 Important
-**Statut :** Planifié — à créer au Sprint 2.5
-**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).
-
-**À faire au Sprint 2.5 (spike T2 Live) :**
-
-- Créer `t2-machine.test.ts` avec tests des transitions : idle → connecting, connecting → listening, listening ↔ speaking, _ → error, _ → ended
-- Tests des messages d'erreur (close code 4001, 4003, autre)
-
-**Condition de résolution :** fin Sprint 2.5.
+> FTD-09 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
---
@@ -589,6 +534,10 @@ 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-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). |
+| FTD-39 | Règle D violée dans `StatCards.tsx` (`plan === 'free'` en dur) | 2026-04-26 | Sprint 5.5 — Remplacement de `{plan === 'free' && ...}` (ligne 90) par `{!hasAccess(plan, 'dashboard') && ...}`. Sémantique du gating : afficher « Renouvellement offert à l'upgrade » uniquement aux utilisateurs sans accès au dashboard complet (= Free). Import `hasAccess` ajouté depuis `@/entities/user/lib`. Tests Dashboard verts. |
| FTD-19 | Token `--shadow-focus` absent de `src/index.css` | 2026-04-22 | Résolu au Sprint 3.5. Ajout de `--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18)` dans `@theme {}` (valeur conforme à `DESIGN_SYSTEM.md §2`) et `--shadow-focus: 0 0 0 3px rgba(91, 127, 255, 0.32)` dans `.dark {}` (recalculé sur la teinte expria dark `#5B7FFF`). Tailwind 4 génère automatiquement l'utility `shadow-focus`. Migration de 5 occurrences `ring-2 ring-expria/20` → `shadow-focus` dans `Button.tsx`, `Card.tsx`, `SimulationForm.tsx` (×3), `SpecialCharsKeyboard.tsx`. Factorisation bonus : className dupliquée des boutons secondaires de `SimulationForm` extraite en const `secondaryActionBtn`. |
---
@@ -624,3 +573,4 @@ Frontend :
| 1.24 | 2026-04-25 | Sprint 4.5 Clean — Ajout FTD-38 🟢 (`useAudioRecorder` ref mise à jour pendant render — eslint-disable local en place) et FTD-39 🟡 (Règle D violée dans `StatCards.tsx` — préexistant Sprint UI Polish). 17 FTD actives — cap dépassé temporairement, à résorber au Sprint 5.5. |
| 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é). |
diff --git a/src/features/dashboard/components/StatCards.tsx b/src/features/dashboard/components/StatCards.tsx
index 2957cbe..53c1ede 100644
--- a/src/features/dashboard/components/StatCards.tsx
+++ b/src/features/dashboard/components/StatCards.tsx
@@ -15,7 +15,7 @@ import { Card } from '@/shared/ui/Card'
import { formatRelativeDate } from '@/shared/lib/date'
import { isEcrit } from '@/entities/production/lib'
import type { SimulationListItem } from '@/entities/production/types'
-import type { Plan } from '@/entities/user/lib'
+import { hasAccess, type Plan } from '@/entities/user/lib'
interface StatCardsProps {
plan: Plan
@@ -87,7 +87,7 @@ function SimulationsRestantesCard({
style={{ width: `${pct}%` }}
/>
- {plan === 'free' && (
+ {!hasAccess(plan, 'dashboard') && (
Renouvellement offert à l'upgrade
)}
diff --git a/src/features/simulations/hooks/useAudioRecorder.ts b/src/features/simulations/hooks/useAudioRecorder.ts
index 674096d..d636a3b 100644
--- a/src/features/simulations/hooks/useAudioRecorder.ts
+++ b/src/features/simulations/hooks/useAudioRecorder.ts
@@ -76,10 +76,10 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi
// Capture options dans une ref pour éviter de réabonner les effets sur
// chaque render (les callers fournissent souvent des fonctions inline).
- // Refacto propre via useEffect tracé en FTD-38.
const optionsRef = useRef(options)
- // eslint-disable-next-line react-hooks/refs
- optionsRef.current = options
+ useEffect(() => {
+ optionsRef.current = options
+ })
const maxReachedFiredRef = useRef(false)
const cleanupTimer = useCallback(() => {