diff --git a/.cursor/skills/frontend-design/SKILL.md b/.cursor/skills/frontend-design/SKILL.md
deleted file mode 100644
index 600b6db..0000000
--- a/.cursor/skills/frontend-design/SKILL.md
+++ /dev/null
@@ -1,42 +0,0 @@
----
-name: frontend-design
-description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
-license: Complete terms in LICENSE.txt
----
-
-This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
-
-The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
-
-## Design Thinking
-
-Before coding, understand the context and commit to a BOLD aesthetic direction:
-- **Purpose**: What problem does this interface solve? Who uses it?
-- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
-- **Constraints**: Technical requirements (framework, performance, accessibility).
-- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
-
-**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
-
-Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
-- Production-grade and functional
-- Visually striking and memorable
-- Cohesive with a clear aesthetic point-of-view
-- Meticulously refined in every detail
-
-## Frontend Aesthetics Guidelines
-
-Focus on:
-- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
-- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
-- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
-- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
-- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
-
-NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
-
-Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
-
-**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
-
-Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
\ No newline at end of file
diff --git a/.env.example b/.env.example
index 40d3f2a..570d1d0 100644
--- a/.env.example
+++ b/.env.example
@@ -7,8 +7,3 @@ VITE_ENABLE_T2_LIVE=false
# Optionnel — DSN Sentry pour monitoring prod (laisser commenté en dev local)
# VITE_SENTRY_DSN=https://xxxxxx@o000000.ingest.sentry.io/0000000
-
-# Sprint 5b — price_ids Stripe publics (Dashboard Stripe → Produits → Plan → Tarif).
-# Requis en dev/prod ; absents en CI tests (tests mockent features/billing/api.ts).
-VITE_STRIPE_PRICE_STANDARD=price_xxx
-VITE_STRIPE_PRICE_PREMIUM=price_xxx
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index 2ba298d..0000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-version: 2
-updates:
- - package-ecosystem: "npm"
- directory: "/"
- schedule:
- interval: "weekly"
- open-pull-requests-limit: 10
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 173c4bb..8484718 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,10 +9,6 @@ on:
jobs:
quality:
runs-on: ubuntu-latest
- env:
- VITE_API_URL: "http://localhost:3000"
- VITE_SUPABASE_URL: "https://fake.supabase.co"
- VITE_SUPABASE_ANON_KEY: "fake-anon-key-for-ci"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -25,7 +21,3 @@ jobs:
- run: npm run typecheck
- run: npm run test
- run: npm audit --audit-level=high
- - name: Install Semgrep
- run: python3 -m pip install --user semgrep
- - name: Semgrep scan
- run: semgrep scan --config=auto --error --severity=ERROR
diff --git a/.gitignore b/.gitignore
index 1862d22..4591520 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,3 @@ dist-ssr
# Claude Code local config
.claude/
-
-# Exploration DA temporaire — supprimer une fois la direction choisie
-design-exploration/
diff --git a/components.json b/components.json
deleted file mode 100644
index 10d45fb..0000000
--- a/components.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
- "rsc": false,
- "tsx": true,
- "tailwind": {
- "config": "",
- "css": "src/index.css",
- "baseColor": "slate",
- "cssVariables": true,
- "prefix": ""
- },
- "aliases": {
- "components": "src/shared/components",
- "utils": "src/shared/lib/utils",
- "ui": "src/shared/components/ui",
- "lib": "src/shared/lib",
- "hooks": "src/shared/hooks"
- },
- "iconLibrary": "lucide"
-}
diff --git a/design-reference/direction-h-dark.html b/design-reference/direction-h-dark.html
deleted file mode 100644
index 4a52f4a..0000000
--- a/design-reference/direction-h-dark.html
+++ /dev/null
@@ -1,927 +0,0 @@
-
-
-
-
-
-Expria — Tableau de bord · Direction H : Mode sombre
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Simulations restantes
-
3 / 5
-
-
Plan Découverte — renouvellement à chaque upgrade
-
-
-
-
Niveau estimé
-
NCLC 8
-
-
Moyenne des 3 dernières simulations · Objectif NCLC 9
-
-
-
-
Plan actuel
-
Découverte
-
Débloquez des simulations illimitées, le Mode Examen et le T2 Live avec Standard ou Premium.
-
- Passer à Standard
- →
-
-
-
-
-
-
-
-
-
-
-
Simulations récentes
-
Vos 3 dernières corrections
-
-
-
-
-
-
-
Expression écrite — Tâche 2
-
Aujourd'hui · 09:42
-
-
NCLC 9
-
16/20
-
-
-
-
-
-
Expression orale — Tâche 1
-
Il y a 2 jours
-
-
NCLC 8
-
14/20
-
-
-
-
-
-
Expression écrite — Tâche 3
-
Il y a 5 jours
-
-
NCLC 9
-
15/20
-
-
-
-
-
-
-
-
Prochaine étape recommandée
-
Travaillez la tâche 2 à l'oral
-
- Votre dernier score à l'EO T2 (14/20) est en dessous de votre moyenne. Une simulation de 10 minutes suffit pour consolider.
-
-
-
-
-
- Commencer maintenant
- →
-
-
-
-
-
-
-
-
Palette de la direction H — mode sombre
-
-
-
-
#0D1220
-
Fond principal
-
-
-
-
#182238
-
Cards surface
-
-
-
-
#5B7FFF
-
Bleu Expria — remonté
-
-
-
-
#27324B
-
Hairlines
-
-
-
-
-
#A8B2C7
-
Corps secondaire
-
-
-
-
-
#F5B849
-
Attention
-
-
-
-
-
-
-
-
-
-
diff --git a/design-reference/direction-h-juste-milieu.html b/design-reference/direction-h-juste-milieu.html
deleted file mode 100644
index 87a062d..0000000
--- a/design-reference/direction-h-juste-milieu.html
+++ /dev/null
@@ -1,921 +0,0 @@
-
-
-
-
-
-Expria — Tableau de bord · Direction H : Juste milieu
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Simulations restantes
-
3 / 5
-
-
Plan Découverte — renouvellement à chaque upgrade
-
-
-
-
Niveau estimé
-
NCLC 8
-
-
Moyenne des 3 dernières simulations · Objectif NCLC 9
-
-
-
-
Plan actuel
-
Découverte
-
Débloquez des simulations illimitées, le Mode Examen et le T2 Live avec Standard ou Premium.
-
- Passer à Standard
- →
-
-
-
-
-
-
-
-
-
-
-
Simulations récentes
-
Vos 3 dernières corrections
-
-
-
-
-
-
-
Expression écrite — Tâche 2
-
Aujourd'hui · 09:42
-
-
NCLC 9
-
16/20
-
-
-
-
-
-
Expression orale — Tâche 1
-
Il y a 2 jours
-
-
NCLC 8
-
14/20
-
-
-
-
-
-
Expression écrite — Tâche 3
-
Il y a 5 jours
-
-
NCLC 9
-
15/20
-
-
-
-
-
-
-
-
Prochaine étape recommandée
-
Travaillez la tâche 2 à l'oral
-
- Votre dernier score à l'EO T2 (14/20) est en dessous de votre moyenne. Une simulation de 10 minutes suffit pour consolider.
-
-
-
-
-
- Commencer maintenant
- →
-
-
-
-
-
-
-
-
Palette de la direction H
-
-
-
-
#EEF2F8
-
Fond principal
-
-
-
-
#FFFFFF
-
Cards (blanc franc)
-
-
-
-
#1B4FD8
-
Bleu Expria — pivot
-
-
-
-
#0B1F5C
-
Bleu nuit — premium
-
-
-
-
-
-
-
#C77A00
-
Attention
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2714d4b..ad7c542 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -47,25 +47,24 @@ Le frontend communique avec Supabase **uniquement pour l'authentification** (log
Versions officielles au 2026-04-17 (cf. ADR 006 pour la justification) :
-| Domaine | Choix | Version | Justification |
-| -------------- | ------------------------------ | ----------------- | ------------------------------------------------------------------------------ |
-| Framework UI | React | 19.2.x | Compilateur React, Actions, useOptimistic |
-| Build tool | Vite | 8.0.x | HMR rapide, moteur Rolldown, config minimale |
-| Langage | TypeScript (strict mode) | 6.0.x | Typage fort obligatoire pour détecter les bugs de permissions à la compilation |
-| Styling | Tailwind CSS | 4.2.x | Configuration CSS-first via `@theme`, moteur Oxide (builds microseconde) |
-| UI components | shadcn/ui | CLI latest | Copy-paste, total contrôle, supporte Tailwind 4 + React 19 depuis 2025 |
-| Routing | React Router | v7.14.x | Compatible API v6, data loaders disponibles |
-| État serveur | TanStack Query | 5.x | Cache, refetch, invalidation, remplace Redux/SWR |
-| État local | `useState` / `useReducer` | React 19 built-in | Pas de store global pour la V2 (voir ADR 003) |
-| Auth | Supabase JS | 2.103.x | Côté frontend : auth uniquement. Cf. `ARCHITECTURE.md` backend §2 |
-| Validation | Zod | latest | Validation des inputs formulaires (cf. SECURITY.md SEC-04) |
-| Rendu Markdown | react-markdown | latest | Rendu sécurisé des rapports IA (cf. SECURITY.md SEC-05) |
-| Tests | Vitest + React Testing Library | latest | Parité avec backend (qui utilise Vitest) |
-| Lint + Format | ESLint + Prettier | 9.x + latest | Standard |
-| CI | GitHub Actions | — | Typecheck + tests + `npm audit` |
+| Domaine | Choix | Version | Justification |
+|---|---|---|---|
+| Framework UI | React | 19.2.x | Compilateur React, Actions, useOptimistic |
+| Build tool | Vite | 8.0.x | HMR rapide, moteur Rolldown, config minimale |
+| Langage | TypeScript (strict mode) | 6.0.x | Typage fort obligatoire pour détecter les bugs de permissions à la compilation |
+| Styling | Tailwind CSS | 4.2.x | Configuration CSS-first via `@theme`, moteur Oxide (builds microseconde) |
+| UI components | shadcn/ui | CLI latest | Copy-paste, total contrôle, supporte Tailwind 4 + React 19 depuis 2025 |
+| Routing | React Router | v7.14.x | Compatible API v6, data loaders disponibles |
+| État serveur | TanStack Query | 5.x | Cache, refetch, invalidation, remplace Redux/SWR |
+| État local | `useState` / `useReducer` | React 19 built-in | Pas de store global pour la V2 (voir ADR 003) |
+| Auth | Supabase JS | 2.103.x | Côté frontend : auth uniquement. Cf. `ARCHITECTURE.md` backend §2 |
+| Validation | Zod | latest | Validation des inputs formulaires (cf. SECURITY.md SEC-04) |
+| Rendu Markdown | react-markdown | latest | Rendu sécurisé des rapports IA (cf. SECURITY.md SEC-05) |
+| Tests | Vitest + React Testing Library | latest | Parité avec backend (qui utilise Vitest) |
+| Lint + Format | ESLint + Prettier | 9.x + latest | Standard |
+| CI | GitHub Actions | — | Typecheck + tests + `npm audit` |
**Choix motivés par ADR :**
-
- ADR 001 : Cloudflare Pages (hébergement)
- ADR 002 : Découplage `auth-client` / `api-client`
- ADR 003 : Pas de Zustand pour la V2
@@ -96,16 +95,10 @@ expria-frontend/
│ └── 005-has-access-typed-strict.md
│
├── src/
-│ ├── app/ # ENTRY POINTS + LAYOUT DE LA COQUILLE
-│ │ ├── main.tsx # Entry point React (montage DOM)
-│ │ ├── providers.tsx # QueryClientProvider + ThemeProvider + Router
+│ ├── app/ # CONFIGURATION ET ENTRY POINTS
+│ │ ├── providers.tsx # QueryClientProvider + AuthProvider + Router
│ │ ├── router.tsx # Routes déclaratives
-│ │ ├── route-titles.ts # Mapping route → titre (breadcrumb Topbar)
-│ │ ├── AppLayout.tsx # Coquille app (sidebar + topbar + outlet)
-│ │ ├── Sidebar.tsx # Navigation desktop (navy permanent)
-│ │ ├── Topbar.tsx # Topbar sticky (breadcrumb, recherche, theme toggle)
-│ │ ├── BottomNav.tsx # Navigation mobile (< 1024px)
-│ │ └── MaintenancePage.tsx # Page affichée si VITE_MAINTENANCE_MODE=true
+│ │ └── main.tsx # Entry point React
│ │
│ ├── entities/ # OBJETS MÉTIER (indépendants de l'UI)
│ │ ├── user/
@@ -113,109 +106,67 @@ expria-frontend/
│ │ │ ├── lib.ts # hasAccess(), canSimulate()
│ │ │ ├── access.ts # IDENTIQUE à expria-backend/src/lib/access.ts
│ │ │ ├── api.ts # GET /plans/status, POST /auth/verify-token
-│ │ │ ├── query-keys.ts # Constantes de clés TanStack (PLAN_QUERY_KEY)
│ │ │ └── __tests__/
+│ │ │ ├── hasAccess.test.ts
+│ │ │ └── canSimulate.test.ts
│ │ │
│ │ ├── production/
│ │ │ ├── types.ts # Production, Tache, Mode
│ │ │ ├── lib.ts # helpers (format tache, etc.)
-│ │ │ └── api.ts # POST /simulations, GET /simulations/:id, PATCH /:id/contenu
+│ │ │ └── api.ts # POST /simulations, GET /simulations/:id
│ │ │
-│ │ ├── report/
-│ │ │ ├── types.ts # Report, Critere, Revelation, Diagnostic
-│ │ │ ├── lib.ts # Logique de floutage selon plan
-│ │ │ ├── api.ts # POST /corrections/ee, POST /corrections/eo
-│ │ │ └── __tests__/
-│ │ │
-│ │ ├── patterns/ # Sprint 3.6c — analyse patterns Premium
-│ │ │ ├── types.ts # Pattern, PatternAnalysis, PreparationIndex
-│ │ │ └── api.ts # GET /users/patterns
-│ │ │
-│ │ ├── presentation/ # Sprint 4c-2 — présentation T1 EO
-│ │ │ ├── types.ts
-│ │ │ └── api.ts # POST /presentations/generate
-│ │ │
-│ │ └── transcription/ # Sprint 4c — code Deepgram dormant (cf. FTD-37)
-│ │ ├── types.ts
-│ │ └── api.ts
+│ │ └── report/
+│ │ ├── types.ts # Report, Critere
+│ │ ├── lib.ts # Logique de floutage selon plan
+│ │ ├── api.ts # POST /corrections/ee, POST /corrections/eo
+│ │ └── __tests__/
+│ │ └── floutage.test.ts
│ │
│ ├── features/ # UI (composants + pages + hooks)
│ │ ├── auth/
-│ │ │ ├── components/ # ProtectedRoute
+│ │ │ ├── components/ # LoginForm, RegisterForm, ProtectedRoute
│ │ │ ├── pages/ # LoginPage, RegisterPage
│ │ │ └── hooks/ # useAuth
│ │ │
│ │ ├── dashboard/
-│ │ │ ├── components/ # DashboardFreeView/StandardView/PremiumView,
-│ │ │ │ # NclcHero, StatCards, RecentSimulations,
-│ │ │ │ # NextStepCard, PaywallBanner, MonProfilPreparation
+│ │ │ ├── components/ # DashboardFreeView, DashboardStandardView, DashboardPremiumView
│ │ │ ├── pages/ # DashboardPage (orchestre les vues selon le plan)
│ │ │ └── hooks/ # usePlan
│ │ │
│ │ ├── simulations/
-│ │ │ ├── components/ # SimulationForm, AudioRecorder, TimerDisplay,
-│ │ │ │ │ # TaskSelector, SujetCard/Display, IdeesSuggestions,
-│ │ │ │ │ # NclcCibleSelector, SpecialCharsKeyboard,
-│ │ │ │ │ # WordCountBar, TranscriptionDisplay
-│ │ │ │ └── rapport/ # ScoreHero, RevelationCards, CritereCard,
-│ │ │ │ # DiagnosticCallout, ConseilNclcCallout,
-│ │ │ │ # ExerciceInteractive, ProductionModeleSection,
-│ │ │ │ # JobStatusFallback
-│ │ │ ├── pages/ # EE : SimulationPage, SujetsPage, RapportPage
-│ │ │ │ # EO : SujetsEOPage, PreEnregistrementEOPage,
-│ │ │ │ # EnregistrementEOPage, SimulationEOPage,
-│ │ │ │ # ModeChoixT1Page, QuestionnaireT1Page,
-│ │ │ │ # PresentationGenereeT1Page
-│ │ │ ├── hooks/ # useSimulation, useSujets, useRapport, useTimer,
-│ │ │ │ # useAutosave, useIdees, useAudioRecorder,
-│ │ │ │ # useDeepgramLive (dormant — FTD-37)
-│ │ │ ├── lib/ # simulationConfig.ts (durées, mots cibles, etc.)
-│ │ │ └── state/ # SimulationFlowProvider + simulationFlow (machine d'état)
+│ │ │ ├── components/ # SimulationForm, AudioRecorder, TimerExam
+│ │ │ ├── pages/ # SimulationPage, RapportPage
+│ │ │ └── hooks/ # useSimulation, useExamMode
│ │ │
-│ │ ├── historique/ # Sprint 3.7 — liste des productions
-│ │ │ ├── components/ # SimulationsList, SimulationListItem
-│ │ │ ├── pages/ # HistoriquePage
-│ │ │ └── hooks/ # useSimulationsList
+│ │ ├── t2-live/
+│ │ │ ├── components/ # DialogueView, AudioVisualizer
+│ │ │ ├── pages/ # T2LivePage
+│ │ │ ├── hooks/ # useT2LiveSession
+│ │ │ ├── lib/
+│ │ │ │ ├── ws-client.ts # WebSocket + reconnexion
+│ │ │ │ └── audio.ts # Capture PCM + lecture réponse
+│ │ │ └── state/
+│ │ │ └── t2-machine.ts # State machine (idle → connecting → listening → ...)
│ │ │
-│ │ ├── progression/ # Sprint 3.6c — analyse patterns Premium
-│ │ │ ├── components/ # PreparationIndexHero, PatternsList,
-│ │ │ │ # PatternExerciceCard, ProgressionPremium,
-│ │ │ │ # BlurredProgression, NotReadyState
-│ │ │ ├── pages/ # ProgressionPage
-│ │ │ └── hooks/ # usePatterns
-│ │ │
-│ │ └── design-system/ # Page interne de référence visuelle (DA Charcoal)
-│ │ └── DesignSystemPage.tsx
+│ │ └── billing/
+│ │ ├── components/ # PaymentSummary
+│ │ ├── pages/ # PricingPage, CheckoutPage, UpgradePage
+│ │ └── hooks/ # useStripeCheckout
│ │
│ ├── shared/ # CODE RÉUTILISABLE NON MÉTIER
-│ │ ├── ui/ # PRIMITIVES EXPRIA (PascalCase) — voir note ci-dessous
-│ │ │ ├── Button.tsx
-│ │ │ ├── Card.tsx
-│ │ │ └── Badge.tsx
│ │ ├── components/
-│ │ │ ├── ui/ # PRIMITIVES SHADCN BRUTES (kebab-case) — voir note
-│ │ │ │ ├── avatar.tsx
-│ │ │ │ ├── badge.tsx
-│ │ │ │ ├── button.tsx
-│ │ │ │ ├── dialog.tsx
-│ │ │ │ ├── input.tsx
-│ │ │ │ ├── label.tsx
-│ │ │ │ ├── progress.tsx
-│ │ │ │ └── separator.tsx
-│ │ │ ├── Logo.tsx
-│ │ │ └── ThemeToggle.tsx
-│ │ ├── hooks/ # useTheme
+│ │ │ ├── ui/ # Button, Modal, Badge (shadcn/ui)
+│ │ │ ├── PaywallModal.tsx # Blocage + boutons upgrade
+│ │ │ └── Spinner.tsx
+│ │ ├── hooks/ # useDebounce, useLocalStorage
│ │ ├── lib/
│ │ │ ├── auth-client.ts # Supabase Auth uniquement (ADR 002)
│ │ │ ├── api-client.ts # Fetch + retry + timeout + logging (ADR 002)
│ │ │ ├── query-client.ts # Configuration TanStack Query
-│ │ │ ├── logger.ts # Logging structuré frontend
-│ │ │ ├── theme.ts # getInitialTheme / applyTheme / persistTheme
-│ │ │ ├── audio.ts # Helpers MediaRecorder + mime detection
-│ │ │ ├── date.ts # formatRelativeDate (Intl.RelativeTimeFormat)
-│ │ │ └── utils.ts # cn() — clsx + tailwind-merge
+│ │ │ └── logger.ts # Logging structuré frontend
│ │ ├── types/
-│ │ │ └── api.ts # ApiError, ApiErrorCode, FrontendErrorCode
+│ │ │ ├── api.ts # ApiResponse, ApiError
+│ │ │ └── common.ts # Types utilitaires
│ │ └── config/
│ │ └── env.ts # Validation des variables d'environnement au démarrage
│ │
@@ -235,29 +186,6 @@ expria-frontend/
└── README.md
```
-### Note sur `app/` — entry points + layout
-
-Le dossier `app/` contient les entry points React (`main.tsx`, `providers.tsx`, `router.tsx`) **ET** les composants layout de la coquille applicative (`AppLayout`, `Sidebar`, `Topbar`, `BottomNav`, `MaintenancePage`). Ces composants ne sont rattachés à aucune feature : ils définissent la structure visuelle globale de l'app et orchestrent l'affichage des routes. Leur rôle structurel justifie leur place dans `app/` plutôt que dans `shared/components/` ou dans une feature dédiée.
-
-> Note : `t2-live/` (Sprint 6) et `billing/` (Sprint 5) ne sont pas encore implémentés et n'apparaissent volontairement pas dans cette arborescence. Voir `ROADMAP.md` pour le calendrier.
-
-### Convention `shared/ui/` vs `shared/components/ui/`
-
-Deux dossiers UI cohabitent dans `shared/`. **La distinction est volontaire :**
-
-| Dossier | Convention | Contenu | Usage |
-| ----------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `shared/ui/` | PascalCase (`Button.tsx`) | **Wrappers Expria maison** : tokens DA Charcoal appliqués, API simplifiée, variants métier (`primary`, `secondary`, `ghost`, `upgrade`). | **À utiliser par défaut dans toutes les features.** |
-| `shared/components/ui/` | kebab-case (`button.tsx`) | **Primitives shadcn/ui brutes** générées par la CLI shadcn. | À utiliser **uniquement** comme base interne pour construire un wrapper Expria, ou quand une primitive Radix (Dialog, Popover) est nécessaire directement. |
-
-**Règle d'évolution :**
-
-- Toute nouvelle primitive Expria va dans `shared/ui/`.
-- Aucune nouvelle primitive ne doit être ajoutée manuellement dans `shared/components/ui/` — ce dossier est réservé aux fichiers générés par la CLI shadcn.
-- Si un wrapper Expria s'appuie sur une primitive shadcn, il l'importe depuis `shared/components/ui/` et l'expose sous une API simplifiée dans `shared/ui/.tsx`.
-
-Cette dualité est tracée dans `TECH_DEBT.md` (FTD-26) — documentée, pas à fusionner.
-
### Règles de dépendance entre dossiers
```
@@ -269,10 +197,6 @@ shared/ ne doit RIEN importer des autres dossiers
Cette hiérarchie garantit que la logique métier (`entities/`) ne dépend jamais de l'UI (`features/`), et que les briques réutilisables (`shared/`) restent portables.
-**Exception documentée — cross-entity report → user :**
-`entities/report/lib.ts` importe `hasAccess` et `Plan` depuis `entities/user/lib`.
-Justification : la logique de floutage des rapports dépend intrinsèquement des permissions utilisateur. Utiliser `hasAccess()` est obligatoire (Règle D) ; le déplacer vers `shared/` briserait la cohésion du domaine `user`. Cette exception est volontaire et ne doit pas être généralisée à d'autres paires d'entities.
-
---
## 4. Flux de données
@@ -336,12 +260,12 @@ Composant React
**Gestion des close codes côté frontend :**
-| Close code | Cause | Action côté frontend |
-| ---------- | ------------------------ | ------------------------------------------------------- |
-| 1000 | Fermeture normale | State → 'ended', afficher le rapport |
-| 4001 | AUTH_REQUIRED | State → 'error', redirect `/login` |
-| 4003 | PLAN_INSUFFICIENT | State → 'error', afficher PaywallModal Premium |
-| Autre | Erreur réseau ou serveur | State → 'error', message générique + bouton "Réessayer" |
+| Close code | Cause | Action côté frontend |
+|---|---|---|
+| 1000 | Fermeture normale | State → 'ended', afficher le rapport |
+| 4001 | AUTH_REQUIRED | State → 'error', redirect `/login` |
+| 4003 | PLAN_INSUFFICIENT | State → 'error', afficher PaywallModal Premium |
+| Autre | Erreur réseau ou serveur | State → 'error', message générique + bouton "Réessayer" |
---
@@ -421,44 +345,44 @@ export interface ApiError {
error: true
code: ApiErrorCode
message: string
- status?: number // quirk backend (simulations, corrections)
+ status?: number // quirk backend (simulations, corrections)
}
export type ApiErrorCode =
- | 'AUTH_REQUIRED' // 401 — JWT absent, invalide ou expiré
- | 'PLAN_INSUFFICIENT' // 403 — feature non disponible pour ce plan
- | 'QUOTA_REACHED' // 403 — quota de simulations Free épuisé
- | 'VALIDATION_ERROR' // 400 — corps de requête invalide (simulations, corrections)
- | 'INVALID_BODY' // 400 — corps de requête invalide (plans, stripe) — voir note
- | 'INVALID_PLAN' // 400 — valeur de plan inconnue
- | 'NO_ACTIVE_SUBSCRIPTION' // 400 — tentative d'upgrade sans abonnement actif
- | 'SIMULATION_NOT_FOUND' // 404 — simulation inexistante ou non accessible
- | 'STRIPE_WEBHOOK_INVALID' // 400 — signature webhook invalide
- | 'INTERNAL_ERROR' // 500 — erreur serveur inattendue
+ | 'AUTH_REQUIRED' // 401 — JWT absent, invalide ou expiré
+ | 'PLAN_INSUFFICIENT' // 403 — feature non disponible pour ce plan
+ | 'QUOTA_REACHED' // 403 — quota de simulations Free épuisé
+ | 'VALIDATION_ERROR' // 400 — corps de requête invalide (simulations, corrections)
+ | 'INVALID_BODY' // 400 — corps de requête invalide (plans, stripe) — voir note
+ | 'INVALID_PLAN' // 400 — valeur de plan inconnue
+ | 'NO_ACTIVE_SUBSCRIPTION' // 400 — tentative d'upgrade sans abonnement actif
+ | 'SIMULATION_NOT_FOUND' // 404 — simulation inexistante ou non accessible
+ | 'STRIPE_WEBHOOK_INVALID' // 400 — signature webhook invalide
+ | 'INTERNAL_ERROR' // 500 — erreur serveur inattendue
// Erreurs générées côté frontend uniquement (pas envoyées par le backend)
export type FrontendErrorCode =
- | 'TIMEOUT' // timeout côté client (AbortController)
- | 'NETWORK_ERROR' // pas de réponse réseau
- | 'PARSE_ERROR' // réponse non-JSON
+ | 'TIMEOUT' // timeout côté client (AbortController)
+ | 'NETWORK_ERROR' // pas de réponse réseau
+ | 'PARSE_ERROR' // réponse non-JSON
```
> **Note sur `VALIDATION_ERROR` vs `INVALID_BODY`** : le backend utilise deux codes pour la même classe d'erreur (corps invalide). `VALIDATION_ERROR` dans les routes simulations/corrections, `INVALID_BODY` dans les routes plans/stripe. Cette inconsistance est documentée dans `TECH_DEBT.md` backend (TD-15 à créer). Côté frontend, les deux codes sont gérés de la même manière dans l'UI.
### Codes d'erreur — mapping HTTP
-| Code backend | HTTP | Signification | Routes émettrices |
-| ------------------------ | ---- | ---------------------------------------------------- | --------------------------------------- |
-| `AUTH_REQUIRED` | 401 | JWT absent, invalide, expiré, ou profil introuvable | middleware, corrections |
-| `PLAN_INSUFFICIENT` | 403 | Feature réservée à un plan supérieur | middleware, simulations |
-| `QUOTA_REACHED` | 403 | 5/5 simulations utilisées (plan Free) | simulations |
-| `VALIDATION_ERROR` | 400 | Corps de requête invalide (simulations, corrections) | simulations, corrections |
-| `INVALID_BODY` | 400 | Corps de requête invalide (plans, stripe) | plans, stripe |
-| `INVALID_PLAN` | 400 | Valeur de plan inconnue dans le payload | plans, stripe |
-| `NO_ACTIVE_SUBSCRIPTION` | 400 | Upgrade prorata sans abonnement actif | plans |
-| `SIMULATION_NOT_FOUND` | 404 | Simulation inexistante ou non accessible | corrections |
-| `STRIPE_WEBHOOK_INVALID` | 400 | Signature webhook invalide | stripe |
-| `INTERNAL_ERROR` | 500 | Erreur serveur inattendue | plans, stripe, corrections, simulations |
+| Code backend | HTTP | Signification | Routes émettrices |
+|---|---|---|---|
+| `AUTH_REQUIRED` | 401 | JWT absent, invalide, expiré, ou profil introuvable | middleware, corrections |
+| `PLAN_INSUFFICIENT` | 403 | Feature réservée à un plan supérieur | middleware, simulations |
+| `QUOTA_REACHED` | 403 | 5/5 simulations utilisées (plan Free) | simulations |
+| `VALIDATION_ERROR` | 400 | Corps de requête invalide (simulations, corrections) | simulations, corrections |
+| `INVALID_BODY` | 400 | Corps de requête invalide (plans, stripe) | plans, stripe |
+| `INVALID_PLAN` | 400 | Valeur de plan inconnue dans le payload | plans, stripe |
+| `NO_ACTIVE_SUBSCRIPTION` | 400 | Upgrade prorata sans abonnement actif | plans |
+| `SIMULATION_NOT_FOUND` | 404 | Simulation inexistante ou non accessible | corrections |
+| `STRIPE_WEBHOOK_INVALID` | 400 | Signature webhook invalide | stripe |
+| `INTERNAL_ERROR` | 500 | Erreur serveur inattendue | plans, stripe, corrections, simulations |
### Pattern `apiFetch`
@@ -474,14 +398,9 @@ const { data, error, isLoading } = useQuery({
// error est de type ApiError | null
if (error) {
switch (error.code) {
- case 'AUTH_REQUIRED':
- redirectToLogin()
- break
- case 'QUOTA_REACHED':
- openUpgradeModal()
- break
- default:
- showGenericErrorToast()
+ case 'AUTH_REQUIRED': redirectToLogin(); break
+ case 'QUOTA_REACHED': openUpgradeModal(); break
+ default: showGenericErrorToast()
}
}
```
@@ -565,18 +484,9 @@ export const PLANS = {
},
}
-export function getPlanPermissions(plan: Plan) {
- /* ... */
-}
-export function canUserSimulate(user: { plan: string; simulations_used: number }): {
- allowed
- reason?
-} {
- /* ... */
-}
-export function checkFeatureAccess(plan: Plan, feature: Feature): boolean {
- /* ... */
-}
+export function getPlanPermissions(plan: Plan) { /* ... */ }
+export function canUserSimulate(user: { plan: string; simulations_used: number }): { allowed, reason? } { /* ... */ }
+export function checkFeatureAccess(plan: Plan, feature: Feature): boolean { /* ... */ }
```
### Alias frontend-idiomatiques
@@ -585,7 +495,11 @@ Le fichier `src/entities/user/lib.ts` ré-exporte ces fonctions sous des noms st
```typescript
// src/entities/user/lib.ts
-import { canUserSimulate, checkFeatureAccess, getPlanPermissions } from './access'
+import {
+ canUserSimulate,
+ checkFeatureAccess,
+ getPlanPermissions,
+} from './access'
/**
* Alias frontend-idiomatique de checkFeatureAccess.
@@ -636,7 +550,6 @@ VITE_SUPABASE_URL=https://xxx.supabase.co
VITE_SUPABASE_ANON_KEY=xxx
VITE_ENABLE_T2_LIVE=false # flag pour cacher T2 en prod tant que pas prêt
VITE_SENTRY_DSN=xxx # optionnel, pour monitoring
-VITE_MAINTENANCE_MODE=false # true = affiche MaintenancePage avant tout provider
```
### Règle absolue
@@ -650,7 +563,6 @@ VITE_MAINTENANCE_MODE=false # true = affiche MaintenancePage avant tout pr
- `STRIPE_WEBHOOK_SECRET`
Cette règle est vérifiée par :
-
- Le plugin Security Guidance de Claude Code (voir `SECURITY.md`)
- Une règle Semgrep dans la CI
- Le scan de secrets GitHub (Dependabot)
@@ -665,12 +577,12 @@ Cette règle est vérifiée par :
### Infrastructure cible (cf. ADR 001)
-| Composant | Plateforme | URL |
-| --------------- | --------------------- | ------------------------ |
-| Frontend | Cloudflare Pages | `https://expria.app` |
-| Backend API | Render (Frankfurt) | `https://api.expria.app` |
-| DNS | Vercel (actuellement) | — |
-| Base de données | Supabase (Frankfurt) | — |
+| Composant | Plateforme | URL |
+|---|---|---|
+| Frontend | Cloudflare Pages | `https://expria.app` |
+| Backend API | Render (Frankfurt) | `https://api.expria.app` |
+| DNS | Vercel (actuellement) | — |
+| Base de données | Supabase (Frankfurt) | — |
### Workflow de déploiement
@@ -705,13 +617,13 @@ Tests ciblés sur la logique critique, pas exhaustifs. On copie la stratégie ba
### Fichiers obligatoirement couverts
-| Fichier | Nombre de tests minimum |
-| --------------------------------------------------------- | --------------------------------------- |
-| `src/entities/user/__tests__/hasAccess.test.ts` | 14+ |
-| `src/entities/user/__tests__/canSimulate.test.ts` | 7 |
-| `src/entities/report/__tests__/floutage.test.ts` | 8+ (un par critère à flouter × 3 plans) |
-| `src/features/t2-live/state/__tests__/t2-machine.test.ts` | 6+ (transitions d'états) |
-| `src/features/dashboard/hooks/__tests__/usePlan.test.ts` | 3+ (cache, refetch, invalidation) |
+| Fichier | Nombre de tests minimum |
+|---|---|
+| `src/entities/user/__tests__/hasAccess.test.ts` | 14+ |
+| `src/entities/user/__tests__/canSimulate.test.ts` | 7 |
+| `src/entities/report/__tests__/floutage.test.ts` | 8+ (un par critère à flouter × 3 plans) |
+| `src/features/t2-live/state/__tests__/t2-machine.test.ts` | 6+ (transitions d'états) |
+| `src/features/dashboard/hooks/__tests__/usePlan.test.ts` | 3+ (cache, refetch, invalidation) |
### Fichiers non testés (par design)
@@ -738,50 +650,39 @@ Un échec sur l'un de ces jobs bloque le merge.
Ces règles sont héritées de `DEVELOPMENT_PRINCIPLES.md` backend et adaptées au frontend.
### Règle 1 — Séparation stricte
-
Le frontend affiche des données et relaie des actions. Aucune logique métier.
### Règle 2 — Source de vérité unique pour les plans
-
`src/entities/user/access.ts` est identique à `expria-backend/src/lib/access.ts`. Toute modification se fait dans les deux dépôts, dans le même commit logique.
### Règle 3 — Maximum 3 fichiers par étape
-
Hérité du backend. Si une tâche nécessite plus de 3 fichiers, elle est découpée.
### Règle 4 — Plan avant code
-
Aucune session Claude Code ne commence à coder sans plan validé.
### Règle 5 — Tests verts avant de continuer
-
`npm run test` et `npm run typecheck` doivent passer après chaque étape.
### Règle 6 — Jamais de clé privée dans le frontend
-
Variables `VITE_*` uniquement. Cf. section 7.
### Règle 7 — Jamais de `if (plan === 'xxx')`
-
Toute vérification de permission passe par `hasAccess()` ou `canSimulate()`. Cf. ADR 005.
### Règle 8 — Jamais de logique métier dans `features/`
-
Les règles de floutage, de quotas, de permissions vivent dans `entities/*/lib.ts`. Les composants de `features/` appellent ces fonctions.
### Règle 9 — Jamais d'appel direct à Supabase pour les données métier
-
Supabase côté frontend est **uniquement** pour l'authentification. Toute lecture/écriture passe par le backend Hono.
### Règle 10 — Signaler tout écart par rapport au plan
-
Identique à la Règle H backend.
---
## 11. Historique des versions de ce document
-| Version | Date | Auteur | Changements |
-| ------- | ---------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1.0 | 2026-04-17 | Hermann (avec assistance Claude) | Création initiale |
-| 1.1 | 2026-04-25 | Hermann (avec assistance Claude) | FTD-25 + FTD-26 — §3 reflète l'arborescence réelle ; ajout note `app/` (entry points + layout) ; ajout convention `shared/ui/` vs `shared/components/ui/` |
+| Version | Date | Auteur | Changements |
+|---|---|---|---|
+| 1.0 | 2026-04-17 | Hermann (avec assistance Claude) | Création initiale |
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index fb6b15f..b0c2276 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -29,923 +29,9 @@ Chaque entrée suit ce format :
---
-## [Unreleased] — 2026-07-02 — Sprint 7.5 — Clean FTD-44
-
-### Changed
-
-- Hooks audio génériques `useAudioCapture`, `useAudioPlayback`, `useAudioRecording` (+ test) déplacés de `features/t2-live/hooks/` vers `shared/lib/audio/`. Imports mis à jour dans `useT2LiveSession.ts` et `useT1LiveSession.ts`. Résout FTD-44.
-
-### Removed
-
-- Marqueurs `// TODO(FTD-44)` retirés de `useT1LiveSession.ts`.
-
-### Notes
-
-- 4 fichiers déplacés (3 hooks + 1 test) + 2 fichiers d'imports édités. Aucun changement fonctionnel (pipeline audio « Voie A » intact).
-- 301/301 tests verts, 0 erreur typecheck.
-- Validation manuelle : T2 Live D2-D5 verts ; T1 Live parcours complet vert (préparation → dialogue → présentation → interruption → fin → téléchargement audio → rapport), navigation privée. D6 non rejoué (optionnel, état pré-existant partiel, sans lien avec cette refacto).
-- Commit `d9160c4`.
-- **TECH_DEBT.md v1.30 → v1.31** : FTD-44 résolue (fermée) ; ajout FTD-47 🟢 (sessions T1 Live non taguées dans l'historique — découverte pendant cette session, racine Sprint 7a backend). **14 → 15 FTD actives — cap de 15 atteint.**
-- **GOLDEN_DATASET.md** : Groupe D étendu avec D12-D16 (T1 Live) — libellés vérifiés dans le code réel (`T1PreparationPage.tsx`, `T1DialoguePage.tsx`, `t1-machine.ts`).
-
----
-
-## [Unreleased] — 2026-06-30 — Sprint 7b — Frontend T1 Live (monologue + interruption non déterministe)
-
-### Added
-
-- Machine d'état T1 (`features/t1-live/state/t1-machine.ts`) — 8 états purs (`idle`, `preparing`, `connecting`, `presenting`, `interrupted`, `processing`, `ended`, `error`). Le cœur est la transition `interrupted ⇄ presenting` (interruption examinateur puis reprise candidat). +23 tests.
-- `useT1LiveSession` (`features/t1-live/hooks/useT1LiveSession.ts`) — orchestrateur du dialogue T1, calqué sur `useT2LiveSession` (discipline « Voie A »). WS `wss://${API_URL}/t1/live?token=` (PAS de `&sujet=` — T1 n'est pas subject-based). Aucun VAD micro (T1 = monologue) ; l'uplink micro est coupé/rétabli pendant une interruption via un **ref** (`uplinkMutedRef`), jamais via `setState` (leçon Voie A). Réagit aux signaux applicatifs `{type:'interruption_start'}` / `{type:'interruption_end'}`. Timer dur 180 s. Close codes 1000/4001/4003/4005/4006.
-- `T1PreparationPage` + `T1DialoguePage` (`features/t1-live/pages/`) — parcours préparation → dialogue (3:00) ; écran terminal « Télécharger l'audio » + « Voir le rapport » (`/rapport/:id`). L'UI ne suppose JAMAIS qu'une relance suit (interruption non déterministe).
-- `T1SpeakingIndicator` (`features/t1-live/components/`) — indicateur de prise de parole (amplitude micro réelle en `presenting`, animation décorative en `interrupted`).
-- Carte `EO_T1_LIVE` dans `TaskSelector` (discriminateur `live?: 'T1' | 'T2'`, label « Tâche 1 — Live ») gatée Premium via `hasAccess(plan, 'oral_t2_live')` (TD-24 — pas de nouvelle permission, le gate couvre T1 et T2 Live) + prop `onT1LiveSelect`. `SimulationEOPage` câble `onT1LiveSelect → /simulation/eo/t1/live/preparation`.
-- `features/simulations/lib/t1Questionnaire.ts` — définition partagée du questionnaire T1 (FIELDS + schéma zod + `EMPTY_REPONSES`), réutilisée par le batch `QuestionnaireT1Page`.
-
-### Changed
-
-- `useT1LiveSession` aligné sur le **Patch 7a backend** : plus d'envoi du message `{type:'context'}`, plus d'option `reponses`, la session audio démarre directement sur `ws.onopen` (WS_OPENED → presenting).
-- Parcours T1 Live simplifié : carte `EO_T1_LIVE` → préparation → dialogue (plus d'étape questionnaire intermédiaire).
-- `t1-machine` : commentaire et test nettoyés (mapping close **4004** retiré → 4006), cohérent avec la suppression du contexte côté backend.
-
-### Removed
-
-- `T1LiveQuestionnairePage` et `T1LiveContext` (post-Patch 7a) — le backend n'exige plus de message `context` ni de réponses pré-remplies ; ces écrans/état deviennent sans objet.
-
-### Notes
-
-- **FTD-44 gelée** (§3bis TECH_DEBT) — les trois hooks audio génériques sont empruntés à `features/t2-live/hooks/` (violation FSD inter-features assumée et tracée, sites marqués `// TODO(FTD-44)`), réactivée au Sprint 7.5 (factorisation Sprint 7).
-- WebSocket / AudioContext non matérialisables en jsdom → validation manuelle ; la logique pure de transition est couverte par `t1-machine.test.ts`.
-- Bugs amont observés au test manuel, hors contrôle frontend : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
-
----
-
-## [Unreleased] — 2026-06-29 — Sprint 6e — T2 Live « Voie A » (mix audio temps réel)
-
-### Added
-
-- `public/pcm-record-processor.js` — AudioWorklet « tap » branché sur le mix du contexte partagé : prélève le mix (micro candidat + voix IA) en temps réel et émet des chunks Int16 vers le hook d'enregistrement. Permet un WAV aligné temporellement sur une horloge unique.
-- `useAudioCapture` expose désormais `contextRef` (AudioContext partagé) + `mixNodeRef` (GainNode point de convergence) pour partager une horloge unique entre capture, playback et enregistrement.
-- Indicateur de prise de parole du candidat : VAD par RMS sur le flux micro Int16 (seuils `SPEAK_RMS=500` / `SILENCE_RMS=250`, debounce 700 ms) pilotant les transitions `speaking` ↔ `listening`.
-- Détection `newTurn` : un chunk audio IA reçu après > 800 ms de silence IA marque la reprise de parole de l'examinateur → réalignement de l'edge-tracking micro + `USER_SILENT`.
-
-### Changed
-
-- **Architecture audio « Voie A »** : passage à **UN SEUL AudioContext au rate NATIF** (≈ 48 kHz), partagé par capture / playback / enregistrement. Suppression du forçage `{ sampleRate: 16000 }` et des deux contextes séparés 16 k / 24 k.
-- `useAudioPlayback({ contextRef, mixNodeRef })` ne crée plus son propre contexte : la source IA est routée vers `ctx.destination` (audible) ET vers `mixGain` (captée par le tap). Buffer créé à 24 k, rééchantillonné automatiquement par le contexte natif.
-- `useAudioRecording` : enregistrement via tap worklet sur le mix (`mixGain → recordNode → gain(0) → destination`, sink muet pour pull cross-navigateur). Buffer Int16 hors cycle de vie du contexte (`exportWAV()` survit à la fermeture). **WAV mono au rate natif, single-track, zéro resample** (remplace l'ancien WAV 24 k multi-piste).
-- `useT2LiveSession` : cycle de vie audio aligné sur la « Voie A » — start sur `ws.onopen` après `capture.start()` résolu ; stop sur `endDialogue` (débranche le tap, buffer conservé) ; cancel ferme le contexte (buffer abandonné, aucun export).
-- **Bug 6 — « Nouvelle simulation »** : le routage vers la bonne tâche s'appuie désormais sur le champ `tache` propagé dans le rapport (`report/api.ts`, `types.ts`, `RapportPage.tsx`), sans query param.
-
-### Fixed
-
-- **Anti-blanc EO** : suppression des silences/blancs en début de dialogue grâce à l'horloge unique et au scheduling continu de la voix IA.
-- Correction de l'écho de la voix candidat (`mixGain` jamais connecté à `destination`).
-- **Bug 4 — « Voir le rapport »** : la navigation vers `/rapport/:id` aboutit bien (garde `navigatingAwayRef` empêchant le cleanup/teardown d'avorter la redirection).
-- **Bug 5 — « Annuler » (`cancelDialogue`)** : arrête l'enregistrement, ne déclenche aucune évaluation, ne produit aucun WAV et ne persiste aucune production (WS fermée sans message de fin).
-- Stabilité de l'uplink micro : l'architecture « Voie A » supprime l'état React réactif sur la `MediaStream` (source du _starving_ d'uplink), au profit de refs stables sur le contexte/mix partagés.
-
-### Removed
-
-- Helpers `resample16kTo24k` et `mixTracksToInt16` de `audio-utils.ts` (rendus inutiles par l'horloge unique et le single-track). Helpers purs conservés : `arrayBufferToBase64`, `base64ToArrayBuffer`, `int16ToFloat32`, `float32ToInt16`, `concatInt16`, `buildWavHeader`.
-- Instrumentation de diagnostic `[BISECT]` retirée des hooks T2 Live (logique runtime VAD / garde-fous / routage des messages conservée).
-
-### Notes
-
-- Tous les bugs ciblés (anti-blanc, Voie A, bugs 4/5/6, indicateur de parole) validés **à l'oreille en navigation privée** — console sans `[BISECT]`.
-- Tests frontend : 259 → **269 verts (37 fichiers)**.
-- AudioContext / AudioWorklet / WebSocket non matérialisables en jsdom → validation audio à l'oreille (objectif de la session). `useAudioRecording` couvert sur sa surface pure (export WAV, reset).
-
----
-
-## [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
-
-- `public/pcm-capture-processor.js` — AudioWorklet processor : capture micro, rééchantillonnage vers 16 kHz si `sampleRate` natif différent, conversion Float32 → Int16 LE, chunks de 4096 samples (~256 ms).
-- `src/shared/lib/audio-utils.ts` — 6 helpers purs : `arrayBufferToBase64`, `base64ToArrayBuffer`, `int16ToFloat32`, `float32ToInt16`, `resample16kTo24k`, `buildWavHeader`.
-- `src/features/t2-live/hooks/useAudioCapture.ts` — hook capture : `getUserMedia` (mono, echoCancellation, noiseSuppression) → AudioContext 16 kHz → AudioWorklet → callback `onChunk(base64)`. Cleanup au stop/unmount.
-- `src/features/t2-live/hooks/useAudioPlayback.ts` — hook playback : AudioContext 24 kHz lazy-init, scheduling séquentiel via `start(max(currentTime, lastEndTime))` pour lecture sans gaps. Cleanup au stop/unmount.
-- `src/features/t2-live/hooks/useAudioRecording.ts` — hook recording : buffer chronologique unique normalisé 24 kHz (chunks candidat rééchantillonnés 16→24k), `addAIChunk(base64)` décode en interne, `exportWAV()` → Blob `audio/wav` mono 24 kHz.
-- 12 tests `audio-utils.test.ts` (round-trips base64/ArrayBuffer, clamping int16/float32, interpolation resample, header WAV).
-- 7 tests `useAudioRecording.test.ts` (add candidat resample, add IA, alternance, header WAV, reset, export vide, chunks vides ignorés).
-
-### Notes
-
-- Tests frontend : 219 → 238 verts (+19).
-- `useAudioCapture` et `useAudioPlayback` dépendent de AudioContext (API navigateur) — validation manuelle au Sprint 6c.
-- AudioWorklet utilisé directement (pas ScriptProcessorNode) — FTD-06 ne s'applique plus pour T2 Live.
-
----
-
-## [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
-
-- `src/features/billing/hooks/useCustomerPortal.ts` — hook `{ openPortal, isLoading, error }` autour de `createCustomerPortalSession` + redirect full-page vers Stripe Customer Portal. Message d'erreur backend (`NO_ACTIVE_SUBSCRIPTION`) propagé tel quel.
-- `src/features/billing/components/AccountBillingSection.tsx` — section UI `Card` : badge plan + CTA contextuel (Free → lien « Voir les plans » vers `/plan` ; Standard/Premium → bouton « Gérer mon abonnement » → Customer Portal).
-- `src/features/account/pages/ParametresPage.tsx` — page conteneur `/parametres` avec section Abonnement + section Session (bouton « Se déconnecter » → `signOut()` + `queryClient.clear()` + `navigate('/login')`).
-- 6 tests (3 useCustomerPortal + 3 AccountBillingSection).
-
-### Changed
-
-- `src/features/billing/pages/PricingPage.tsx` — branche **Standard→Premium** routée vers `useCustomerPortal.openPortal()` au lieu de Stripe Checkout direct (le Customer Portal Stripe affiche le montant prorata + confirmation native). `buildCtaConfigs` refactor : signature `(plan, isStandardPending, isPremiumPending, onUpgrade)` ; loading state combiné selon source ; erreur unifiée `checkoutError ?? portalError`.
-- `src/features/billing/__tests__/PricingPage.test.tsx` — 6e test : Standard click « Passer en Premium » → `createCustomerPortalSession` appelé (et `createCheckoutSession` non appelé).
-- `src/app/router.tsx` — `/parametres` → ` ` (sous PrivateLayout).
-
-### Notes
-
-- Tests : 212 → 219 verts (+7).
-- Customer Portal Stripe doit être configuré côté Dashboard Stripe (hors code) pour fonctionner en prod.
-
----
-
-## [Unreleased] — 2026-04-26 — Sprint 5c — Flow Checkout post-redirect
-
-### Added
-
-- `src/features/billing/hooks/useStripeCheckout.ts` — hook `{ checkout, isLoading, pendingPriceType, error }` autour de la mutation Stripe Checkout + redirect full-page sur succès. `pendingPriceType` permet l'affichage loading par carte sans state local.
-- `src/features/dashboard/hooks/useUpgradeSuccessHandler.ts` — détecte `?upgrade=success` au mount du Dashboard, invalide le cache `PLAN_QUERY_KEY` (refetch automatique du plan), nettoie l'URL via `history.replaceState` (préserve les autres params utm\_\*, etc.).
-- `src/features/dashboard/components/UpgradeSuccessBanner.tsx` — callout success-soft « Bienvenue ! Votre plan a été mis à jour. » + bouton dismiss.
-- 9 tests (4 useStripeCheckout + 5 useUpgradeSuccessHandler).
-
-### Changed
-
-- `src/features/billing/pages/PricingPage.tsx` — migration vers `useStripeCheckout` (suppression `useMutation` inline + `pendingType` state local).
-- `src/features/dashboard/pages/DashboardPage.tsx` — branche `useUpgradeSuccessHandler` + rend `` au-dessus de `` quand `showSuccess`.
-
-### Cross-repo
-
-- `expria-backend@28f8373` — `fix(stripe): cancel_url /tarifs → /plan`. Bug détecté lors de cette session : la route `/tarifs` n'existe pas côté frontend, les checkouts annulés aboutissaient sur un 404. Corrigé en commit séparé sur le backend.
-
-### Notes
-
-- Tests : 203 → 212 verts (+9).
-- Race condition connue (FTD-43) : le webhook Stripe peut arriver après le redirect frontend ; `usePlan()` peut retourner l'ancien plan brièvement. Le banner indique « rafraîchissez dans quelques secondes » pour gérer ce cas.
-
----
-
-## [Unreleased] — 2026-04-26 — Sprint 5b — Page tarifaire `/plan`
-
-### Added
-
-- `src/features/billing/api.ts` — `createCheckoutSession(priceType)` + `createCustomerPortalSession()` (utilisée Sprint 5d).
-- `src/features/billing/components/PlanCard.tsx` — carte plan présentationnelle pure : props `cta`, `currentBadge`, `highlighted`, `ctaHint`, `errorMessage`.
-- `src/features/billing/pages/PricingPage.tsx` — orchestration 3 colonnes (Découverte / Standard / Premium) avec gating dynamique selon `usePlan()`. CTA payant → Stripe Checkout (full-page redirect). Callout d'erreur sous la carte cliquée.
-- 5 tests PricingPage (rendu Free/Standard/Premium + click + erreur).
-
-### Changed
-
-- `src/shared/config/env.ts` + `.env.example` — ajout `VITE_STRIPE_PRICE_STANDARD` + `VITE_STRIPE_PRICE_PREMIUM` (optionnels — public price_ids Stripe).
-- `src/app/router.tsx` — `/plan` → ` ` (sous PrivateLayout, donc ProtectedRoute).
-- **Uniformisation CTA upgrade** : `SimulationsList`, `RapportPage`, `TaskSelector`, `DashboardFreeView`, `PaywallBanner` → libellé « Voir les plans » (au lieu de « Passer en Standard » / « Passer en Premium → » / « Voir les offres »). Cibles navigation inchangées (`/plan`).
-- `SimulationsList.test.tsx` — assertion adaptée au nouveau libellé.
-
-### Notes
-
-- Tests : 198 → 203 verts (+5).
-- `DashboardStandardView` et `BlurredProgression` conservent leurs CTA orientés (« Passer en Premium ») — sémantiquement corrects (Standard a un seul upgrade possible ; pattern_analysis est Premium-only).
-
----
-
-## [Unreleased] — 2026-04-26 — Sprint 4.8 — Phonologie EO (frontend)
-
-### Added
-
-- `src/entities/report/__tests__/getMaxScorePerCritere.test.ts` — 7 tests (détection maxScore + mapping libellés EO).
-
-### Changed
-
-- `src/entities/report/lib.ts` — nouveau helper `getMaxScorePerCritere(rapport): 4 | 5` (détection sur criteres.length === 5). `CRITERE_NOM_TO_CODE` étendu avec les 4 libellés EO Sprint 4.8.
-- `src/features/simulations/components/rapport/CritereCard.tsx` — nouvelle prop `maxScore` : affiche `X/4` (EO Sprint 4.8) ou `X/5` (EE, EO legacy).
-- `src/features/simulations/pages/RapportPage.tsx` — calcul maxScore propagé aux CritereCard.
-- `src/entities/report/types.ts` — commentaire Critere.score clarifié.
-
-### Notes
-
-- Rétrocompatibilité : rapports EO legacy (4 critères × /5) et EE (4 × /5) inchangés.
-- Tests : 191 → 198 verts (+7).
-
----
-
-## [Unreleased] — 2026-04-26 — Sprint 4.6 — UI EO (waveform + timeline)
-
-### Added
-
-- `RecordingWaveform.tsx` — visualiseur audio animé (AnalyserNode, fftSize 256,
- smoothing 0.7, 32 barres). Visible uniquement pendant `isRecording`. Respecte
- `prefers-reduced-motion` (frame statique). AudioContext fermé au cleanup.
-- `RecordingTimeline.tsx` — barre de progression colorée avec seuils fixes :
- vert (0 → maxSeconds-30s), orange (maxSeconds-30s → maxSeconds-15s),
- rouge (maxSeconds-15s → fin). Applicable T1 et T3.
-- `RecordingTimeline.test.tsx` — 7 tests (logique seuils + rendu + clamp).
-
-### Changed
-
-- `useAudioRecorder.ts` — expose `mediaStream: MediaStream | null` (set au
- start, reset au cleanup).
-- `AudioRecorder.tsx` — intègre Waveform + Timeline dans l'UI d'enregistrement.
-
-### Notes
-
-- Aucun changement backend.
-- Tests : 166 → 173 verts (+7).
-
----
-
-## [Unreleased] — 2026-04-25 — Sprint 4.5 Clean + fixes Golden Dataset
-
-### Added
-
-- `features/simulations/components/rapport/__tests__/ScoreHero.test.tsx` — 3 tests (un par état : depasse / atteint / !atteint)
-- `features/simulations/components/rapport/__tests__/ConseilNclcCallout.test.tsx` — 3 tests (patch FTD-40)
-- Test d'hydratation EO_T1 dans `simulationFlowT1.test.tsx` (resume au refresh : production + présentation)
-
-### Changed
-
-- `ARCHITECTURE.md` §3 — arborescence réelle reflétée (FTD-25) : note `app/` documente entry points + composants layout (AppLayout, Sidebar, Topbar, BottomNav, MaintenancePage). `t2-live/` et `billing/` retirés (non implémentés). Ajout `entities/{patterns,presentation,transcription}` et `features/{historique,progression,design-system}`. Bump v1.1.
-- `ARCHITECTURE.md` §3 — convention `shared/ui/` (wrappers Expria PascalCase) vs `shared/components/ui/` (primitives shadcn kebab-case) documentée (FTD-26).
-- `ConseilNclcCallout.tsx` — props `nclc` + `nclcCible` ajoutées ; patch temporaire FTD-40 (texte fixe « Excellent travail — vous avez dépassé votre objectif. Continuez sur cette lancée pour viser NCLC {nclc+1} ! » quand `nclc > nclcCible`).
-- `RapportPage.tsx` — passe `nclc` + `nclcCible` à `ConseilNclcCallout`.
-- `ScoreHero.tsx` — encart de conclusion à 3 états (depasse / atteint / !atteint).
-- `SimulationFlowProvider.tsx` — `useEffect` persiste `production.id` dans `localStorage.expria_simulation_id` pour TOUS les flows (EE + EO_T1 + EO_T3) → resume au refresh fonctionnel pour EO.
-
-### Fixed
-
-- Sprint 4.5 Clean — 3 erreurs lint Sprint 4c corrigées :
- - `useDeepgramLive.ts:152` — directive `eslint-disable-next-line` orpheline retirée
- - `useAudioRecorder.test.ts:77,81` — params `_t`/`_timeslice` neutralisés via `void` (signature mock préservée)
- - `useAudioRecorder.ts:73` — `eslint-disable-next-line react-hooks/refs` + commentaire renvoyant à FTD-38
-- `QuestionnaireT1Page.test.tsx:10` — import `React` inutilisé supprimé (TS6133).
-
-### Notes
-
-- **TECH_DEBT.md bumps** : v1.23 (FTD-25/26 fermées) → v1.24 (FTD-38/39 ouvertes) → v1.25 (FTD-40/41 ouvertes).
-- **FTD ouvertes Sprint 4.5** :
- - FTD-38 🟢 — `useAudioRecorder` ref mise à jour pendant render (eslint-disable local en place)
- - FTD-39 🟡 — Règle D violée dans `StatCards.tsx:90` (préexistant Sprint UI Polish)
- - FTD-40 🟡 — Conclusion `conseil_nclc` backend incohérente quand NCLC atteint > cible (patch frontend en place, fix backend prompt à venir)
- - FTD-41 🔴 — Persistance présentation EO T1 en BDD (résout FTD-35 ; localStorage instable)
-- **FTD fermées Sprint 4.5** : FTD-25 🟢, FTD-26 🟡.
-- **Cap FTD : 19/15 — dépassé de 4. Résorption obligatoire au Sprint 5.5 avant toute nouvelle FTD.**
-- Tests : 159 → 166 verts (+7). Typecheck + lint : 0 erreur.
-
----
-
-## [Unreleased] — 2026-04-25 — Sprint 4c — Frontend EO (T1 + T3)
-
-### Added
-
-- `features/simulations/pages/SimulationEOPage.tsx` — TaskSelector EO (T1, T3, T2 cadenas Premium)
-- `features/simulations/pages/SujetsEOPage.tsx` — grille sujets EO_T3 + bouton aléatoire
-- `features/simulations/pages/PreEnregistrementEOPage.tsx` — consigne + durée recommandée
-- `features/simulations/pages/EnregistrementEOPage.tsx` — enregistrement audio + auto-submit à expiration
-- `features/simulations/pages/ModeChoixT1Page.tsx` — choix Générer / Enregistrer directement
-- `features/simulations/pages/QuestionnaireT1Page.tsx` — 5 champs + validation Zod + génération IA
-- `features/simulations/pages/PresentationGenereeT1Page.tsx` — texte généré, modifier, copier, .txt, refaire, localStorage
-- `features/simulations/hooks/useAudioRecorder.ts` — MediaRecorder, timer, maxSeconds, auto-stop, download
-- `features/simulations/hooks/useDeepgramLive.ts` — conservé dormant (FTD-37)
-- `features/simulations/components/AudioRecorder.tsx` — UI enregistrement, maxSeconds/onMaxReached
-- `features/simulations/components/TranscriptionDisplay.tsx` — conservé dormant
-- `entities/transcription/` — token Deepgram (dormant, FTD-37)
-- `entities/presentation/` — generatePresentation (POST /presentations/generate)
-- `shared/lib/audio.ts` — blobToBase64 helper
-
-### Changed
-
-- `SimulationFlowProvider` — étendu EO : submitEoAudio, presentationT1, résolution race condition step=done
-- `entities/report/api.ts` — CORRECTION_EE_TIMEOUT_MS=60s / CORRECTION_EO_TIMEOUT_MS=120s
-- `entities/report/types.ts` — CorrectEoPayload étendu audioBase64/mimeType
-- MIME normalisé côté frontend (strip codec params)
-- router.tsx — 7 nouvelles routes EO sous SimulationFlowLayout
-- FTD-30 à 37 tracées dans TECH_DEBT.md
-
-### Notes
-
-- Transcription live Deepgram abandonnée pour le MVP — Gemini batch côté backend
-- Audio non stocké côté serveur — bouton télécharger local
-- Tests : 122 → 159 verts (+37)
-
----
-
-## [Unreleased] — 2026-04-25 — Fix timeout API
-
-### Fixed
-
-- `DEFAULT_TIMEOUT_MS` augmenté de 5s à 15s dans `api-client.ts`. Le backend Render Starter a des latences occasionnelles >5s sur les premières requêtes authentifiées.
-
-### Notes
-
-- UptimeRobot configuré pour pinger `https://api.expria.app/` toutes les 5 minutes (keepalive serveur).
-
----
-
-## [Unreleased] — 2026-04-25 — Sprint UI Polish — Sidebar + Topbar + Dashboard
-
-### Added
-
-- `src/app/Topbar.tsx` — topbar sticky avec backdrop-blur, breadcrumb "Expria › {page}", barre de recherche (placeholder), icônes raccourcis clavier et notifications.
-- `src/app/route-titles.ts` — mapping centralisé pathname → titre de page, consommé par Topbar.
-- `src/features/dashboard/components/NclcHero.tsx` — carte hero NCLC avec jauge horizontale 5→10 + anneau SVG score circulaire. Supporte état placeholder (Free/vide).
-- `src/features/dashboard/components/StatCards.tsx` — 3 cartes métriques (simulations restantes, NCLC estimé, dernier score avec delta coloré).
-- `src/features/dashboard/components/RecentSimulations.tsx` — liste 3 dernières simulations avec badge NCLC coloré + navigation vers `/rapport/:id`.
-- `src/features/dashboard/components/NextStepCard.tsx` — carte "Prochaine étape" recommandée, contenu statique par plan.
-- `src/features/dashboard/components/DashboardFreeView.tsx` — vue dashboard Free (hero placeholder, stat cards, premiers pas, PaywallBanner).
-- `src/features/dashboard/components/DashboardStandardView.tsx` — vue dashboard Standard (hero NCLC dernière simu, simulations récentes, NextStepCard).
-- `src/features/dashboard/components/DashboardPremiumView.tsx` — vue dashboard Premium (tout Standard + MonProfilPreparation).
-
-### Changed
-
-- `src/app/Sidebar.tsx` — icônes lucide-react (LayoutGrid, Pencil, Mic, etc.), cadenas Lock sur items verrouillés, badge upgrade ArrowUpCircle sur "Mon plan", user footer (avatar initiales + nom + plan + ThemeToggle), logo header "EX|PRIA" avec séparateur et sous-titre.
-- `src/app/AppLayout.tsx` — intégration Topbar sticky, padding reporté sur wrapper contenu.
-- `src/features/dashboard/pages/DashboardPage.tsx` — refactoré en orchestrateur : routing vers Free/Standard/Premium via `hasAccess`. Aucun `plan === 'xxx'` (Règle D).
-- `src/features/dashboard/components/PaywallBanner.tsx` — refonte full-width + correction tokens morts Boréal (`border-brand-100`, `dark:border-brand/20`).
-
-### Removed
-
-- `src/app/MobileHeader.tsx` — fonctionnalité reprise par Topbar + Sidebar (0 consommateur confirmé par grep).
-
-### Notes
-
-- Tests : 122/122 verts. Typecheck : 0 erreur.
-- Contenu NextStepCard statique par plan (pas d'endpoint backend dédié).
-- Hero NCLC : Premium → usePatterns, Standard → NCLC dernière simulation, Free → état placeholder.
-- Timeout API intermittent (cold start Render) préexistant — cause le fallback temporaire plan=free au chargement initial.
-
----
-
-## [Unreleased] — 2026-04-24 — Sprint DA Charcoal — Reskin complet
-
-### Changed
-
-- Remplacement intégral `src/index.css` par palette Charcoal (DESIGN_SYSTEM.md v2.0). Dark = thème par défaut, `.light` = override via `@custom-variant light`.
-- Sidebar navy `#0C1528` permanent (identique dark et light) avec tokens `--color-sidebar-*`.
-- Layout `AppLayout` : radial-gradient sur ``, sidebar 230px, `max-w-[1100px]`.
-- Script anti-FOUC inline dans `index.html` (détection `prefers-color-scheme` + `localStorage`).
-- Renommage tokens Boréal→Charcoal sur ~45 composants (ink-1→ink-primary, expria→brand, line→border, deep→sidebar-bg, \*-bg→\*-soft, etc.).
-- Inversion `dark:` → baseline + `light:` sur 5 primitives shadcn (button, badge, input, dialog, avatar).
-- `DesignSystemPage` réécrite avec palette Charcoal complète.
-- `docs/adr/006-stack-versions-2026.md` mis à jour : tokens Charcoal, suppression `@variant dark` et `.dark {}`.
-
-### Fixed
-
-- Logo Expria : wordmark forcé en `text-white` dans la sidebar (invisible en light mode sur fond navy).
-
-### Notes
-
-- 59 fichiers modifiés, +1173/-727 lignes.
-- Tests : 122/122 verts. Typecheck : 0 erreur.
-- Timeout API intermittent observé (cold start Render) — préexistant, non lié au reskin.
-
----
-
-## [Unreleased] — 2026-04-23 — Clean FTD-23 + FTD-24
-
-### Fixed
-
-- **FTD-23 résolu** : `useAutosave` ne fire plus après correction — `enabled` propagé avec `step !== 'done' && step !== 'correcting'` depuis `SimulationForm`. 2 tests de régression ajoutés.
-- **FTD-24 résolu** : polling automatique 3s dans `useRapport` quand `exercices_status` ou `modele_status === 'pending'`. Arrêt auto dès ready/error. Timeout 2 min avec message + bouton Réessayer dans `JobStatusFallback`. 5 tests ajoutés.
-
-### Notes
-
-- Tests frontend : 122/122 verts (+7 vs baseline 115).
-- TECH_DEBT.md → v1.19. 10 FTD actives (cap 15).
-
----
-
-## [Unreleased] — 2026-04-23 — FTD-28 — Semgrep CI + CI verte
-
-### Added
-
-- Semgrep scan (`--severity=ERROR`) dans les CI frontend et backend (FTD-28).
-- Variables d'env factices dans CI frontend pour les tests.
-
-### Fixed
-
-- 4 erreurs ESLint corrigées : split SimulationFlowProvider (react-refresh), hook conditionnel MonProfilPreparation, ref render useTimer, setState effect AppLayout.
-- Prettier format sur 7 fichiers.
-- CI frontend verte pour la première fois depuis le 18 avril.
-
----
-
-## [Unreleased] — 2026-04-23 — FTD-27 — CI backend
-
-### Added
-
-- `expria-backend/.github/workflows/ci.yml` — CI GitHub Actions (test + audit, Node 22). CI verte au premier run.
-- FTD-27 fermée dans TECH_DEBT.md (v1.17).
-
----
-
-## [Unreleased] — 2026-04-23 — FTD-29 — Dependabot config
-
-### Added
-
-- `.github/dependabot.yml` créé dans les 2 dépôts (npm, weekly, limit 10 PRs).
-- FTD-29 fermée dans TECH_DEBT.md (v1.16).
-
----
-
-## [Unreleased] — 2026-04-23 — Réorg sécurité TECH_DEBT v1.15
-
-### Changed
-
-- `TECH_DEBT.md` v1.14 → v1.15 — réorganisation sécurité.
-- Gelées (backlog post-MVP) : FTD-06 (AudioWorklet), FTD-08 (Tests E2E), FTD-15 (option 'system' thème).
-- Ajoutées : FTD-27 🔴 (CI backend), FTD-28 🔴 (Semgrep CI), FTD-29 🟡 (Dependabot config).
-- GitHub : Dependabot alerts + security updates activés sur les deux dépôts (UI GitHub).
-
----
-
-## [Unreleased] — 2026-04-23 — Triage FTD v1.14
-
-### Changed
-
-- `TECH_DEBT.md` v1.13 → v1.14 — triage dette technique : 17 → 15 FTD actives (cap respecté).
-- Fermées : FTD-04 (miroir docs, accepté ADR 004), FTD-05 (scaffold caduc, audit clean), FTD-20 (GET /simulations/:id livré Sprint 3.6a), FTD-22 (code orphelin /sujets, résolution complète).
-- Ajoutées : FTD-25 🟢 (ARCHITECTURE.md §3 désaligné), FTD-26 🟡 (cohabitation shared/ui vs shared/components/ui).
-
----
-
## [Unreleased]
### Added
-
- Documentation initiale du projet (ARCHITECTURE, ONBOARDING, SECURITY, etc.)
- 5 ADRs pour les décisions architecturales majeures
- Code source de `src/entities/user/access.ts` et `lib.ts` avec tests
-
-## [Unreleased] — 2026-04-22 — Sprint 3.5 — Clean post-Sprint 3
-
-### Changed
-
-- **FTD-17 résolu** : `PLAN_QUERY_KEY` centralisé dans `src/entities/user/query-keys.ts` (constantes pures, aucun import runtime). `usePlan` le ré-exporte ; `SimulationPage` et `RapportPage` remplacent leur `useQuery` inline par le hook `usePlan()` — déduplication totale de la clé et de la config `staleTime`.
-- **FTD-18 résolu** : `SimulationForm` migré de `@/shared/components/ui/button` (shadcn) vers la primitive canonique `@/shared/ui/Button`. Aucun variant à adapter (usage sans prop `variant`).
-- **FTD-19 résolu** : token `--shadow-focus` ajouté dans `@theme {}` (`0 0 0 3px rgba(27, 79, 216, 0.18)` — conforme `DESIGN_SYSTEM.md §2`) et dans `.dark {}` (recalculé sur la teinte expria dark). Migration de 5 occurrences `ring-2 ring-expria/20` → utility `shadow-focus` dans `Button`, `Card`, `SimulationForm` (×3), `SpecialCharsKeyboard`.
-- Factorisation `SimulationForm` : className dupliquée des deux boutons secondaires (« Suggestions d'idées » / « Changer de sujet ») extraite en const locale `secondaryActionBtn`.
-- `TECH_DEBT.md` → v1.11. 15 FTD actives (cap de 15 respecté).
-
-### Notes
-
-- Timeouts DeepSeek intermittents observés pendant les tests manuels Groupe B + C — cause externe (API tierce), hors périmètre refactor Sprint 3.5.
-- B8 : comportement actuel diffère du spec `PARCOURS_UTILISATEURS.md §2 "Quota atteint"` — affichage d'une bannière inline au lieu du modal de blocage attendu. À corriger dans un sprint dédié (non inclus dans ce clean, qui n'introduit aucune nouvelle fonctionnalité).
-
-## [Unreleased] — 2026-04-22 — Sprint 3.6c — Analyse patterns (Backend + Frontend)
-
-### Added (backend)
-
-- `GET /users/patterns` — analyse des patterns récurrents pour utilisateur Premium.
- - Auth : `authMiddleware` + `planMiddleware('pattern_analysis')` (403 `PLAN_INSUFFICIENT` si Free/Standard).
- - < 5 productions corrigées → `200 { ready: false, minimum: 5, current: N }`.
- - ≥ 5 → `200 { ready: true, patterns, exercises, preparation_index, analyzed_productions, last_analysis }`.
-- `patternsController.aggregatePatterns` (pure) : agrège les `erreurs_codes` sur N productions, seuil 3/5, dédoublonnage intra-prod (un même code dans un rapport ne compte qu'une fois), codes `autre` distingués par description, tri par fréquence DESC.
-- `patternsController.computePreparationIndex` (pure) : 60 % score moyen normalisé + 20 % régularité (médiane des intervalles entre prod) + 20 % tendance (pente linéaire sur les 5 scores). Clamp `[0, 100]`, messages figés selon les seuils `<40` / `40-70` / `>70`.
-- `patternsController.list` — orchestre fetch productions + cache `pattern_analyses` + recompute + DeepSeek + INSERT. Stratégie d'invalidation : `MAX(productions.created_at) > lastAnalysis.created_at` → recompute, sinon cache hit.
-- `generatePatternExercices` dans `src/lib/deepseek.ts` — prompt système validé par Hermann avec format `{ consigne, exemple, correction, astuce }`, température 0.4, `AbortSignal.timeout(20_000)`, validation runtime des critères via `isValidCritere`.
-- Table `pattern_analyses` — migration `005_sprint_3_6c_pattern_analyses.sql` : UUID PK + FK cascade user_id + `productions_ids UUID[]` + patterns/exercises JSONB + preparation_index (CHECK `[0, 100]`) + preparation_message + analyzed_count + RLS SELECT par user_id + index `(user_id, created_at DESC)`.
-- 19 nouveaux tests (`patternsController.test.ts`) : 7 sur `aggregatePatterns`, 4 sur `computePreparationIndex`, 8 sur route (401, 403 free/standard, <5 prod, cache hit, cache miss + insert, no patterns, DeepSeek fail gracieux). **205 tests backend verts** (+19 vs baseline 186).
-
-### Added (frontend)
-
-- Page `/progression` — route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`.
-- `ProgressionPage` — orchestre `usePlan` + `usePatterns`, gate plan via `hasAccess('pattern_analysis')`.
-- `ProgressionPremium` — orchestrateur : si not-ready → `NotReadyState` ; sinon Hero indice + patterns + exercices long terme + footer « Analyse basée sur vos N dernières productions — il y a X ».
-- `PreparationIndexHero` — score /100 + jauge horizontale colorée (rouge <40 / ambre 40-70 / vert >70) + message.
-- `PatternsList` — liste des patterns avec libellé via nouveau `CRITERE_LABELS` + badge fréquence (3/5, 4/5, 5/5).
-- **`PatternExerciceCard`** — _nouveau composant lesson-style_, non interactif (contrairement à `ExerciceInteractive` du rapport individuel) : critère + diagnostic + consigne + bloc incorrect (barré rouge) côte à côte avec bloc correct (vert) + **encart astuce proéminent** (icône ampoule + fond warning).
-- `NotReadyState` — barre de progression N/5 + CTA `Démarrer une simulation`.
-- `BlurredProgression` — aperçu flouté pour Free/Standard + bouton upgrade Premium.
-- Section Dashboard Premium `MonProfilPreparation` — MetricCard indice (score + jauge compacte + message) + nombre d'erreurs récurrentes + CTA « Voir mon profil de préparation » vers `/progression`. Garde explicite `hasAccess('pattern_analysis')` → composant retourne `null` pour Free/Standard (pas rendu dans le DOM).
-- `usePatterns(plan)` — hook TanStack Query partagé entre `/progression` et dashboard ; clé `['users', 'patterns']`, `staleTime: 60s`, `enabled` conditionné par `hasAccess` pour éviter un 403 parasite.
-- `entities/patterns/types.ts` + `entities/patterns/api.ts` — types miroirs du backend (`Pattern`, `PatternExercice`, `PreparationIndex`, `PatternsReady`, `PatternsNotReady`) + `getPatterns()` avec timeout 25 s.
-- `CRITERE_LABELS` exporté depuis `entities/report/lib.ts` — miroir du backend pour affichage du libellé humain à partir du code taxonomie.
-- 13 nouveaux tests : 6 sur `ProgressionPremium` (not-ready, ready avec indice/patterns/exercices, footer, 0 pattern) + 7 sur `MonProfilPreparation` (gating Free/Standard, Premium ready/not-ready, loading, error, 0 pattern). **115 tests frontend verts** (+13 vs baseline 102).
-
-### Notes
-
-- **Formule indice** arbitraire (60/20/20) — à affiner après observation prod si besoin.
-- **Dégradation gracieuse DeepSeek** : si `generatePatternExercices` throw, le backend persiste quand même l'analyse avec `exercises: []` et logue l'erreur. Le frontend affiche alors la liste des patterns sans section exercices (pas de message d'erreur explicite côté UI — l'utilisateur ne sait pas qu'il manque quelque chose).
-- **`ExerciceInteractive` NON réutilisé** pour les exercices long terme : les shapes et UX sont différents (lesson vs tentative). Deux composants distincts cohabitent.
-- **Migration SQL à exécuter manuellement** : `cd expria-backend && supabase db push` avant les tests end-to-end Premium.
-
-## [Unreleased] — 2026-04-22 — Sprint 3.7 — Historique (Backend + Frontend)
-
-### Added (backend)
-
-- `GET /simulations` — liste paginée des productions de l'utilisateur connecté.
- - Query params : `page` (défaut 1, entier ≥ 1), `limit` (défaut 20, entier entre 1 et 50).
- - Tri : `created_at DESC` côté Supabase.
- - Filtre : `user_id = profile.id` (double-protection avec RLS).
- - Projection : `id, tache, mode, score, nclc, nclc_cible, created_at` — champs lourds (`contenu`, `rapport`, `exercices`, `modele`) **exclus**.
- - Réponse : `{ data: ListItem[], pagination: { page, limit, total } }`.
- - Erreurs : `400 VALIDATION_ERROR` si `page`/`limit` invalide, `401 AUTH_REQUIRED` si JWT absent, `500 INTERNAL_ERROR` si DB down.
-- `simulationController.list(options, profile)` + interfaces `ListOptions`, `ListItem`, `ListResult`.
-- 12 nouveaux tests sur la route `GET /simulations` (186 tests backend verts, +12 vs baseline 174).
-
-### Added (frontend)
-
-- Page `/historique` (route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`).
-- `HistoriquePage` — orchestre `usePlan` + `useSimulationsList`, state local de pagination, gating plan Free via `hasAccess('dashboard')`.
-- `SimulationsList` — composant liste avec :
- - Empty state + CTA « Démarrer une simulation » → `/simulation/ee`
- - Loading skeleton (5 barres animées)
- - Error state (callout discret `border-l-danger`)
- - Aperçu flouté Free + bouton `variant="upgrade"` « Passer en Standard »
- - Pagination Précédent / Suivant (masquée si une seule page)
- - Affichage « Page X sur Y — Z simulations »
-- `SimulationListItem` — carte item : date relative, libellé de tâche (`formatTache`), score `/20`, `NCLC atteint / cible`, badges « Examen » et « En cours » (rapport non prêt). Clic → `/rapport/:id`.
-- `useSimulationsList(page, limit)` — hook TanStack Query, clé `['simulations', 'list', page, limit]`, `staleTime: 30s`, `placeholderData: keepPreviousData` pour éviter le flash de squelette au changement de page.
-- `listSimulations(page, limit)` dans `entities/production/api.ts` — wrap `apiFetch` + `URLSearchParams`.
-- Types `SimulationListItem` et `SimulationsListResponse` dans `entities/production/types.ts`.
-- `src/shared/lib/date.ts` — helper `formatRelativeDate(iso, now?)` basé sur `Intl.RelativeTimeFormat('fr', { numeric: 'auto' })`. Seuils : secondes → minutes → heures → jours → semaines → mois → années. Zéro dépendance.
-- 18 nouveaux tests frontend (7 `date.test.ts` + 11 `SimulationsList.test.tsx`).
-
-### Notes
-
-- Les simulations avec `score === null` (en cours ou correction échouée) sont **affichées** avec un badge « En cours ». Clic → `/rapport/:id` — `RapportPage` gère le cas `REPORT_NOT_READY` (FTD-21) en redirigeant vers `/simulation/ee`.
-- `BlurredPreview` dupliqué localement dans `SimulationsList` (pattern équivalent à `BlurredSection` de `RapportPage`). À extraire en `shared/` si le pattern se répète dans un 3ᵉ endroit — pas fait dans ce sprint.
-- Pagination : Précédent/Suivant (MVP) retenu contre scroll infini. Le choix sera revu si l'historique dépasse 100 items en prod.
-- Tests frontend : **102/102 verts** (+18 vs baseline 84).
-
-## [Unreleased] — 2026-04-22 — Sprint 3.6b — Qualité correction — Frontend
-
-### Added
-
-- `NclcCibleSelector` (segmented control NCLC 9 / NCLC 10) dans `SimulationForm` — valeur propagée au payload `POST /corrections/ee` via `SimulationFlowProvider.submitText(texte, nclcCible)`.
-- Composants `rapport/` dans `features/simulations/components/` :
- - `ScoreHero` — score /20, jauge avec marqueur du seuil NCLC cible, écart vs objectif (« X points avant NCLC 9 »), badges NCLC atteint / cible.
- - `RevelationCards` — 3 colonnes : ce que le candidat croit / ce que le correcteur observe / conséquence.
- - `DiagnosticCallout` — callout « Ce qui freine votre progression ».
- - `CritereCard` — carte enrichie par critère (exemple / suggestion / astuce + badges codes taxonomie).
- - `ConseilNclcCallout` — plan d'action NCLC (objectif, écart, action prioritaire).
- - `ExerciceInteractive` — carte exercice avec zone texte, bouton Indice (révélé une fois), bouton « Voir la correction » (activé après saisie), explication.
- - `ProductionModeleSection` — texte final + notes pédagogiques + transformations original/amélioré + message encourageant.
- - `JobStatusFallback` — fallback pour `exercices_status` / `modele_status` en `'pending'` ou `'error'`.
-- Helpers dans `entities/report/lib.ts` : `groupErreursByCritere`, `ecartVsCible`, `critereCodeFromNom`.
-- Tests `ExerciceInteractive.test.tsx` (6 tests) — couvre état interne : révélation unique indice, activation bouton correction, affichage correction + explication.
-- FTD-24 🟡 dans `TECH_DEBT.md` — polling automatique pour exercices/modèle `pending` (refresh manuel en MVP).
-
-### Changed
-
-- `entities/report/types.ts` — refonte complète alignée sur le backend Sprint 3.6a : `Report` remplace l'ancien (revelation, diagnostic, criteres enrichis, conseil_nclc, erreurs_codes top-level, exercices dynamiques, modele structuré, statuts pending/ready/error). Suppression de `feedback_court`, `erreurs[]`, `modele:string`, `idees[]` (obsolètes).
-- `entities/report/lib.ts` — `BlurableSection` réduite à `'criteres' | 'exercices' | 'modele'` : `revelation`, `diagnostic`, `conseil_nclc` deviennent visibles pour tous les plans conformément à PLANS_TARIFAIRES.md §2.
-- `entities/production/types.ts` — `SimulationState` étendu avec `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` ; `SimulationRapport` aligné sur `CorrectionRapport` backend.
-- `entities/report/api.ts` — `getReport` recombine `SimulationState.rapport` + `exercices` + `modele` + statuts en un `Report` unifié pour `useRapport`.
-- `RapportPage.tsx` — réécriture complète : câble tous les nouveaux composants, branche le gating plan via `isSectionVisible`, affiche `JobStatusFallback` pour les jobs asynchrones. Résout l'écran blanc post-Sprint 3.6a.
-- `floutage.test.ts` réécrit (17 tests — matrice de visibilité + helpers lib).
-
-### Fixed
-
-- **Race condition `modele_status`** (backend) : l'update principal de correction écrasait `modele_status='ready'` déjà posé par `runModeleJob` (lancé en parallèle option b). `correctionController.correctEE` ne touche plus aux colonnes `*_status` — pilotées exclusivement par les jobs asynchrones.
-- **Boucle infinie retour rapport → SimulationPage** : le useEffect sticky `step === 'done' → navigate('/rapport/:id')` renvoyait l'utilisateur sur le rapport à chaque tentative de retour vers `/simulation/ee`. Supprimé ; la navigation initiale vers `/rapport/:id` est déclenchée une seule fois dans `correctMutation.onSuccess` du provider.
-- **Boucle retour /sujets → SimulationPage** : même pattern sticky pour `step === 'choosing-subject' → navigate('/sujets')`. Supprimé ; navigation initiale vers `/sujets` déplacée dans `createMutation.onSuccess`.
-- **RapportPage hors SimulationFlowProvider** : la route `/rapport/:id` n'était pas sous `SimulationFlowLayout` — l'appel à `useSimulation()` depuis RapportPage throw. Route déplacée sous le layout, l'instance du provider est partagée avec `/simulation/ee` et `/sujets`.
-
-### Added
-
-- Bouton « Nouvelle simulation » en bas de `RapportPage` qui `reset()` + `navigate('/simulation/ee')`.
-- `reset()` explicite dans le bouton « ← Retour » de `SujetsPage` avant la navigation, pour empêcher tout re-déclenchement de la garde sticky.
-
-### Changed
-
-- Navigations post-mutation déplacées dans `onSuccess` du provider (pattern cohérent pour `createMutation` → `/sujets` et `correctMutation` → `/rapport/:id`). Plus de useEffect réactif aux changements de `step` côté SimulationPage.
-- `SujetsPage` : garde étendue de `!production` à `!production \|\| step === 'idle' \|\| step === 'done'` pour couvrir le cas post-rapport (évite le 400 VALIDATION_ERROR sur `PATCH /simulations/:id/sujet` d'une simulation déjà corrigée).
-- `RapportPage` breadcrumb : ` ` remplacé par `` qui `reset()` avant navigate.
-
-### Notes
-
-- **Option β retenue** : frontend aligné sur la structure backend réelle du Sprint 3.6a. Aucun aller-retour backend.
-- `feedback_court` supprimé de l'UI ; `diagnostic` remplace la section « Retour général ».
-- Polling automatique non implémenté (FTD-24) : refresh manuel de la page si `exercices_status` / `modele_status` = `'pending'`.
-- Tests : **84/84 verts** (+8 vs baseline 76).
-
-## [Unreleased] — 2026-04-22 — Sprint 3.6a — Qualité correction — Backend
-
-### Added (backend)
-
-- `src/lib/taxonomieErreurs.ts` : constantes des 63 codes TCF Canada + 4 codes `autre` par critère, validation runtime `isValidCode` / `isValidCritere`, et injection au prompt via `buildTaxonomyPromptSection`.
-- Prompts dynamiques dans `src/lib/deepseek.ts` : `buildCorrectionPrompt` (prompt maître avec `nclc_cible` 9 ou 10, sujet, documents T3), `buildModelPrompt` (production modèle cible NCLC 9 fixe), `buildExercicesPrompt` (3 exercices ciblés sur `erreurs_codes` + extraits `exemple`, format `{difficulte, theme, diagnostic, consigne, extrait, indice, correction, explication}`).
-- Post-traitement production modèle : `wordCountTCF`, `stripModelAnnotations`, `truncateToMaxWords`.
-- Route `POST /corrections/ee` accepte le paramètre `nclc_cible` (optionnel, défaut 9, valeurs acceptées : 9 ou 10 ; sinon 400 VALIDATION_ERROR).
-- Migration SQL `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` — colonnes : `revelation`, `diagnostic`, `conseil_nclc`, `erreurs_codes`, `exercices`, `modele`, `nclc_cible`, `exercices_status`, `modele_status` + index GIN sur `erreurs_codes` (pour Sprint 3.6c).
-- `controllers/__tests__/correctionController.test.ts` (7 tests) : parallélisme, statuts ready/error, `nclc_cible=10` propagé, simulation introuvable/autre user.
-- `docs/TECH_DEBT.md` TD-15 🟡 : jobs fire-and-forget peuvent rester `pending` si redémarrage process.
-
-### Changed (backend)
-
-- `correctEE` dans `deepseek.ts` — nouvelle signature `correctEE(CorrectionInput)` + nouvelle forme `CorrectionRapport` (revelation, diagnostic, criteres[{exemple,suggestion,astuce}], conseil_nclc, erreurs_codes). `EERapport` devient alias de `CorrectionRapport`.
-- `correctionController.correctEE` : lance 3 appels DeepSeek en parallèle ; await uniquement sur la correction pour répondre 200 ; modèle et exercices s'exécutent en fire-and-forget et mettent à jour `{exercices,exercices_status}` et `{modele,modele_status}` en base (pending → ready/error).
-- `simulationController.getById` retourne les nouveaux champs : `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` en plus du `rapport` enrichi.
-- `deepseek.test.ts` réécrit — 25 tests (ancien pipeline supprimé, nouveaux tests sur correctEE/generateProductionModele/generateExercices/helpers + EO inchangé).
-
-### Notes
-
-- **Option A retenue** : backend renvoie uniquement la nouvelle forme. Frontend (Sprint 3.6b) casse tant que non livré — livraison groupée sans déploiement intermédiaire.
-- Prompt exercices rédigé côté backend (option b), basé sur les codes taxonomie + extraits `exemple` des critères. Format aligné sur captures d'écran demandées.
-- Migration SQL à exécuter manuellement via `supabase db push` — Hermann avant le premier test end-to-end.
-- Tests backend : 173/173 verts (+18 vs baseline de 155).
-
-## [Unreleased] — 2026-04-22 — Planification Sprint 3.6a/3.6b/3.6c
-
-### Added
-
-- Sprints 3.6a (backend prompts + taxonomie), 3.6b (frontend rapport enrichi), 3.6c (analyse patterns Premium) ajoutés à la ROADMAP entre Sprint 3.5 et Sprint 4.
-- `TAXONOMIE_ERREURS.md` — 63 codes d'erreurs TCF Canada sur 4 critères + 4 codes « autre » + procédure d'enrichissement.
-
-## 2026-04-21 — FTD-21 — Persistance session `/simulation/ee`
-
-### Added
-
-- `useAutosave(simulationId, contenu, enabled)` : autosave debounce 30 s + flush sur `beforeunload`, dedup par dernier contenu sauvegardé (6 tests).
-- `SimulationFlowProvider` hydrate la session au montage depuis `localStorage` (`expria_simulation_id`) → `GET /simulations/:id` → restaure `step='task-selected'` + `production` + `sujet` si `rapport=null` ; nettoie la clé sinon (3 tests resume).
-- Types `SimulationState`, `SimulationRapport` + API `getSimulationState`, `autosaveContenu`, `updateSujet` dans `entities/production`.
-- Indicateur "Sauvegardé à HH:MM" sous la textarea `SimulationForm` (text-xs, `aria-live="polite"`).
-
-### Changed
-
-- `getReport` délègue désormais à `getSimulationState` et lève `REPORT_NOT_READY` si `rapport=null`. `RapportPage` catche cette erreur et redirige vers `/simulation/ee` avec message discret "Votre simulation est en cours.".
-- `SimulationForm` accepte `simulationId`, `initialContenu`, `step` et persiste `expria_simulation_id` dans `localStorage` tant que la simulation est active ; nettoie la clé quand `step='done'`.
-- `changeSubject` persiste le changement côté backend via `PATCH /simulations/:id/sujet` (best-effort, silencieux si échec).
-
-### Security
-
-- localStorage ne stocke que `simulation_id` (UUID non-sensible) — conforme SECURITY.md §2.6.
-
-### Notes
-
-- FTD-21 reste ouvert pour `/simulation/eo` (Sprint 4) et `/examen` (Sprint 7).
-
----
-
-## 2026-04-21 — Tâche G5 — Suggestions d'idées DeepSeek
-
-### Ajouté
-
-- **Backend** — `POST /sujets/idees` : génère 5 suggestions
- d'idées via DeepSeek pour aider l'étudiant à prolonger sa
- rédaction (prompt coach TCF Canada, temperature 0.5,
- timeout 15 s via AbortSignal, JSON strict
- `{ idees: string[] }`)
-- `generateIdees(consigne, contenu)` dans `src/lib/deepseek.ts`
- (validation tableau non vide)
-- 5 tests route `POST /sujets/idees` : 401 sans auth,
- 400 sujet_consigne manquant, 400 contenu < 30 mots,
- 200 succès avec idees[], 500 DeepSeek throw
-- **Frontend** — `getIdees(consigne, contenu)` dans
- `entities/report/api.ts` (POST `/sujets/idees`,
- timeoutMs 15 000)
-- Hook `useIdees` — `useMutation` exposant
- `{ idees, isLoading, error, fetchIdees, reset }`
-- Composant `IdeesSuggestions` — modal shadcn Dialog avec
- liste à puces, états loading/erreur/succès,
- `reset()` automatique à la fermeture
-- Bouton "Suggestions d'idées" (icône Lightbulb) dans
- `SimulationForm` à côté de "Changer de sujet"
-- Prop `plan: Plan` ajouté à `SimulationForm` (wiring
- `planData.plan` depuis `SimulationPage`)
-
-### Règles d'accès
-
-- Règle D respectée : `hasAccess(plan, 'tips')` obligatoire
-- Plan Free : bouton visible mais désactivé avec tooltip
- "Disponible en Standard" (tips=false pour Free)
-- Standard + Premium : bouton actif dès 30 mots écrits
-- Désactivé également si `!sujet`, `isSubmitting`, ou
- `idees.isLoading`
-
-### Tests
-
-- Backend — Typecheck : 0 erreur, Vitest : 144/144 passés
- (+5 tests POST /sujets/idees)
-- Frontend — Typecheck : 0 erreur, Vitest : 67/67 passés
-- Test manuel : validé avec compte Standard (bouton actif
- à 30+ mots, modal affiche 5 idées) et Free (bouton
- verrouillé avec tooltip)
-
-## 2026-04-21 — Tâche G4 + Refonte page /sujets + Fix quota simulations
-
-### Ajouté
-
-- **Tâche G4** — choix du sujet avec dropdown intégré et bouton
- aléatoire dans SimulationForm (hook `useSujets`, composant
- `SujetSelector`, `getSujets()` sur `GET /sujets?mode=&tache=`)
-- **Refonte UX `/sujets`** (Option A) — page dédiée avec grille
- de cartes `SujetCard` (responsive 1/2/3 colonnes), état partagé
- via `SimulationFlowProvider` pour survivre aux navigations entre
- `/simulation/ee` et `/sujets`. MVP : refresh sur `/sujets`
- redirige vers `/simulation/ee`.
-- Bouton "Changer de sujet" dans `SimulationForm` — retour à
- `/sujets` via `goToSubjectPicker`
-- Prop `type: 'EE' | 'EO'` sur `TaskSelector` (EO_CARDS réservé
- usage futur — non routé, `/simulation/eo` reste `ComingSoon`
- jusqu'au Sprint EO)
-
-### Modifié
-
-- `useSimulation` refacto en consommateur de
- `SimulationFlowProvider` (source de vérité déplacée hors du hook)
-- `SujetDisplay` redevient présentationnel (dropdown retiré)
-- `TaskSelector` : retrait des cartes EO de la page
- Expression Écrite (affiche uniquement EE T1/T2/T3)
-
-### Corrigé
-
-- **Quota simulations (backend — commit `ecb478e`, expria-backend)** :
- incrément `simulations_used` déplacé de
- `simulationController.create()` vers `correctionController.correctEE/EO`
- (Option B). Une simulation créée mais jamais corrigée ne consomme
- plus le quota utilisateur.
-
-### Supprimé
-
-- `SujetSelector.tsx` — orphelin après refonte `/sujets`
-- Helper `selectSujet` de `useSimulation` — orphelin
-- FTD-22 tracée résolue partiellement (step `'choosing-subject'`
- - `goToSubjectPicker` conservés intentionnellement)
-
-### Tests
-
-- Typecheck : 0 erreur
-- Vitest : 67/67 passés
-- Test manuel : flux complet EE T1 avec choix de sujet
- (carte + aléatoire + changement de sujet) validé
-
-## 2026-04-21 — Tâches G2+G3 — Clavier + Minuteur
-
-### Ajouté
-
-- Composant SpecialCharsKeyboard — 30 caractères spéciaux
- français en flex-wrap, sticky au scroll
-- Bloc "Temps restant" sticky avec TimerDisplay MM:SS
- (critique < 2min : rouge + pulse, expiré : rouge bold)
-- Composant WordCountBar — barre de progression colorée
- (orange < cible, vert dans cible, rouge > cible)
-- Hook useTimer avec 7 tests unitaires
-- Config par tâche dans simulationConfig.ts
- (EE T1: 10min/60-120 mots, T2: 20min/120-150,
- T3: 30min/120-180)
-- Auto-submit à l'expiration si ≥ 30 mots
-- Bouton "Soumettre ma production" (était "Envoyer")
-- Textarea auto-resize sans scroll interne
-
-### Changed
-
-- Compteur de caractères remplacé par WordCountBar
-- Bouton soumission bloqué si < 30 mots
-
-### Tests
-
-- Typecheck : 0 erreur
-- Vitest : 66/66 passés (+7 tests useTimer)
-- Test manuel : minuteur + clavier validés sur mobile
- et desktop
-
-## 2026-04-21 — Tâche G1 — Affichage de la consigne
-
-### Ajouté
-
-- Interface SujetData dans entities/production/types.ts
-- Production enrichie avec sujet: SujetData | null
-- Composant SujetDisplay — affiche consigne, rôle, contexte, doc1, doc2 selon le sujet retourné
-- useSimulation expose sujet dans son retour
-- SimulationForm intègre SujetDisplay au-dessus de la textarea
-- FTD-21 tracée (persistance session simulation)
-
-### Tests
-
-- Typecheck : 0 erreur
-- Vitest : 59/59 passés
-- Test manuel : consigne affichée sur /simulation/ee
-
-## 2026-04-20 — Audit frontend ↔ backend — alignement types Report
-
-### Modifié
-
-- `src/entities/report/types.ts` — `Critere.note` → `Critere.score`, `Report.exercices: Exercice[]` → `Report.exercices: string[]`, JSDoc ajusté
-- `src/features/simulations/pages/RapportPage.tsx` — import `Exercice` retiré, `critere.note` → `critere.score`, `ExerciceCard` refactoré pour consommer une `string` rendue en Markdown, clé d'itération par index
-
-### Supprimé
-
-- Interface `Exercice { titre, contenu }` de `entities/report/types.ts` — remplacée par `string[]` pour coller au contrat backend
-
-### Contexte (backend associé, expria-backend)
-
-Quatre commits côté backend finalisent l'alignement du contrat `Report` :
-
-- `feat(corrections)`: renommages `production_modele`→`modele`, `suggestions_idees`→`idees`, ajout `feedback_court` + prompts DeepSeek mis à jour + validations runtime
-- `feat(corrections)`: réponse enrichie avec `simulation_id` côté `correctionController`
-- `feat(simulations)`: nouvelle route `GET /simulations/:id` (auth owner, gestion `SIMULATION_NOT_FOUND`/`AUTH_REQUIRED`/`REPORT_NOT_READY`) + 4 tests
-- `feat(simulations)`: sujet aléatoire (table `sujets`) retourné avec chaque production créée (EO_T2_LIVE exclu, non bloquant si aucun sujet actif)
-
-### Tests
-
-- Typecheck : 0 erreur
-- Vitest : 59/59 passés
-
-### À faire (hors scope — session frontend dédiée ultérieurement)
-
-- Ajouter `sujet: SujetData | null` dans `entities/production/types.ts`
-- Consommer le sujet retourné dans `SimulationPage` (affichage consigne + docs)
-- Consommer `feedback_court` dans `RapportPage` (rendu toujours visible — cf. PLANS_TARIFAIRES §2 — déjà supporté par le type `Report`, reste à brancher dans l'UI si ce n'est pas déjà le cas)
-
-## 2026-04-20 — Sprint 0.5 bis — AppLayout + primitives UI + refonte visuelle
-
-### Ajouté
-
-- `src/app/AppLayout.tsx` — layout applicatif desktop/mobile (sidebar fixe 240px, drawer mobile, BottomNav)
-- `src/app/Sidebar.tsx` — navigation latérale avec verrouillage `hasAccess()` (Progression, Examen blanc, Historique)
-- `src/app/MobileHeader.tsx` — header mobile sticky (Logo, ThemeToggle, bouton menu hamburger)
-- `src/app/BottomNav.tsx` — navigation mobile fixe (4 items, bottom sheet "Simuler", tap target min 44px)
-- `src/shared/ui/Button.tsx` — primitive Button (variants: primary/secondary/ghost/upgrade ; sizes: sm/md/lg ; loading Loader2)
-- `src/shared/ui/Card.tsx` — primitive Card (variants: default/raised/interactive ; rendu `` si `onClick` fourni)
-- `src/shared/ui/Badge.tsx` — primitive Badge (variants: plan/nclc/neutral ; couleur selon `planValue` pour variant plan)
-
-### Modifié
-
-- `src/app/router.tsx` — layout routes via `PrivateLayout` (`ProtectedRoute` + `AppLayout` + `Outlet`) ; `ComingSoon` inline ; redirect `/simulation` → `/simulation/ee`
-- `src/features/simulations/components/TaskSelector.tsx` — refonte avec `Card interactive` / `Card default opacity-60`, `Badge` "EE"/"EO", eyebrow `tracking-widest`, icône verrou
-- `src/features/simulations/pages/SimulationPage.tsx` — suppression header interne (Logo + ThemeToggle) ; root `` ; `Button` migré vers `@/shared/ui/Button` `variant="secondary"`
-- `src/features/dashboard/pages/DashboardPage.tsx` — suppression header interne ; `Button` `variant="primary"` avec `navigate('/simulation/ee')` ; `Badge` `variant="plan" planValue={data.plan}` ; tout migré vers `@/shared/ui/`
-
-### Documentation
-
-- `docs/TECH_DEBT.md` v1.6 — ajout FTD-18 (SimulationForm migration Button), FTD-19 (token `--shadow-focus` manquant)
-
-### Tests
-
-- Typecheck : 0 erreur
-- Vitest : 59/59 passés
-- Tests manuels : à valider par Hermann
-
----
-
-## 2026-04-19 — Sprint 1 / Étape 6 — Maintenance mode + outillage sécurité
-
-### Ajouté
-
-- Page de maintenance statique (`src/app/MaintenancePage.tsx`) — logo + message, tokens Direction H, zéro dépendance
-- Guard `VITE_MAINTENANCE_MODE` dans `main.tsx` — si `true`, aucun provider ne se monte, aucun appel réseau
-- Variable `VITE_MAINTENANCE_MODE` dans `env.ts` (optionnelle, défaut `false`)
-- Hook PreToolUse Claude Code (`security-check.sh`) — 9 patterns SECURITY.md §2
-- Hook Stop Claude Code (`check-file-size.sh`) — alerte fichiers > 200 lignes
-- MCP server Semgrep enregistré dans Claude Code
-
-### Documentation
-
-- `ARCHITECTURE.md` §7 — ajout `VITE_MAINTENANCE_MODE` dans la liste des variables
-- `TECH_DEBT.md` — FTD-16 résolu (maintenance mode implémenté)
-
-### Tests
-
-- Typecheck : 0 erreur
-- Vitest : 37/37 passés
-- Test manuel : maintenance mode vérifié (page affichée, aucun appel réseau, routing bloqué)
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
deleted file mode 100644
index 0a776ab..0000000
--- a/docs/DEPLOYMENT.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# DEPLOYMENT.md — Expria V2
-> Version 1.0 — Rédigé avant lancement
-> Procédure officielle de bascule V1 → V2 sur expria.app
-> À lire intégralement avant toute action de déploiement.
-
----
-
-## 1. Architecture cible
-
-| Composant | V1 (actuel) | V2 (cible) |
-|---|---|---|
-| Frontend | Next.js sur Render | React/Vite sur Cloudflare Pages |
-| Backend | Next.js API routes sur Render | Hono.js sur Render (déjà live) |
-| DNS | Vercel | Vercel (inchangé) |
-| Domaine | expria.app | expria.app (inchangé) |
-| Auth | Supabase | Supabase (inchangé) |
-| Paiement | Stripe | Stripe (inchangé) |
-
----
-
-## 2. Prérequis — ne pas lancer la bascule sans cocher tout
-
-### Code
-- [ ] Tous les tests Vitest backend passent (0 échec)
-- [ ] Tous les tests Vitest frontend passent (0 échec)
-- [ ] npm run typecheck frontend → 0 erreur
-- [ ] Smoke test complet (Groupe Z du GOLDEN_DATASET.md) validé en local
-
-### Infrastructure
-- [ ] Backend V2 stable sur api.expria.app depuis au moins 48h sans erreur critique Sentry
-- [ ] Sentry configuré et actif sur le frontend V2
-- [ ] Variables d'environnement Cloudflare Pages configurées :
- - VITE_API_URL=https://api.expria.app
- - VITE_SUPABASE_URL=...
- - VITE_SUPABASE_ANON_KEY=...
- - VITE_ENABLE_T2_LIVE=false
-- [ ] CNAME Cloudflare Pages créé et testé sur une URL de preview
-
-### Stripe
-- [ ] Webhooks Stripe pointent vers api.expria.app (backend V2)
-- [ ] Test de paiement réel effectué sur l'URL de preview Cloudflare Pages
-
-### Rollback DNS — valeurs de référence (ne pas supprimer)
-- @ → A → 216.24.57.1 (frontend V1 Render)
-- www → CNAME → expria.onrender.com (frontend V1 Render)
-- api → CNAME → expria-backend.onrender.com (backend V2 — inchangé)
-
----
-
-## 3. Procédure de bascule — dans l'ordre exact
-
-### Étape 1 — Mettre V1 en mode maintenance (2 min)
-Dans Render, sur le service frontend V1 :
-- Modifier la variable d'environnement `MAINTENANCE_MODE=true`
-- Redéployer le service V1
-- Vérifier que expria.app affiche la page de maintenance
-
-> ⚠️ À partir de ce moment, expria.app est inaccessible pour les utilisateurs.
-> Faire cette étape à une heure creuse (nuit, week-end).
-
-### Étape 2 — Configurer le CNAME dans Vercel (5 min)
-Dans le dashboard Vercel → Domains → expria.app :
-- Supprimer ou modifier l'enregistrement A/CNAME actuel qui pointe vers Render
-- Ajouter un CNAME : `expria.app` → `.pages.dev` (URL Cloudflare Pages)
-- Sauvegarder
-
-### Étape 3 — Configurer le domaine dans Cloudflare Pages (5 min)
-Dans Cloudflare Pages → projet expria-frontend → Custom domains :
-- Ajouter `expria.app`
-- Cloudflare Pages vérifie le CNAME automatiquement
-- Attendre la validation (peut prendre 1-5 min)
-
-### Étape 4 — Vérifier la propagation DNS (5-15 min)
-Vérifier sur https://dnschecker.org que `expria.app` pointe vers Cloudflare Pages.
-Ne pas continuer avant que la propagation soit visible depuis au moins 3 régions.
-
-### Étape 5 — Smoke test en production (15 min)
-Rejouer le Groupe Z du GOLDEN_DATASET.md sur expria.app :
-- [ ] Z1 — Inscription + première simulation Free
-- [ ] Z2 — Blocage quota Free
-- [ ] Z3 — Simulation Standard complète
-- [ ] Z4 — Mode examen bloqué en Standard
-- [ ] Z5 — T2 live Premium
-- [ ] Z6 — Mode examen EE complet
-- [ ] Z7 — Paiement Free → Standard
-- [ ] Z8 — Prorata Standard → Premium
-- [ ] Z9 — Déconnexion + accès protégé
-- [ ] Z10 — Responsive mobile Home + Login
-
-### Étape 6 — Vérifier Sentry (5 min)
-- Ouvrir le dashboard Sentry projet expria-frontend
-- Vérifier qu'aucune erreur critique n'apparaît dans les 5 premières minutes
-- Si erreur critique → déclencher le rollback immédiatement
-
-### Étape 7 — Déclarer la bascule réussie
-- Noter la date et l'heure dans ce fichier (section 6)
-- Désactiver MAINTENANCE_MODE sur V1 (optionnel — V1 reste sur Render comme fallback 30 jours)
-
----
-
-## 4. Rollback — si quelque chose casse
-
-**Objectif : revenir sur V1 en moins de 10 minutes.**
-
-### Étape 1 — Désactiver V2 (2 min)
-Dans Cloudflare Pages → projet expria-frontend :
-- Désactiver le domaine personnalisé expria.app
-
-### Étape 2 — Remettre V1 en ligne (3 min)
-Dans Vercel → Domains → expria.app :
-- Remettre le CNAME/A record vers Render (valeur originale)
-Dans Render → service frontend V1 :
-- Modifier `MAINTENANCE_MODE=false`
-- Redéployer
-
-### Étape 3 — Vérifier (5 min)
-- Vérifier que expria.app affiche à nouveau V1
-- Vérifier que la connexion et une simulation fonctionnent
-
-### Étape 4 — Diagnostiquer
-- Ouvrir Sentry V2 — identifier l'erreur critique
-- Ne pas retenter la bascule avant d'avoir corrigé et rejoué le Groupe Z complet
-
----
-
-## 5. Post-bascule — checks 24h après
-
-- [ ] Sentry : aucune erreur critique nouvelle
-- [ ] Stripe : webhooks reçus et traités correctement
-- [ ] Supabase : aucune erreur d'auth dans les logs
-- [ ] Au moins 1 simulation complète effectuée par un vrai utilisateur
-- [ ] V1 sur Render toujours en ligne comme fallback (désactiver après 30 jours)
-
----
-
-## 6. Historique des déploiements
-
-| Date | Version | Résultat | Notes |
-|---|---|---|---|
-| — | — | — | — |
-
----
-
-## 7. Historique du document
-
-| Version | Date | Changements |
-|---|---|---|
-| 1.0 | 2026-04-19 | Création initiale |
\ No newline at end of file
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
deleted file mode 100644
index 796b5d4..0000000
--- a/docs/DESIGN_SYSTEM.md
+++ /dev/null
@@ -1,507 +0,0 @@
-# DESIGN_SYSTEM.md — Expria Frontend
-
-> **Document de référence — Version 2.0 — 24 avril 2026**
-> Source de vérité unique pour l'identité visuelle, les tokens de design et les primitives UI.
-> Toute décision de DA doit être consignée ici avant d'être implémentée.
-> **Remplace intégralement la v1.0 (Direction Boréal) du 17 avril 2026.**
-
----
-
-## 1. Direction artistique — verrouillée
-
-**Nom :** Charcoal
-**Positionnement :** outil pro sérieux, premium sans scolaire, immersif sans austère.
-**Référence mentale :** Linear, Notion Desktop, Primo TCF — sidebar sombre permanente, contenu aéré.
-
-### Parti pris fondateurs
-
-| Principe | Décision |
-|---|---|
-| Mode par défaut | **Dark** (charcoal chaud `#111111`) |
-| Mode clair | Activé — fond gris froid `#F3F4F6`, cartes blanches |
-| Détection thème | `prefers-color-scheme` au chargement, toggle manuel, persistance `localStorage` |
-| Sidebar | **Navy `#0C1528` permanent** — identique dark et light. C'est l'ancre visuelle de la marque. |
-| Fond principal (dark) | `#111111` avec deux halos bleus subtils (`radial-gradient` à 4–5% opacité) |
-| Fond principal (light) | `#F3F4F6` avec deux halos bleus très discrets (2–3% opacité) |
-| Bleu de marque | `#1B4FD8` **sacro-saint** — invariant entre les modes |
-| Bleu texte accent | `#7da4f0` en dark, `#1B4FD8` en light (lisibilité adaptée au fond) |
-| Surfaces (dark) | Semi-transparentes `rgba(255,255,255,0.035)` — jamais de gris opaque |
-| Surfaces (light) | Blanc pur `#FFFFFF` avec ombre subtile `shadow-card` |
-| Angles | Rayons généreux mais retenus : 8 / 12 / 16 px |
-| Ombres (dark) | Aucune — la bordure 1px et la transparence suffisent |
-| Ombres (light) | Minimales. `shadow-card` subtile sur les surfaces élevées |
-| Animations | 150–200 ms, `ease-out`, respect de `prefers-reduced-motion` |
-| Icônes | `lucide-react` pour les icônes standard. SVG inline dans `shared/ui/icons/` pour les icônes custom |
-| Typographie | Plus Jakarta Sans exclusivement (via Google Fonts, fallback système) |
-| Approche responsive | **Desktop-first** pour l'app (usage quotidien sur ordinateur). Mobile-first uniquement pour le funnel d'acquisition (landing, pricing, inscription) |
-
-### Ce qu'on refuse explicitement
-
-- Gradients criards — le seul acceptable est le dégradé `accent → accent-dark` sur le CTA primaire.
-- Glassmorphism ou `backdrop-filter` généralisé — réservé à la topbar et à la bottom nav mobile.
-- Emojis dans les éléments interactifs ou les labels fonctionnels.
-- Ombres lourdes, "drop shadows" style Material Design 2.
-- Plus de 3 niveaux d'élévation visuelle (fond → surface → surface-raised → modal).
-- Toute police autre que Plus Jakarta Sans.
-- Les motifs SaaS génériques : illustrations 3D, dégradés violet-rose, glass blobs.
-- Fond blanc pur (`#FFFFFF`) en tant que fond de page — toujours `--color-canvas`.
-- Couleurs hexadécimales en dur dans les composants — toujours via token.
-
----
-
-## 2. Tokens — `src/index.css`
-
-Remplacer intégralement le contenu actuel. Tailwind 4 lit automatiquement les tokens déclarés dans `@theme`. Les deux thèmes sont actifs dès maintenant.
-
-```css
-@import 'tailwindcss';
-@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
-
-@theme {
- /* ══════════════════════════════════════════════════════════════════════
- INVARIANTS — identiques dark et light
- ══════════════════════════════════════════════════════════════════════ */
-
- /* ── Sidebar (navy permanent) ── */
- --color-sidebar-bg: #0C1528;
- --color-sidebar-border: rgba(255, 255, 255, 0.07);
- --color-sidebar-text: rgba(255, 255, 255, 0.6);
- --color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
- --color-sidebar-text-active: #ffffff;
- --color-sidebar-nav-hover: rgba(255, 255, 255, 0.07);
- --color-sidebar-nav-active: rgba(255, 255, 255, 0.1);
- --color-sidebar-section-label: rgba(255, 255, 255, 0.3);
-
- /* ── Brand ── */
- --color-brand: #1B4FD8;
- --color-brand-hover: #1744B8;
- --color-brand-active: #13379C;
- --color-brand-dark: #1740b0;
- --color-brand-ink: #FFFFFF;
-
- /* ── Semantic ── */
- --color-warning: #f59e0b;
- --color-warning-soft: rgba(245, 158, 11, 0.12);
- --color-danger: #ef4444;
- --color-danger-soft: rgba(239, 68, 68, 0.12);
-
- /* ── Typography ── */
- --font-sans: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
- --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
-
- --text-xs: 11px;
- --text-sm: 13px;
- --text-base: 14px;
- --text-md: 15px;
- --text-lg: 17px;
- --text-xl: 20px;
- --text-2xl: 24px;
- --text-3xl: 32px;
- --text-display: 40px;
-
- /* ── Rayons ── */
- --radius-xs: 6px;
- --radius-sm: 8px;
- --radius-md: 12px;
- --radius-lg: 16px;
- --radius-xl: 20px;
- --radius-pill: 999px;
-
- /* ── Focus ── */
- --shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18);
-
- /* ══════════════════════════════════════════════════════════════════════
- DARK MODE (default) — tokens de contenu
- ══════════════════════════════════════════════════════════════════════ */
-
- --color-canvas: #111111;
- --color-surface: rgba(255, 255, 255, 0.035);
- --color-surface-hover: rgba(255, 255, 255, 0.055);
- --color-surface-solid: #1e1e1e;
- --color-surface-raised: #222222;
- --color-border: rgba(255, 255, 255, 0.06);
- --color-border-strong: rgba(255, 255, 255, 0.12);
-
- --color-ink-primary: #e5e5e5;
- --color-ink-secondary: rgba(255, 255, 255, 0.55);
- --color-ink-tertiary: rgba(255, 255, 255, 0.3);
- --color-ink-inverse: #111111;
-
- --color-brand-soft: rgba(27, 79, 216, 0.1);
- --color-brand-text: #7da4f0;
-
- --color-success: #4ade80;
- --color-success-soft: rgba(74, 222, 128, 0.12);
-
- --color-topbar-bg: rgba(17, 17, 17, 0.88);
- --color-gradient-a: rgba(27, 79, 216, 0.05);
- --color-gradient-b: rgba(27, 79, 216, 0.03);
-
- --shadow-card: none;
- --shadow-raised: none;
-}
-
-/* ══════════════════════════════════════════════════════════════════════
- LIGHT MODE — override .light sur
- ══════════════════════════════════════════════════════════════════════ */
-
-.light {
- --color-canvas: #F3F4F6;
- --color-surface: #ffffff;
- --color-surface-hover: #f8f9fb;
- --color-surface-solid: #ffffff;
- --color-surface-raised: #ffffff;
- --color-border: rgba(0, 0, 0, 0.07);
- --color-border-strong: rgba(0, 0, 0, 0.14);
-
- --color-ink-primary: #0f0f1a;
- --color-ink-secondary: rgba(0, 0, 0, 0.55);
- --color-ink-tertiary: rgba(0, 0, 0, 0.3);
- --color-ink-inverse: #ffffff;
-
- --color-brand-soft: rgba(27, 79, 216, 0.06);
- --color-brand-text: #1B4FD8;
-
- --color-success: #16a34a;
- --color-success-soft: rgba(22, 163, 74, 0.1);
-
- --color-topbar-bg: rgba(243, 244, 246, 0.88);
- --color-gradient-a: rgba(27, 79, 216, 0.025);
- --color-gradient-b: rgba(27, 79, 216, 0.01);
-
- --shadow-card: 0 1px 2px rgba(15, 18, 32, 0.04), 0 1px 8px rgba(15, 18, 32, 0.03);
- --shadow-raised: 0 4px 16px rgba(15, 18, 32, 0.06), 0 1px 2px rgba(15, 18, 32, 0.04);
-}
-
-/* ── Globals ── */
-
-html, body {
- background: var(--color-canvas);
- color: var(--color-ink-primary);
- font-family: var(--font-sans);
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- font-variant-numeric: tabular-nums;
-}
-
-*:focus-visible {
- outline: none;
- box-shadow: var(--shadow-focus);
- border-radius: var(--radius-xs);
-}
-
-@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after {
- animation-duration: 0ms !important;
- transition-duration: 0ms !important;
- }
-}
-```
-
-### Règles d'usage des tokens
-
-1. **Aucune valeur hexadécimale en dur** dans les composants. Toute couleur passe par un token `var(--color-*)`.
-2. **Nommage sémantique obligatoire.** On écrit `bg-[var(--color-surface)]`, pas `bg-white` ni `bg-gray-50`.
-3. **Ne jamais utiliser** `bg-white`, `bg-gray-*`, `text-gray-*` — ces classes Tailwind cassent le dual-theme.
-4. Si un cas d'usage exige une teinte hors charte, **le documenter ici avant de l'ajouter**. Pas de token orphelin.
-5. La sidebar utilise ses propres tokens `--color-sidebar-*` — ils ne changent **jamais** entre les modes.
-6. Le fond principal utilise toujours les deux `radial-gradient` subtils — jamais un aplat uni.
-
----
-
-## 3. Gestion du thème — `src/shared/lib/theme.ts`
-
-```typescript
-export type Theme = 'dark' | 'light';
-
-export function getInitialTheme(): Theme {
- const stored = localStorage.getItem('expria-theme') as Theme | null;
- if (stored === 'dark' || stored === 'light') return stored;
- if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
- return 'dark';
-}
-
-export function applyTheme(theme: Theme): void {
- document.documentElement.classList.toggle('light', theme === 'light');
-}
-
-export function persistTheme(theme: Theme): void {
- localStorage.setItem('expria-theme', theme);
-}
-```
-
-**Script anti-FOUC** — à insérer inline dans `` de `index.html` :
-
-```html
-
-```
-
----
-
-## 4. Typographie
-
-| Usage | Taille | Poids | Tracking | Ligne | Token |
-|---|---|---|---|---|---|
-| Display (NCLC hero) | 40px | 800 | -0.02em | 1.0 | `text-display` |
-| H1 page | 32px | 700 | -0.02em | 1.1 | `text-3xl` |
-| H2 section | 24px | 700 | -0.015em | 1.2 | `text-2xl` |
-| H3 card title | 20px | 700 | -0.01em | 1.3 | `text-xl` |
-| Lead / intro | 17px | 500 | -0.005em | 1.5 | `text-lg` |
-| Body | 14px | 400 | 0 | 1.6 | `text-base` |
-| Body renforcé | 15px | 500 | 0 | 1.55 | `text-md` |
-| Small / meta | 13px | 500 | 0 | 1.5 | `text-sm` |
-| Eyebrow / label | 11px | 600 | 0.1em (uppercase) | 1.4 | `text-xs` |
-
-**Règles :**
-- Tout nombre métier (score 16/20, NCLC 7,5, compteur 3/5) est rendu en `font-variant-numeric: tabular-nums`.
-- `Plus Jakarta Sans` chargée via Google Fonts CDN avec fallback système.
-- Les chiffres français utilisent la **virgule** comme séparateur décimal (`7,5`, jamais `7.5`).
-
----
-
-## 5. Primitives UI
-
-À créer dans `src/shared/ui/` en FSD, une primitive par dossier (`button/`, `card/`, etc.) avec `index.ts` pour l'export.
-
-### Inventaire
-
-| Composant | Variants | Usage |
-|---|---|---|
-| `Button` | `primary` / `secondary` / `ghost` / `upgrade` | CTA, actions tertiaires |
-| `Card` | `default` / `raised` / `interactive` | Cadre métriques, item simulation |
-| `MetricCard` | `default` / `hero` | Bloc NCLC, compteur simulations |
-| `ProgressBar` | `default` | Progression vers NCLC 9 |
-| `Badge` | `plan` / `nclc` / `brand` / `success` / `warning` / `danger` | Plan, niveau, chips sémantiques |
-| `Sidebar` | — | Nav desktop (≥ 1024px), navy permanent |
-| `BottomNav` | — | Nav mobile (< 1024px), 4–5 items max |
-| `ThemeToggle` | — | Bouton soleil/lune dans le footer sidebar |
-| `PageHeader` | — | Greeting + plan badge |
-| `SectionHeader` | — | Titre de section + action optionnelle |
-
-### Patterns de référence — copier, ne pas réinterpréter
-
-**Sidebar NavItem actif :**
-```tsx
-
- {isActive && (
-
- )}
-
- {label}
-
-```
-
-**Card :**
-```tsx
-
- {children}
-
-```
-
-**Bouton CTA primaire :**
-```tsx
-
- Nouvelle simulation
-
-```
-
-**Bouton secondaire :**
-```tsx
-
- Voir mon profil →
-
-```
-
-**Badge sémantique :**
-```tsx
-
- {children}
-
-```
-
-### Règles d'implémentation
-
-- Chaque primitive **accepte `className`** en plus de ses props typées, pour overrides ponctuels.
-- Chaque primitive **expose ses props via un type exporté** (`ButtonProps`, `CardProps`, etc.).
-- Aucune primitive ne contient de logique métier ou d'appel API. Elles reçoivent tout par props.
-- Les icônes sont importées de `lucide-react` et passées comme composant, jamais par nom de string.
-
----
-
-## 6. Layout principal — `AppLayout`
-
-```tsx
-
-
{/* fixed, w-[230px], bg sidebar navy */}
-
-
- {children}
-
-
-
-```
-
-### Breakpoints
-
-| Breakpoint | Comportement |
-|---|---|
-| `< 1024px` | Sidebar masquée, `BottomNav` fixe en bas, padding horizontal 20px |
-| `≥ 1024px` | Sidebar 230px + contenu centré 1100px max, padding 36px |
-| `≥ 1440px` | Contenu centré 1100px max (pas d'élargissement) |
-
-### Densité verticale
-
-- Padding vertical section : 24px mobile, 32px desktop.
-- Gap inter-cards : 12px mobile, 16px desktop.
-- Marge sous `PageHeader` : 20px mobile, 28px desktop.
-
----
-
-## 7. Données mock
-
-Avant branchement API, fournir les données via `src/shared/api/mock/dashboard.ts`. Données crédibles, françaises, alignées sur l'audience réelle.
-
-```typescript
-export const mockDashboard = {
- user: {
- firstName: 'Yacine',
- plan: 'decouverte' as const,
- planLabel: 'Plan Découverte',
- },
- metrics: {
- nclcEstimated: 7.5,
- nclcTarget: 9,
- simulationsUsed: 2,
- simulationsQuota: 5,
- lastScore: { value: 16, max: 20, type: 'ecrit' as const },
- },
- recentSimulations: [
- { id: 's-001', type: 'ecrit', relativeDate: 'il y a 2 jours', score: 16, max: 20, nclc: 8 },
- { id: 's-002', type: 'oral', relativeDate: 'il y a 5 jours', score: 14, max: 20, nclc: 7 },
- { id: 's-003', type: 'ecrit', relativeDate: 'il y a 1 semaine', score: 15, max: 20, nclc: 7 },
- ],
- nextStep: {
- title: 'Cible une simulation orale cette semaine',
- body: 'Ton écrit est solide (NCLC 8). L\'oral reste à consolider pour sécuriser ton NCLC 9.',
- action: { label: 'Démarrer Expression Orale', to: '/simulation/orale' },
- },
-} as const;
-```
-
-**Règles contenu :**
-- Aucun "Lorem ipsum", aucune date absolue — relatif uniquement.
-- Les prénoms mocks reflètent l'audience : Yacine, Aminata, Kenza, Bilal, Fatou, Kévin.
-- Les scores suivent une progression crédible (pas de 20/20 ni de 5/20).
-
----
-
-## 8. Accessibilité — plancher
-
-- Contraste minimum **WCAG AA** sur tous les couples texte/fond (vérifié dark ET light).
-- Tous les éléments interactifs ont un `:focus-visible` avec `--shadow-focus` (halo bleu 3px).
-- Les icônes décoratives portent `aria-hidden="true"`.
-- Les icônes fonctionnelles (sans label visible) portent `aria-label`.
-- Les landmarks sémantiques : ``, ``, ``, ``.
-- Le `BottomNav` mobile respecte la hauteur minimale tap target : **44×44 px** par item.
-- Le `ThemeToggle` a un `aria-label` dynamique : "Passer en mode clair" / "Passer en mode sombre".
-
----
-
-## 9. Dépendances externes
-
-| Dépendance | Statut | Justification |
-|---|---|---|
-| `lucide-react` | ✅ Autorisée | Icônes cohérentes, tree-shakeable, aucun CSS importé |
-| `clsx` + `tailwind-merge` | ✅ Autorisées | Utilitaire `cn()` pour merge de classes |
-| `shadcn/ui` | ⛔ Interdit | Overrides Tailwind trop complexes pour le volume actuel |
-| `radix-ui` | 🔒 Reporté | Utilisable si besoin justifié par ADR (Dialog, Popover) |
-
----
-
-## 10. Règles impératives pour Claude Code
-
-1. **Ne jamais utiliser de couleurs en dur** — toujours `var(--color-*)`.
-2. **Ne jamais utiliser `bg-white`, `bg-gray-*`, `text-gray-*`** — utiliser les tokens sémantiques.
-3. **La sidebar est toujours navy** — ses tokens ne changent jamais entre dark et light.
-4. **Le fond principal utilise deux `radial-gradient` subtils** — jamais un aplat uni.
-5. **Typographie : Plus Jakarta Sans uniquement** — jamais Inter, Roboto, ou system seul.
-6. **Les cartes** utilisent `var(--color-surface)` + `var(--color-border)` — en dark c'est semi-transparent, en light c'est blanc avec shadow.
-7. **Les hover states** utilisent `var(--color-surface-hover)` — jamais de `rgba` en dur.
-8. **Copier les patterns de la section 5** — ne pas réinterpréter, ne pas "améliorer".
-9. **Tester visuellement en dark ET en light** avant de valider un composant.
-
----
-
-## 11. Journal des décisions DA
-
-| Date | Décision | Contexte |
-|---|---|---|
-| 2026-04-17 | Direction A (Boréal) validée comme base | 5 directions explorées, A choisie |
-| 2026-04-17 | Fond `#F4F2EC`, light-only, dark reporté Sprint 2+ | Première itération |
-| 2026-04-24 | **Direction Charcoal adoptée — remplace Boréal** | Analyse concurrentielle Primo TCF, 4 directions testées (Deep Navy, Royal Blue, Gradient Mesh, Charcoal), Charcoal retenu avec touch de Gradient Mesh |
-| 2026-04-24 | Sidebar navy `#0C1528` permanent dark+light | Cohérence Slack/Discord/Linear, ancre visuelle de marque |
-| 2026-04-24 | Dark mode activé par défaut (`#111111`) | Usage quotidien desktop, cible intérieur, cohérent avec le positionnement premium |
-| 2026-04-24 | Light mode activé avec fond `#F3F4F6` | Sidebar navy maintenue, topbar claire, cartes blanches avec shadow |
-| 2026-04-24 | `prefers-color-scheme` respecté au chargement | Fallback dark si pas de préférence système |
-| 2026-04-24 | Desktop-first pour l'app | Analytics V1 : 60% desktop après 1 semaine d'usage. Mobile = acquisition (Facebook/WhatsApp), desktop = usage quotidien |
-| 2026-04-24 | Plus Jakarta Sans via Google Fonts CDN | Chargement explicite, pas de fallback-only |
-| 2026-04-24 | `lucide-react` autorisée | Remplace les SVG inline manuels |
-| 2026-04-24 | Tokens dual-theme actifs dès maintenant | Plus de dark reporté — les deux modes sont livrés ensemble |
-
----
-
-## 12. Hors périmètre actuel
-
-Éléments explicitement **reportés** :
-
-- Thème haut-contraste (WCAG AAA).
-- Internationalisation (i18n) — monolingue FR.
-- Animations avancées (scroll-linked, shared element transitions).
-- Illustrations personnalisées / iconographie signature.
-- Self-hosting de la font Plus Jakarta Sans.
-- Troisième thème (ex: "mode examen" épuré).
-
-Chacun de ces points mérite un ADR dédié quand il sera abordé.
diff --git a/docs/DEVELOPMENT_PRINCIPLES.md b/docs/DEVELOPMENT_PRINCIPLES.md
index 935b6f5..9d70607 100644
--- a/docs/DEVELOPMENT_PRINCIPLES.md
+++ b/docs/DEVELOPMENT_PRINCIPLES.md
@@ -143,24 +143,6 @@ Voir `SECURITY.md` pour le détail.
Claude Code ne crée jamais de worktree Git.
Toutes les modifications se font directement dans le dossier du projet principal.
-### Règle L — Tokens du design system, jamais de valeurs brutes
-Toutes les couleurs dans le JSX passent exclusivement par les tokens Direction H :
-- Utilitaires Tailwind : `bg-canvas`, `text-ink-2`, `border-line`, `bg-expria`, `text-danger`, etc.
-- Jamais de classes couleur Tailwind par défaut : `bg-slate-100`, `text-gray-500`, `blue-600`…
-- Jamais de valeurs inline brutes : `#1B4FD8`, `oklch(…)`, `rgb(…)` dans les className ou style
-- Pour les inline styles dynamiques uniquement : `style={{ background: 'var(--color-expria)' }}`
-- Tout nouveau token est ajouté exclusivement dans `@theme {}` (et `.dark {}`) de `src/index.css`
-
-```tsx
-// ❌ JAMAIS
-
-
-
-// ✅ TOUJOURS
-
-
{/* inline style dynamique uniquement */}
-```
-
---
## 3. Structure du code — conventions
@@ -479,57 +461,3 @@ Avant chaque session Claude Code, vérifier :
| Version | Date | Changements |
|---|---|---|
| 1.0 | 2026-04-17 | Création, adaptée de la version backend |
-| 1.1 | 2026-04-18 | Ajout Règle L — tokens du design system (Sprint 0.5) |
-| 1.2 | 2026-04-21 | Ajout section 10 — Session Clean obligatoire après chaque sprint |
-
----
-
-## 10. Session Clean (obligatoire après chaque sprint)
-
-> Session séparée du sprint de dev — jamais en cours d'implémentation.
-
-### Déclenchement
-- Le sprint est terminé
-- Tous les tests automatisés sont verts
-- Un commit propre existe (point de retour sûr)
-
-### Prompt standard à donner à Claude Code
-
-Lis dans l'ordre :
-1. docs/ARCHITECTURE.md
-2. docs/DEVELOPMENT_PRINCIPLES.md
-3. docs/DESIGN_SYSTEM.md
-
-Sprint [X] terminé, tests au vert, commit propre effectué.
-Agis comme un ingénieur senior.
-Analyse uniquement les fichiers modifiés ce sprint.
-
-Objectif : réduire la complexité sans changer aucune fonctionnalité.
-
-Règles :
-- 1 fichier modifié à la fois
-- npm run typecheck + npm run test après chaque fichier
-- Si un test échoue : annuler la modification, passer au suivant
-- Ne pas toucher aux fichiers non modifiés ce sprint
-- Ne pas supprimer de code sans vérifier au préalable
- qu'il n'est pas référencé ailleurs dans le projet
- (grep obligatoire avant toute suppression)
-- Aucune décision architecturale — si un doute,
- signaler et attendre
-
-Produis un plan (liste des fichiers à nettoyer, ordre)
-et attends le GO.
-
-### Séquence obligatoire
-1. Claude Code propose le plan (fichiers + ordre)
-2. Validation dans le Project avant GO
-3. Claude Code factorise — 1 fichier à la fois
-4. npm run typecheck + npm run test verts après chaque fichier
-5. Tests manuels Golden Dataset — groupes concernés
-6. Si tout vert → commit : refactor(): nettoyage Sprint [X]
-7. CHANGELOG.md mis à jour
-
-### Règle absolue
-Un test manuel qui échoue après refactor = annuler toute
-la session Clean, revenir au commit du sprint,
-diagnostiquer avant de retenter.
diff --git a/docs/GOLDEN_DATASET.md b/docs/GOLDEN_DATASET.md
index b751713..4fcf69c 100644
--- a/docs/GOLDEN_DATASET.md
+++ b/docs/GOLDEN_DATASET.md
@@ -16,135 +16,120 @@
4. En cas de doute : rejouer le groupe Z (smoke test complet)
**Environnement de test :**
-
- URL frontend local : `http://localhost:5173`
- URL backend : `https://api.expria.app` (ou local si dev simultané)
- Navigateurs à couvrir : Chrome + Firefox + Safari mobile (via DevTools mobile emulation minimum)
**Comptes de test (identiques au backend) :**
-| Compte | Plan | Mot de passe |
-| ----------------------- | -------------------- | --------------- |
-| test.free@gmail.com | free | Expria2025!test |
-| test.standard@gmail.com | standard | Expria2025!test |
-| test.premium@gmail.com | premium | Expria2025!test |
-| test.quota@gmail.com | free (5/5 utilisées) | Expria2025!test |
+| Compte | Plan | Mot de passe |
+|---|---|---|
+| test.free@gmail.com | free | Expria2025!test |
+| test.standard@gmail.com | standard | Expria2025!test |
+| test.premium@gmail.com | premium | Expria2025!test |
+| test.quota@gmail.com | free (5/5 utilisées) | Expria2025!test |
---
## Groupe A — Authentification et routing
-| # | Test | Compte | Résultat attendu | ✅/❌ |
-| --- | --------------------------------------------- | --------- | ------------------------------------------------- | ----- |
-| A1 | Arriver sur `/` sans être connecté | — | Page Home publique affichée | |
-| A2 | Cliquer "Se connecter" depuis Home | — | Redirection `/login`, formulaire visible | |
-| A3 | Inscription avec email + mot de passe valides | nouveau | Compte créé, plan=free, redirection `/dashboard` | |
-| A4 | Connexion avec identifiants corrects | test.free | Redirection `/dashboard`, plan Free affiché | |
-| A5 | Connexion avec mot de passe incorrect | test.free | Message d'erreur en français, pas de redirection | |
-| A6 | Déconnexion depuis le menu utilisateur | test.free | Redirection `/`, session invalidée | |
-| A7 | Accès direct à `/dashboard` sans auth | — | Redirection `/login` (ProtectedRoute) | |
-| A8 | Accès direct à `/t2-live` en tant que Free | test.free | Redirection ou PaywallModal "Exclusivité Premium" | |
-| A9 | Session JWT expirée pendant navigation | test.free | Message "Session expirée", redirection `/login` | |
-| A10 | Rafraîchir la page après login | test.free | Reste connecté, dashboard réaffiché | |
+| # | Test | Compte | Résultat attendu | ✅/❌ |
+|---|---|---|---|---|
+| A1 | Arriver sur `/` sans être connecté | — | Page Home publique affichée | |
+| A2 | Cliquer "Se connecter" depuis Home | — | Redirection `/login`, formulaire visible | |
+| A3 | Inscription avec email + mot de passe valides | nouveau | Compte créé, plan=free, redirection `/dashboard` | |
+| A4 | Connexion avec identifiants corrects | test.free | Redirection `/dashboard`, plan Free affiché | |
+| A5 | Connexion avec mot de passe incorrect | test.free | Message d'erreur en français, pas de redirection | |
+| A6 | Déconnexion depuis le menu utilisateur | test.free | Redirection `/`, session invalidée | |
+| A7 | Accès direct à `/dashboard` sans auth | — | Redirection `/login` (ProtectedRoute) | |
+| A8 | Accès direct à `/t2-live` en tant que Free | test.free | Redirection ou PaywallModal "Exclusivité Premium" | |
+| A9 | Session JWT expirée pendant navigation | test.free | Message "Session expirée", redirection `/login` | |
+| A10 | Rafraîchir la page après login | test.free | Reste connecté, dashboard réaffiché | |
---
## Groupe B — Plan Free (parcours complet)
-| # | Test | Compte | Résultat attendu | ✅/❌ |
-| --- | ----------------------------------------------- | --------------------- | ------------------------------------------------------------------------------- | ----- |
-| B1 | Dashboard Free après connexion | test.free | Compteur "X/5 simulations", aperçu flouté du dashboard Premium visible | |
-| B2 | Badge compteur simulations affiché | test.free | Visible en permanence dans le header du dashboard | |
-| B3 | Lancer une simulation EE T1 | test.free (quota < 5) | Interface de production affichée, pas de tips visibles | |
-| B4 | Soumettre une production EE | test.free | Rapport affiché : score + NCLC visibles, critères floutés avec cadenas | |
-| B5 | Rapport flouté avec mentions correctes | test.free | "Disponible en Standard" + bouton upgrade visible | |
-| B6 | Lancer une simulation EO T1 | test.free | Interface d'enregistrement audio, pas d'erreur microphone | |
-| B7 | Tenter EO T2 live depuis le sélecteur de tâches | test.free | Cadenas + message "Exclusivité Premium" | |
-| B8 | Atteindre la 6e simulation | test.quota | Modal de blocage : "5/5 utilisées" + 2 boutons (Standard/Premium) + "Plus tard" | |
-| B9 | Cliquer "Plus tard" dans le modal | test.quota | Modal fermé, dashboard visible, pas de redirection | |
-| B10 | Cliquer "Mode Examen" | test.free | Cadenas + message "Exclusivité Premium" | |
-| B11 | Tenter accès URL direct `/exam-mode` | test.free | Redirection ou PaywallModal | |
+| # | Test | Compte | Résultat attendu | ✅/❌ |
+|---|---|---|---|---|
+| B1 | Dashboard Free après connexion | test.free | Compteur "X/5 simulations", aperçu flouté du dashboard Premium visible | |
+| B2 | Badge compteur simulations affiché | test.free | Visible en permanence dans le header du dashboard | |
+| B3 | Lancer une simulation EE T1 | test.free (quota < 5) | Interface de production affichée, pas de tips visibles | |
+| B4 | Soumettre une production EE | test.free | Rapport affiché : score + NCLC visibles, critères floutés avec cadenas | |
+| B5 | Rapport flouté avec mentions correctes | test.free | "Disponible en Standard" + bouton upgrade visible | |
+| B6 | Lancer une simulation EO T1 | test.free | Interface d'enregistrement audio, pas d'erreur microphone | |
+| B7 | Tenter EO T2 live depuis le sélecteur de tâches | test.free | Cadenas + message "Exclusivité Premium" | |
+| B8 | Atteindre la 6e simulation | test.quota | Modal de blocage : "5/5 utilisées" + 2 boutons (Standard/Premium) + "Plus tard" | |
+| B9 | Cliquer "Plus tard" dans le modal | test.quota | Modal fermé, dashboard visible, pas de redirection | |
+| B10 | Cliquer "Mode Examen" | test.free | Cadenas + message "Exclusivité Premium" | |
+| B11 | Tenter accès URL direct `/exam-mode` | test.free | Redirection ou PaywallModal | |
---
## Groupe C — Plan Standard
-| # | Test | Compte | Résultat attendu | ✅/❌ |
-| --- | -------------------------------------------- | ------------- | --------------------------------------------------------------------------------- | ----- |
-| C1 | Dashboard Standard après connexion | test.standard | Historique visible, pas de compteur simulations, bouton "Choisir une tâche" actif | |
-| C2 | Lancer simulation EE sans limite | test.standard | Accès direct, aucune vérification de quota visible | |
-| C3 | Toggle "Suggestions d'idées" activé | test.standard | Suggestions visibles pendant la simulation | |
-| C4 | Toggle "Mode focus" activé | test.standard | Tips masqués pendant la simulation | |
-| C5 | Rapport complet après soumission EE | test.standard | Score, critères détaillés, erreurs expliquées, modèle, exercices — rien flouté | |
-| C6 | Production apparaît dans le dashboard | test.standard | Date, tâche, score affichés dans la liste | |
-| C7 | Cliquer une production dans l'historique | test.standard | Rapport complet de cette production réaffiché | |
-| C8 | Cliquer "Mode Examen" | test.standard | Message "Réservé au plan Premium" + bouton upgrade | |
-| C9 | Cliquer "EO Tâche 2 live" | test.standard | Cadenas + message "Exclusivité Premium" | |
-| C10 | Après 5 productions : indice de préparation | test.standard | Section indice visible avec score et message interprétatif | |
-| C11 | Upgrade Standard → Premium : prorata affiché | test.standard | Avant confirmation, montant prorata visible (ex : "~10€ aujourd'hui") | |
+| # | Test | Compte | Résultat attendu | ✅/❌ |
+|---|---|---|---|---|
+| C1 | Dashboard Standard après connexion | test.standard | Historique visible, pas de compteur simulations, bouton "Choisir une tâche" actif | |
+| C2 | Lancer simulation EE sans limite | test.standard | Accès direct, aucune vérification de quota visible | |
+| C3 | Toggle "Suggestions d'idées" activé | test.standard | Suggestions visibles pendant la simulation | |
+| C4 | Toggle "Mode focus" activé | test.standard | Tips masqués pendant la simulation | |
+| C5 | Rapport complet après soumission EE | test.standard | Score, critères détaillés, erreurs expliquées, modèle, exercices — rien flouté | |
+| C6 | Production apparaît dans le dashboard | test.standard | Date, tâche, score affichés dans la liste | |
+| C7 | Cliquer une production dans l'historique | test.standard | Rapport complet de cette production réaffiché | |
+| C8 | Cliquer "Mode Examen" | test.standard | Message "Réservé au plan Premium" + bouton upgrade | |
+| C9 | Cliquer "EO Tâche 2 live" | test.standard | Cadenas + message "Exclusivité Premium" | |
+| C10 | Après 5 productions : indice de préparation | test.standard | Section indice visible avec score et message interprétatif | |
+| C11 | Upgrade Standard → Premium : prorata affiché | test.standard | Avant confirmation, montant prorata visible (ex : "~10€ aujourd'hui") | |
---
## Groupe D — Plan Premium
-> ⚠️ Certains items décrivent un état cible (sprints futurs), pas l'état implémenté actuel — voir marqueurs par ligne.
-
-| # | Test | Compte | Résultat attendu | ✅/❌ |
-| --- | ------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
-| D1 | Dashboard Premium après connexion | test.premium | Historique, bouton examen actif, T2 live accessible ; indice / patterns / profil ⏳ non implémenté (sprint ultérieur) | |
-| D2 | Accéder à EO T2 live | test.premium | Page préparation T2, bouton "Démarrer le dialogue" actif | |
-| D3 | Démarrer le dialogue T2 | test.premium | État "Connecting" puis "Listening" ; le candidat prend la parole en premier (le candidat initie l'interaction de service), l'IA répond ensuite | |
-| D4 | Répondre en audio à l'IA | test.premium | L'IA réagit après la réponse du candidat, état oscille listening/speaking | |
-| D5 | Fin de dialogue T2 | test.premium | Rapport complet affiché, production enregistrée avec tag "T2 Live" | |
-| D6 | Déconnexion WebSocket en cours de T2 | test.premium | État "Error" affiché, message utilisateur clair, option de reprise — ⚠️ partiel (cf. note D6) | |
-| D7 | Lancer mode Examen EE | test.premium | Page d'avertissement affichée avant démarrage — ⏳ Sprint 8 — non implémenté | |
-| D8 | Confirmer Examen EE | test.premium | 3 tâches visibles, timer 60:00 démarré, inarrêtable — ⏳ Sprint 8 — non implémenté | |
-| D9 | Blocage à T=0 (Examen EE) | test.premium | Zone de texte figée, message "Temps écoulé", envoi auto — ⏳ Sprint 8 — non implémenté | |
-| D10 | Lancer mode Examen EO | test.premium | Timer 12:00, enregistrement actif, tâches enchaînées — ⏳ Sprint 8 — non implémenté | |
-| D11 | Analyse patterns (5+ productions) | test.premium | Section "Mon profil" avec erreurs récurrentes classées — ⏳ non implémenté (sprint ultérieur) | |
-| D12 | Accès à EO T1 Live → page préparation | test.premium | Carte « Tâche 1 — Live » débloquée sur `/simulation/eo` → clic → `/simulation/eo/t1/live/preparation` (titre « Préparation — Tâche 1 Live », bouton « Je suis prêt — démarrer la présentation ») | |
-| D13 | Démarrer la présentation | test.premium | Passage à `/simulation/eo/t1/live/dialogue`, état `presenting` (« À vous — présentez-vous. ») ; le candidat parle en premier — monologue, pas de VAD micro | |
-| D14 | Interruption examinateur (non déterministe) | test.premium | Si déclenchée par le backend : état `interrupted` (« L'examinateur vous interrompt — répondez-lui. »), reprise en `presenting` — ⚠️ non garantie à chaque essai (probabiliste, cf. note D12-D16) | |
-| D15 | Fin → écran terminal | test.premium | Bouton « Terminer » → état `processing` (« Évaluation en cours… ») → écran « Session terminée » avec boutons « Télécharger l'audio » et « Voir le rapport » | |
-| D16 | Rapport après T1 Live | test.premium | Rapport complet affiché (`/rapport/:id`) — ⚠️ aucun tag « T1 Live » visible (à la différence du tag « T2 Live »), cf. note D12-D16 | |
-
-> **Note D6 (partiel)** : un drop WebSocket subi mène bien à l'état `error` avec un message utilisateur clair (`T2DialoguePage.tsx:165-184`, `useT2LiveSession.ts:336-380`). Mais l'« option de reprise » du critère cible n'est PAS implémentée : l'écran d'erreur n'offre qu'un bouton « Retour aux sujets » (`T2DialoguePage.tsx:176-180`), pas de bouton « Réessayer » / reconnexion. Item à reclasser ✅ une fois la reprise ajoutée.
-
-> **Note D12-D16 (T1 Live)** : D14 dépend d'une décision probabiliste backend (P0=0.2 aucune interruption, P1=0.6 une, P2=0.2 deux, fenêtre 25-75 s) — un essai sans interruption est un résultat nominal, pas un échec de test. Deux dettes amont affectent le réalisme du dialogue sans bloquer ces tests : **FTD-45** (gelée, relances de l'examinateur parfois hors-sujet, extension TD-23) et **FTD-46** (gelée, transcription Gemini Live parfois imprécise) — toutes deux hors contrôle frontend.
->
-> **D16 — divergence vérifiée dans le code** : contrairement à T2 Live (`tache='EO_T2_LIVE'`, libellé « EO · Tâche 2 Live » dans l'historique), le backend T1 Live persiste `tache='EO_T1'` / `mode='entrainement'` (`t1live.ts`) — identique à une simulation T1 batch classique. Vérifié dans `historique.ts` (`TACHE_NUMBER`) et `RapportPage.tsx` : **aucun tag « Live » n'existe** côté frontend pour T1. Tracé dans `TECH_DEBT.md` (**FTD-47**, nouvelle, 🟢 Mineur).
+| # | Test | Compte | Résultat attendu | ✅/❌ |
+|---|---|---|---|---|
+| D1 | Dashboard Premium après connexion | test.premium | Historique, indice, patterns, bouton examen actif, T2 live accessible | |
+| D2 | Accéder à EO T2 live | test.premium | Page préparation T2, bouton "Démarrer le dialogue" actif | |
+| D3 | Démarrer le dialogue T2 | test.premium | État "Connecting" puis "Listening", l'IA prend la parole en premier | |
+| D4 | Répondre en audio à l'IA | test.premium | L'IA réagit après la réponse du candidat, état oscille listening/speaking | |
+| D5 | Fin de dialogue T2 | test.premium | Rapport complet affiché, production enregistrée avec tag "T2 Live" | |
+| D6 | Déconnexion WebSocket en cours de T2 | test.premium | État "Error" affiché, message utilisateur clair, option de reprise | |
+| D7 | Lancer mode Examen EE | test.premium | Page d'avertissement affichée avant démarrage | |
+| D8 | Confirmer Examen EE | test.premium | 3 tâches visibles, timer 60:00 démarré, inarrêtable | |
+| D9 | Blocage à T=0 (Examen EE) | test.premium | Zone de texte figée, message "Temps écoulé", envoi auto | |
+| D10 | Lancer mode Examen EO | test.premium | Timer 12:00, enregistrement actif, tâches enchaînées | |
+| D11 | Analyse patterns (5+ productions) | test.premium | Section "Mon profil" avec erreurs récurrentes classées | |
---
## Groupe E — Paiements Stripe
> ⚠️ Utiliser les cartes de test Stripe :
->
> - Carte valide : `4242 4242 4242 4242` (date future, CVC libre)
> - Carte refusée : `4000 0000 0000 0002`
-| # | Test | Compte | Résultat attendu | ✅/❌ |
-| --- | ------------------------------------------- | -------------------- | --------------------------------------------------------------------------- | ----- |
-| E1 | Upgrade Free → Standard (Stripe Checkout) | test.free | Redirection full page vers Stripe, paiement, retour dashboard Standard | |
-| E2 | Invalidation du cache plan après paiement | test.free → standard | usePlan() refetch automatiquement, dashboard bascule sans recharger la page | |
-| E3 | Upgrade Free → Premium | test.free | Même flux que E1, plan=premium après retour | |
-| E4 | Upgrade Standard → Premium avec prorata | test.standard | Montant prorata affiché avant confirmation, accès Premium immédiat | |
-| E5 | Paiement refusé (carte 4000 0000 0000 0002) | test.free | Message d'erreur Stripe clair, plan inchangé | |
-| E6 | Annuler au milieu du Checkout | test.free | Retour sur `/billing` ou `/pricing`, plan inchangé | |
+| # | Test | Compte | Résultat attendu | ✅/❌ |
+|---|---|---|---|---|
+| E1 | Upgrade Free → Standard (Stripe Checkout) | test.free | Redirection full page vers Stripe, paiement, retour dashboard Standard | |
+| E2 | Invalidation du cache plan après paiement | test.free → standard | usePlan() refetch automatiquement, dashboard bascule sans recharger la page | |
+| E3 | Upgrade Free → Premium | test.free | Même flux que E1, plan=premium après retour | |
+| E4 | Upgrade Standard → Premium avec prorata | test.standard | Montant prorata affiché avant confirmation, accès Premium immédiat | |
+| E5 | Paiement refusé (carte 4000 0000 0000 0002) | test.free | Message d'erreur Stripe clair, plan inchangé | |
+| E6 | Annuler au milieu du Checkout | test.free | Retour sur `/billing` ou `/pricing`, plan inchangé | |
---
## Groupe F — Sécurité et permissions
-| # | Test | Compte | Résultat attendu | ✅/❌ |
-| --- | --------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------- | ----- |
-| F1 | URL directe `/t2-live` en Standard | test.standard | Redirection ou PaywallModal, pas d'accès à la page | |
-| F2 | Inspecter DevTools → clés privées | — | Aucune clé `SERVICE_ROLE`, `GEMINI`, `STRIPE_SECRET` visible | |
-| F3 | Inspecter DevTools → JWT en clair dans localStorage | test.free | JWT Supabase visible (normal, c'est un access token) mais pas de refresh token exposé | |
-| F4 | Modifier le plan dans DevTools via Redux/state | test.free | La modification locale n'a aucun effet — le backend reste l'autorité | |
-| F5 | Rapport contenant des caractères HTML potentiellement malicieux | test.standard | Rendu comme texte, pas comme HTML (aucune exécution) | |
-| F6 | CSP header présent dans la réponse HTTP | — | `Content-Security-Policy` défini dans les headers Cloudflare Pages | |
-| F7 | Console navigateur : pas de log de JWT ou données perso | test.free | Aucun `console.log` contenant email, token, payload API | |
+| # | Test | Compte | Résultat attendu | ✅/❌ |
+|---|---|---|---|---|
+| F1 | URL directe `/t2-live` en Standard | test.standard | Redirection ou PaywallModal, pas d'accès à la page | |
+| F2 | Inspecter DevTools → clés privées | — | Aucune clé `SERVICE_ROLE`, `GEMINI`, `STRIPE_SECRET` visible | |
+| F3 | Inspecter DevTools → JWT en clair dans localStorage | test.free | JWT Supabase visible (normal, c'est un access token) mais pas de refresh token exposé | |
+| F4 | Modifier le plan dans DevTools via Redux/state | test.free | La modification locale n'a aucun effet — le backend reste l'autorité | |
+| F5 | Rapport contenant des caractères HTML potentiellement malicieux | test.standard | Rendu comme texte, pas comme HTML (aucune exécution) | |
+| F6 | CSP header présent dans la réponse HTTP | — | `Content-Security-Policy` défini dans les headers Cloudflare Pages | |
+| F7 | Console navigateur : pas de log de JWT ou données perso | test.free | Aucun `console.log` contenant email, token, payload API | |
---
@@ -152,15 +137,15 @@
Tests à rejouer sur DevTools mobile emulation (iPhone SE, iPhone 12, Samsung Galaxy) ET sur vrai mobile si possible.
-| # | Test | Résultat attendu | ✅/❌ |
-| --- | ---------------------------------- | ---------------------------------------------------------------- | ----- |
-| G1 | Page Home lisible sur écran 375px | Pas de débordement horizontal, CTA accessible | |
-| G2 | Formulaire de login sur mobile | Champs bien dimensionnés, clavier virtuel ne cache pas le bouton | |
-| G3 | Dashboard Free sur mobile | Compteur visible, aperçu flouté lisible | |
-| G4 | Simulation EE sur mobile | Zone de texte utilisable, pas de zoom intempestif | |
-| G5 | Enregistrement audio EO sur mobile | Permission microphone demandée, enregistrement fonctionnel | |
-| G6 | T2 live sur mobile (Premium) | WebSocket fonctionne, audio bidirectionnel OK | |
-| G7 | Modal PaywallModal sur mobile | Scrollable si contenu déborde, bouton fermeture accessible | |
+| # | Test | Résultat attendu | ✅/❌ |
+|---|---|---|---|
+| G1 | Page Home lisible sur écran 375px | Pas de débordement horizontal, CTA accessible | |
+| G2 | Formulaire de login sur mobile | Champs bien dimensionnés, clavier virtuel ne cache pas le bouton | |
+| G3 | Dashboard Free sur mobile | Compteur visible, aperçu flouté lisible | |
+| G4 | Simulation EE sur mobile | Zone de texte utilisable, pas de zoom intempestif | |
+| G5 | Enregistrement audio EO sur mobile | Permission microphone demandée, enregistrement fonctionnel | |
+| G6 | T2 live sur mobile (Premium) | WebSocket fonctionne, audio bidirectionnel OK | |
+| G7 | Modal PaywallModal sur mobile | Scrollable si contenu déborde, bouton fermeture accessible | |
---
@@ -168,18 +153,18 @@ Tests à rejouer sur DevTools mobile emulation (iPhone SE, iPhone 12, Samsung Ga
Les 10 scénarios les plus critiques, à rejouer dans l'ordre avant chaque déploiement production.
-| # | Test | Description rapide |
-| --- | -------------------------------------- | ------------------------------------------------------------- |
-| Z1 | Inscription + première simulation Free | Compte créé → simulation → rapport flouté visible |
-| Z2 | Blocage quota Free | 6e simulation → modal de blocage |
-| Z3 | Simulation Standard complète | Login → simulation → rapport complet → dashboard |
-| Z4 | Mode examen bloqué en Standard | Bouton mode examen → message upgrade |
-| Z5 | T2 live Premium | Login → T2 live → dialogue → rapport |
-| Z6 | Mode examen EE complet | Lancement → timer → T=0 → envoi auto → rapport |
-| Z7 | Paiement Free → Standard | Stripe Checkout → retour dashboard Standard sans rechargement |
-| Z8 | Prorata Standard → Premium | Montant affiché → confirmation → accès Premium immédiat |
-| Z9 | Déconnexion + accès protégé | Logout → accès `/dashboard` → redirection `/login` |
-| Z10 | Responsive mobile Home + Login | Affichage correct sur iPhone SE |
+| # | Test | Description rapide |
+|---|---|---|
+| Z1 | Inscription + première simulation Free | Compte créé → simulation → rapport flouté visible |
+| Z2 | Blocage quota Free | 6e simulation → modal de blocage |
+| Z3 | Simulation Standard complète | Login → simulation → rapport complet → dashboard |
+| Z4 | Mode examen bloqué en Standard | Bouton mode examen → message upgrade |
+| Z5 | T2 live Premium | Login → T2 live → dialogue → rapport |
+| Z6 | Mode examen EE complet | Lancement → timer → T=0 → envoi auto → rapport |
+| Z7 | Paiement Free → Standard | Stripe Checkout → retour dashboard Standard sans rechargement |
+| Z8 | Prorata Standard → Premium | Montant affiché → confirmation → accès Premium immédiat |
+| Z9 | Déconnexion + accès protégé | Logout → accès `/dashboard` → redirection `/login` |
+| Z10 | Responsive mobile Home + Login | Affichage correct sur iPhone SE |
---
@@ -205,14 +190,13 @@ Test échoue
> Remplir après chaque session Claude Code frontend.
| Date | Session | Tests rejoués | Résultat | Notes |
-| ---- | ------- | ------------- | -------- | ----- |
-| — | — | — | — | — |
+|---|---|---|---|---|
+| — | — | — | — | — |
---
## Historique de ce document
-| Version | Date | Changements |
-| ------- | ---------- | ------------------------------------------------------------------- |
-| 1.0 | 2026-04-17 | Création initiale, 55 tests frontend |
-| 1.1 | 2026-07-02 | Ajout Groupe D étendu T1 Live (D12-D16) — Sprint 7.5 Clean (FTD-44) |
+| Version | Date | Changements |
+|---|---|---|
+| 1.0 | 2026-04-17 | Création initiale, 55 tests frontend |
diff --git a/docs/PARCOURS_UTILISATEURS.md b/docs/PARCOURS_UTILISATEURS.md
index abec527..6613dd0 100644
--- a/docs/PARCOURS_UTILISATEURS.md
+++ b/docs/PARCOURS_UTILISATEURS.md
@@ -299,41 +299,18 @@ Page de préparation :
— Explication du déroulé
(l'IA joue le rôle de l'examinateur)
— Consigne de la tâche affichée
- — Zone de notes personnelles (brouillon local du candidat)
- — Bouton "Suggestions d'idées"
- → propose des pistes pour nourrir la préparation
- → débloqué seulement quand les notes atteignent ~30 mots
- (évite une demande d'idées "à vide")
— Bouton "Démarrer le dialogue"
↓
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SIMULATION LIVE — T2 Expression Orale
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- — Le candidat ouvre l'interaction de service (il a besoin d'une
- information et initie la conversation — format réel TCF Canada)
- — L'examinateur (IA) répond ensuite et relance le dialogue
- — Le candidat poursuit en audio en temps réel
- — La voix de l'IA est jouée sans blanc ni coupure ;
- voix de l'examinateur et voix du candidat partagent la même
- horloge audio (dialogue fluide, sans décalage)
- — Un indicateur signale qui a la parole
- (le candidat parle / il écoute l'examinateur)
+ — L'IA ouvre le dialogue (première prise de parole de l'examinateur)
+ — Le candidat répond en audio en temps réel
— L'IA adapte ses relances selon les réponses du candidat
- — Timer de préparation 2:00 (transition automatique vers le dialogue à 0:00)
- — Timer de dialogue 3:30 (210 s)
- ↓
-Pendant le dialogue, le candidat peut :
- — "Annuler" → quitte la simulation sans évaluation,
- aucun rapport généré, aucun enregistrement conservé
+ — Durée libre en mode entraînement (pas de timer sur cette tâche)
↓
Fin du dialogue (candidat ou IA clôture)
↓
-Écran terminal :
- — Bouton "Télécharger l'audio" (enregistrement WAV du dialogue complet,
- voix candidat + examinateur mixées sur une seule piste)
- — Bouton "Voir le rapport" → /rapport/:id
- — Bouton "Nouvelle simulation" → relance le parcours T2 Live
- ↓
Rapport complet généré (même structure que les autres tâches) ✅
↓
Production enregistrée dans le dashboard avec tag "T2 Live"
@@ -447,18 +424,17 @@ Webhook Stripe : customer.subscription.deleted
## 5. Matrice des upgrades / downgrades
-| Depuis → Vers | Action | Montant facturé | Délai | Données |
-| ------------------ | ------------------------------- | --------------------- | ---------------------- | ---------- |
-| Free → Standard | Stripe Checkout | 19,90€ | Immédiat après webhook | Conservées |
-| Free → Premium | Stripe Checkout | 39,90€ | Immédiat après webhook | Conservées |
-| Standard → Premium | Prorata Stripe | Différence au prorata | Immédiat après webhook | Conservées |
-| Premium → Standard | Résiliation + nouvel abonnement | 19,90€ | Immédiat après webhook | Conservées |
-| Premium → Free | Résiliation | — | Immédiat après webhook | Conservées |
-| Standard → Free | Résiliation | — | Immédiat après webhook | Conservées |
+| Depuis → Vers | Action | Montant facturé | Délai | Données |
+|---|---|---|---|---|
+| Free → Standard | Stripe Checkout | 19,90€ | Immédiat après webhook | Conservées |
+| Free → Premium | Stripe Checkout | 39,90€ | Immédiat après webhook | Conservées |
+| Standard → Premium | Prorata Stripe | Différence au prorata | Immédiat après webhook | Conservées |
+| Premium → Standard | Résiliation + nouvel abonnement | 19,90€ | Immédiat après webhook | Conservées |
+| Premium → Free | Résiliation | — | Immédiat après webhook | Conservées |
+| Standard → Free | Résiliation | — | Immédiat après webhook | Conservées |
> **Règle absolue :** les productions ne sont jamais supprimées, quel que soit le changement de plan.
> L'accès aux features change. Les données restent.
### Détail du prorata Standard → Premium
-
Stripe crédite automatiquement les jours non consommés du plan Standard et facture les jours restants au tarif Premium. L'utilisateur voit le montant exact avant de confirmer. Aucun calcul manuel requis côté code — comportement natif de Stripe via `subscription.update()` avec `proration_behavior: 'always_invoice'`.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
deleted file mode 100644
index c92e5d0..0000000
--- a/docs/ROADMAP.md
+++ /dev/null
@@ -1,233 +0,0 @@
-# ROADMAP.md — Expria Frontend
-
-> Source de vérité de l'ordre d'implémentation des sprints.
-> Ne pas modifier sans validation de Hermann.
-
----
-
-## Sprint 0 — Fondations ✅
-
-1. Scaffold Vite + TypeScript + Tailwind + shadcn/ui
-2. Structure de dossiers complète
-3. docs/ copiés depuis backend + adaptations
-4. ONBOARDING.md rédigé
-
-## Sprint 0.5 — Design System ✅
-
-- Direction artistique Boréal validée
-- Tokens CSS dans index.css
-- DESIGN_SYSTEM.md rédigé
-
-## Sprint 1 — Auth + API layer ✅
-
-5. auth-client.ts
-6. api-client.ts
-7. query-client.ts
-8. entities/user/\*
-9. features/auth (Login, Register, ProtectedRoute)
-
-## Sprint 2 — Dashboard conditionnel ✅
-
-10. usePlan hook
-11. shared/components/PaywallModal
-12. features/dashboard (Free / Standard / Premium)
-
-## Sprint 3 — Simulations EE ✅
-
-13. entities/production/_ + entities/report/_
-14. features/simulations (EE T1/T2/T3)
-15. Affichage rapport avec floutage conditionnel
-
-## Sprint 3.5 — Clean
-
-- Factorisation des fichiers modifiés Sprint 3
-- Tests manuels Groupe B + C rejoués
-- Commit refactor(simulation-ee)
-
-## Sprint 3.6a — Qualité correction — Backend ✅
-
-- Remplacement prompt maître (docs/Prompt_maître.md) + intégration taxonomie erreurs (docs/TAXONOMIE_ERREURS.md)
-- Remplacement prompt production modèle (docs/Prompt_production_modèle.md) — cible fixe NCLC 9
-- Génération parallèle correction + exercices + modèle (await correction, fire-and-forget sur les deux autres)
-- Nouveaux champs DB : revelation, diagnostic, conseil_nclc, erreurs_codes, exercices_status, modele_status, nclc_cible
-- Mise à jour GET /simulations/:id
-- Migration SQL : `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` (à exécuter manuellement)
-- Tests : 173 tests verts (+18 vs baseline)
-
-## Sprint 3.6b — Qualité correction — Frontend ✅
-
-- Sélecteur NCLC cible dans SimulationForm (9 ou 10, défaut 9) — NclcCibleSelector
-- RapportPage réécrite : ScoreHero (jauge + seuil NCLC cible + écart), RevelationCards, DiagnosticCallout, CritereCard enrichie (exemple/suggestion/astuce + codes taxonomie), ConseilNclcCallout
-- ExerciceInteractive : badge difficulté, zone texte, bouton Indice (une fois), bouton Voir la correction (activé après saisie), explication
-- ProductionModeleSection : texte final + notes pédagogiques + transformations original/amélioré + message
-- JobStatusFallback : gère exercices_status / modele_status (pending / error) — refresh manuel, polling tracé en FTD-24
-- Gating plan conforme PLANS_TARIFAIRES.md : revelation/diagnostic/conseil_nclc tous plans ; criteres/exercices/modele Standard+
-- Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive)
-
-## Sprint 3.7 — Historique ✅
-
-- Backend : `GET /simulations` — liste paginée des productions de l'utilisateur connecté (page/limit, tri `created_at DESC`, projection légère). 186 tests backend verts.
-- Frontend : page `/historique` (route sous AppLayout), liste d'items (date relative, tâche, score /20, NCLC, badge Examen / En cours), pagination Précédent/Suivant, clic → `/rapport/:id`.
-- Gating plan : Free → aperçu flouté + CTA « Passer en Standard » (`hasAccess(plan, 'dashboard')`) ; Standard + Premium → liste complète.
-- État vide : CTA « Démarrer une simulation ».
-- Hook `useSimulationsList(page, limit)` — TanStack Query, `staleTime: 30s`, `keepPreviousData` pour transitions fluides.
-- Helper `formatRelativeDate` (Intl.RelativeTimeFormat, zéro dépendance).
-- 102 tests frontend verts (+18 vs baseline 84).
-
-## Sprint 3.6c — Analyse patterns (Premium) ✅
-
-- Backend : `GET /users/patterns` — agrégation des `erreurs_codes` sur les 5 dernières productions corrigées, seuil 3/5, tri DESC, cache `pattern_analyses` avec invalidation si nouvelle production plus récente que la dernière analyse.
-- Backend : exercices long terme générés par DeepSeek sur patterns confirmés — format `{ consigne, exemple, correction, astuce }` avec prompt dédié (température 0.4, timeout 20 s). Dégradation gracieuse si DeepSeek échoue.
-- Backend : indice de préparation 0→100 — formule 60 % score moyen + 20 % régularité + 20 % tendance, messages figés (`<40`, `40-70`, `>70`).
-- Backend : migration SQL `005_sprint_3_6c_pattern_analyses.sql` (RLS SELECT par user_id, index composite, CHECK constraints).
-- Backend : 205 tests verts (+19 vs baseline 186).
-- Frontend : page `/progression` — orchestration hero (indice + jauge), liste patterns, cartes exercices long terme, footer « il y a X » ; gate plan via `hasAccess('pattern_analysis')` (Free/Standard → aperçu flouté + upgrade).
-- Frontend : `PatternExerciceCard` — composant lesson-style dédié (non interactif, UX distincte de `ExerciceInteractive`) avec encart astuce proéminent.
-- Frontend : Dashboard Premium — section compacte `MonProfilPreparation` (MetricCard indice + nb patterns + CTA vers `/progression`). Absente pour Free/Standard.
-- Frontend : hook `usePatterns` (staleTime 60 s, cache partagé entre page et dashboard, `enabled` conditionné par feature).
-- Frontend : 115 tests verts (+13 vs baseline 102).
-
-## Sprint DA Charcoal — Reskin ✅
-
-- Remplacement palette Boréal par Charcoal (dark default, light override)
-- Sidebar navy permanent, layout radial-gradient, anti-FOUC
-- Renommage tokens sur ~45 composants + inversion dark:/light: shadcn
-- ADR 006 mis à jour
-
-## Sprint UI Polish — Sidebar + Topbar + Dashboard ✅
-
-- Sidebar : icônes lucide, cadenas gating, badge upgrade, user footer, logo "EX|PRIA"
-- Topbar : sticky backdrop-blur, breadcrumb centralisé, recherche placeholder
-- Dashboard : split Free/Standard/Premium, NclcHero + StatCards + RecentSimulations + NextStepCard + PaywallBanner refonte
-- MobileHeader supprimé (remplacé par Topbar)
-
-## Sprint 4 — Simulations EO (audio) ✅
-
-- MediaRecorder + Gemini batch transcription (EO T1/T3)
-- Questionnaire T1 + génération présentation IA (POST /presentations/generate)
-- Auto-submit à expiration de la durée recommandée
-- Rapport EO format 3.6a (4 critères officiels TCF Canada)
-
-## Sprint 4.5 — Clean
-
-- Factorisation des fichiers modifiés Sprint 4
-- Tests manuels Groupe B + D rejoués
-- Commit refactor(simulation-eo)
-
-## Sprint 4.6 — UI EO (waveform + timeline)
-
-- Waveform visualizer pendant l'enregistrement (barres audio animées)
-- Barre timeline colorée : verte → orange (75%) → rouge (dernières 15s)
-- Applicable à toutes les tâches EO (T1 et T3)
-
-## Sprint 4.7 — Historique refonte
-
-- Stats en haut : Total simulations, Score moyen, Meilleur score
-- Filtre par tâche (EE T1/T2/T3, EO T1/T3, Examen blanc)
-- Filtre par période (Ce mois, 3 mois, Tout)
-- Design conforme aux captures de référence
-
-## Sprint 4.8 — Phonologie EO
-
-- Affichage note_phonologie dans RapportPage (déjà stocké en base)
-- Analyse phonologique réelle via Gemini audio (TD-08 backend)
-- Score phonologie dans les 4 critères EO (actuellement fixé à 0)
-
-## Sprint 5 — Billing ✅
-
-- **5a (backend)** : TD-13 webhook idempotency (table `stripe_webhook_events` + helpers + 10 tests) ; route `POST /stripe/customer-portal` + `createBillingPortalSession` ; doc cleanup `ARCHITECTURE-backend.md` (`POST /plans/upgrade` retiré, duplication doc) ; tests backend 261 → 278.
-- **5b (frontend)** : `PricingPage` `/plan` (3 colonnes Découverte/Standard/Premium) + `useStripeCheckout` initial + uniformisation CTA upgrade « Voir les plans » sur 5 emplacements ; env vars `VITE_STRIPE_PRICE_*` ; tests 198 → 203.
-- **5c (frontend + cross-repo backend fix)** : `useStripeCheckout` hook isolé + `useUpgradeSuccessHandler` (détection `?upgrade=success` + invalidation cache plan + URL clean) + `UpgradeSuccessBanner` ; migration `PricingPage` + injection banner `DashboardPage` ; fix backend `cancel_url /tarifs → /plan` ; tests 203 → 212.
-- **5d (frontend)** : `useCustomerPortal` hook + `AccountBillingSection` + `ParametresPage` `/parametres` (Abonnement + Session/déconnexion) ; **Standard→Premium routé via Customer Portal** (prorata natif Stripe) ; tests 212 → 219.
-
-## Sprint 5.5 — Clean
-
-- Factorisation des fichiers modifiés Sprint 5
-- Tests manuels Groupe E rejoués
-- Commit refactor(billing)
-
-## Sprint 6 — T2 Live ✅
-
-18. features/t2-live (ws-client + audio worklet + state machine)
-
-- **6b (frontend)** : capture micro (AudioWorklet 16 kHz uplink) + playback IA + helpers audio purs.
-- **6c (frontend)** : state machine T2 (9 états), `useT2LiveSession` (WebSocket + audio + format Gemini natif), pages Sujets / Préparation / Dialogue + routes ; carte EO T2 Live déverrouillée Premium.
-- **6d (backend)** : prompt T2 durci (anti-relance, interdiction du `?`, règles dures Gemini — TD-22), VAD `realtimeInputConfig` réintégré, `@google/genai` retiré. Validé Groupe D en conditions réelles. Commits `94387a7` (code) + `5f7e52d` (docs), poussés sur `forgejo`.
-- **6e (frontend)** : architecture audio « Voie A » — un seul AudioContext au rate natif partagé (capture + playback + enregistrement), mix temps réel via tap worklet, WAV mono single-track aligné, indicateur de prise de parole (VAD), correction des blancs EO, nettoyage `[BISECT]`. Tests 269/37 ; validation audio à l'oreille.
-
-## Sprint 6.5 — Clean
-
-- Factorisation des fichiers modifiés Sprint 6
-- Tests manuels Groupe D rejoués
-- Commit refactor(t2-live)
-
-## Sprint 7 — T1 Live (interruption aléatoire)
-
-- **7a (backend) ✅** : extension du proxy WebSocket Gemini Live (`gemini-3.1-flash-live-preview`, ws brut, pas de SDK) au mode T1 — system prompt « examinateur », décision d'interruption probabiliste, génération de la question de relance sur transcription partielle (DeepSeek). Réutilise l'infra T2 Live. Scoring EO 5 critères × /4. Phonologie live = 0 (TD-08, gelé). Contraintes héritées : pas de `speechConfig`. Livré : commits `868bd09` (code) + `3722e2a` (docs) ; dettes tracées TD-23/24/25 (cf. `TECH_DEBT-backend.md`).
-- **7b (frontend) ✅** : UI T1 Live — machine d'état T1 (8 états, `interrupted ⇄ presenting`), `useT1LiveSession` (WS `/t1/live`, sans message `context` post-Patch 7a, uplink coupé par ref pendant interruption), `T1PreparationPage` / `T1DialoguePage` / `T1SpeakingIndicator`, carte `EO_T1_LIVE` gatée Premium (`oral_t2_live`). Parcours simplifié carte → prépa → dialogue. `T1LiveQuestionnairePage` + `T1LiveContext` retirés. Réutilise les hooks audio T2 (FTD-44 gelée). **Bugs amont observés au test manuel** (hors contrôle frontend) : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
-
-## Sprint 7.5 — Clean
-
-- Factorisation des fichiers modifiés Sprint 7
-- Tests manuels Groupe D étendu (T1 Live) rejoués
-- Commit refactor(t1-live)
-
-## Sprint 7c — Migration audio EO Live : Gemini Live → Deepgram (T1 + T2)
-
-- Décision actée (**ADR-007**) de migrer l'architecture audio de Gemini Live natif vers une architecture découplée Deepgram (STT nova-3 + DeepSeek LLM + TTS Aura-2), suite à un POC concluant : transcription quasi parfaite à débit lent (vs confusions de langue/omissions Gemini), relances ancrées sur le discours réel du candidat, coût 4-5x inférieur.
-- Implémentation en deux temps : (1) construction du module Deepgram en parallèle de Gemini Live derrière un flag `EO_STT_PROVIDER=gemini|deepgram` pour comparaison en conditions réelles, (2) bascule complète + retrait du code Gemini Live (`geminiLive.ts`, usages `t1live.ts`/`t2live.ts`).
-- Résout FTD-45, FTD-46 (frontend, gelées avec renvoi vers cet ADR — cf. `TECH_DEBT.md`) et TD-22/TD-23 (backend, hors scope frontend — dépôt séparé).
-- Découpage fin (sous-sprints backend/frontend) à définir dans une session de planification dédiée avant exécution — non détaillé ici.
-
-## Sprint 7e — Transcription live à l'écran (T2 + T1)
-
-- Affichage incrémental temps réel des prises de parole pendant le dialogue : router `inputTranscription` + `outputTranscription` (déjà produits côté backend pour l'évaluation) jusqu'au frontend via le WebSocket, puis rendu progressif à l'écran.
-- Placé après le T1 Live pour couvrir **les deux modes live** d'un seul chantier.
-- **Chantier non trivial** (flux WS + affichage incrémental) — à décomposer en sous-étapes ; pas « cosmétique ».
-- **MAJ post-7a** : source backend de la transcription déjà disponible (confirmé par 7a).
-- **Caveat TD-23** : en VAD manuel, `inputTranscription` candidat n'est flushé qu'à `activityEnd` (pas token par token) → l'affichage incrémental temps réel n'est possible que pour `outputTranscription` (examinateur) ; l'incrémental côté candidat est à reconcevoir.
-- **Note (ajoutée 2026-07-02)** : à réévaluer/simplifier une fois la migration **Sprint 7c** (Deepgram, ADR-007) effectuée — le caveat TD-23 ci-dessus disparaît avec un provider nativement streaming. Ce sprint pourrait devenir substantiellement plus simple, voire trivial, une fois 7c terminé.
-
-## Sprint 8 — Mode Examen
-
-- Timer inarrêtable + readOnly à T=0
-
-## Sprint 8.5 — Clean
-
-- Factorisation des fichiers modifiés Sprint 8
-- Tests manuels Groupe D rejoués
-- Commit refactor(exam-mode)
-
-## Sprint 9 — Page Admin (outillage opérationnel)
-
-- **9a (backend)** : middleware auth admin (modèle de sécurité à trancher — cf. SECURITY.md) ; endpoint agrégation chiffres clés (inscrits, corrections jour/mois, abonnements actifs, waitlist) ; endpoint waitlist (liste + export CSV).
-- **9b (backend)** : CRUD sujets (liste + filtres mode·tâche·statut, create, update, toggle actif, delete) — réutilise le modèle de sujets existant, service role.
-- **9c (frontend)** : route admin protégée (hors navigation publique) + Dashboard chiffres clés (compteurs cliquables, refresh périodique).
-- **9d (frontend)** : module Gestion des sujets + module Waitlist (tableau + bouton Export CSV).
-
-## Sprint 9.5 — Clean
-
-- Factorisation des fichiers modifiés Sprint 9
-- Tests manuels Groupe H (admin) joués
-- Commit refactor(admin)
-
-## Sprint 10 — Paiement Orange Money (semi-manuel)
-
-- **10a (backend)** : migration Supabase `commandes_om` (RLS, accès service role) ; endpoint création de commande (code unique + insertion) ; job d'expiration via scheduler Render (pas de cron Vercel).
-- **10b (backend)** : endpoint d'activation → écrit le plan via le même chemin que le webhook Stripe (planController / source de vérité unique, ADR 005) — jamais d'écriture SQL directe du plan ; email de confirmation client.
-- **10c (frontend)** : page client `/paiement-om` (depuis `/plan`, lien WhatsApp pré-rempli) + ajout de l'option « Payer via Orange Money » sur la page plans.
-- **10d (frontend)** : module Commandes OM dans l'admin (onglets en attente / activées / expirées, bouton Activer, countdown, note interne).
-
-## Sprint 10.5 — Clean
-
-- Factorisation des fichiers modifiés Sprint 10
-- Tests manuels Groupe H étendu (flux OM complet) joués
-- Commit refactor(paiement-om)
-
-## Sprint 11 — Pré-lancement
-
-- MAINTENANCE_MODE implémenté ✅ (2026-04-19)
-- Sentry configuré
-- /ultrareview avant bascule
-- Smoke test Groupe Z complet
-- Procédure DEPLOYMENT.md exécutée
diff --git a/docs/TAXONOMIE_ERREURS.md b/docs/TAXONOMIE_ERREURS.md
deleted file mode 100644
index a1c5f0f..0000000
--- a/docs/TAXONOMIE_ERREURS.md
+++ /dev/null
@@ -1,309 +0,0 @@
-# TAXONOMIE_ERREURS.md — Expria V2
-
-> **Document de référence — Version 1.0**
-> Taxonomie fermée des erreurs détectables en expression écrite TCF Canada.
-> Utilisée par le prompt maître pour nommer les erreurs de façon stable et agrégeable.
-> Mise à jour après observation de nouveaux patterns en production.
->
-> **Principe :** DeepSeek doit obligatoirement choisir un code dans cette liste pour chaque erreur identifiée.
-> Si l'erreur ne correspond à aucun code existant, DeepSeek utilise le code `autre` du critère concerné
-> et fournit une description textuelle. Ces occurrences sont remontées pour enrichissement de la taxonomie.
-
----
-
-## Structure d'une erreur dans le rapport
-
-Chaque erreur retournée par le prompt maître doit respecter ce format :
-
-```json
-{
- "code": "virgule_exces",
- "critere": "competence_grammaticale",
- "description": "description libre si code=autre, null sinon"
-}
-```
-
----
-
-## Critère 1 — Adéquation à la tâche et au registre
-
-`critere: "adequation_tache"`
-
-### Contenu
-| Code | Description |
-|---|---|
-| `hors_sujet_total` | La production ne répond pas à la consigne |
-| `hors_sujet_partiel` | Un ou plusieurs points de la consigne sont ignorés |
-| `information_manquante` | Une information demandée explicitement dans la consigne est absente |
-| `enonce_copie` | Le candidat recopie l'énoncé au lieu de le reformuler |
-
-### Longueur
-| Code | Description |
-|---|---|
-| `longueur_insuffisante` | Sous le minimum de mots requis (score plafonné automatiquement) |
-| `longueur_excessive` | Au-dessus du maximum de mots requis |
-
-### Format
-| Code | Description |
-|---|---|
-| `format_non_respecte` | Type de texte non respecté (mail sans objet, blog sans accroche) |
-| `salutation_absente` | Pas de formule d'appel |
-| `cloture_absente` | Pas de formule de clôture ou de signature |
-| `structure_absente` | Texte bloc sans paragraphes |
-
-### Registre
-| Code | Description |
-|---|---|
-| `registre_trop_formel` | Registre soutenu alors que familier requis |
-| `registre_trop_familier` | Registre familier alors que formel requis |
-| `abreviations_sms` | Usage de "bjr", "svp", "stp" hors contexte très informel |
-| `tutoiement_inadequat` | Tutoiement quand vouvoiement requis, ou inversement |
-
-### Non couvert
-| Code | Description |
-|---|---|
-| `autre` | Erreur d'adéquation non couverte par la taxonomie — **description obligatoire** |
-
----
-
-## Critère 2 — Cohérence et cohésion du discours
-
-`critere: "coherence_cohesion"`
-
-### Structure
-| Code | Description |
-|---|---|
-| `introduction_absente` | Pas d'entrée en matière ou d'accroche |
-| `conclusion_absente` | Pas de clôture ou de phrase de synthèse |
-| `paragraphes_absents` | Texte bloc sans découpage en paragraphes |
-| `progression_illogique` | Les idées ne s'enchaînent pas dans un ordre logique |
-
-### Connecteurs
-| Code | Description |
-|---|---|
-| `connecteurs_absents` | Phrases juxtaposées sans lien logique |
-| `connecteurs_repetes` | Même connecteur utilisé en boucle (ex : "et" x5) |
-| `connecteurs_inadequats` | Connecteur utilisé à contresens (ex : "donc" pour introduire une cause) |
-| `connecteurs_insuffisants` | Connecteurs trop simples pour le niveau visé (uniquement "mais", "et", "car") |
-
-### Cohérence thématique
-| Code | Description |
-|---|---|
-| `idee_non_developpee` | Idée introduite puis abandonnée sans explication |
-| `repetition_idee` | Même idée reformulée plusieurs fois sans apport nouveau |
-| `contradiction_interne` | Deux affirmations contradictoires dans le même texte |
-| `hors_propos` | Phrase ou paragraphe sans lien avec le reste du texte |
-
-### Cohésion référentielle
-| Code | Description |
-|---|---|
-| `pronoms_ambigus` | "il", "elle", "ils" sans antécédent clair |
-| `substitution_absente` | Même mot répété au lieu d'utiliser un pronom ou un synonyme |
-| `rupture_temporelle` | Mélange incohérent des temps dans le récit |
-
-### Non couvert
-| Code | Description |
-|---|---|
-| `autre` | Erreur de cohérence/cohésion non couverte par la taxonomie — **description obligatoire** |
-
----
-
-## Critère 3 — Compétence lexicale
-
-`critere: "competence_lexicale"`
-
-### Étendue du vocabulaire
-| Code | Description |
-|---|---|
-| `vocabulaire_basique` | Mots trop simples pour le niveau visé (ex : "bien" au lieu de "remarquable") |
-| `vocabulaire_insuffisant` | Manque de mots pour exprimer une idée, recours à des périphrases maladroites |
-| `registre_lexical_inadequat` | Mots familiers dans un contexte formel, ou inversement |
-
-### Précision
-| Code | Description |
-|---|---|
-| `mot_imprecis` | Mot approximatif (ex : "faire" au lieu de "effectuer", "réaliser", "accomplir") |
-| `contresens_lexical` | Mot utilisé dans un sens erroné |
-| `anglicisme` | Mot anglais utilisé à la place du mot français (ex : "checker" au lieu de "vérifier") |
-| `calque_syntaxique` | Construction calquée sur une autre langue |
-
-### Variété
-| Code | Description |
-|---|---|
-| `repetition_lexicale` | Même mot répété excessivement dans le texte |
-| `synonymes_absents` | Absence de variation lexicale sur un même champ sémantique |
-| `expressions_figees_absentes` | Absence d'expressions idiomatiques attendues au niveau visé |
-
-### Orthographe lexicale
-| Code | Description |
-|---|---|
-| `faute_orthographe_courante` | Erreur sur un mot courant (ex : "apelle" au lieu de "appelle") |
-| `confusion_homophones` | "sa"/"ça", "a"/"à", "ou"/"où", "ce"/"se", "on"/"ont" |
-| `majuscules_incorrectes` | Majuscule absente ou mal placée |
-
-### Non couvert
-| Code | Description |
-|---|---|
-| `autre` | Erreur lexicale non couverte par la taxonomie — **description obligatoire** |
-
----
-
-## Critère 4 — Compétence grammaticale
-
-`critere: "competence_grammaticale"`
-
-### Accords
-| Code | Description |
-|---|---|
-| `accord_sujet_verbe` | "les enfants joue" au lieu de "jouent" |
-| `accord_adjectif_nom` | "une révolution positif" au lieu de "positive" |
-| `accord_participe_passe` | "elle est parti" au lieu de "partie" |
-| `accord_determinant_nom` | "un table" au lieu de "une table" |
-
-### Conjugaison
-| Code | Description |
-|---|---|
-| `temps_verbal_inadequat` | Présent au lieu de passé composé, futur au lieu de conditionnel |
-| `subjonctif_absent` | Indicatif utilisé là où le subjonctif est requis |
-| `subjonctif_incorrect` | Subjonctif utilisé mais mal formé |
-| `conditionnel_absent` | Conditionnel requis mais absent (politesse, hypothèse) |
-| `concordance_temps` | Incohérence des temps dans un même passage |
-
-### Syntaxe
-| Code | Description |
-|---|---|
-| `phrase_incomplete` | Phrase sans verbe conjugué ou sans sujet |
-| `phrase_trop_longue` | Phrase surchargée, incompréhensible |
-| `ordre_mots_incorrect` | "je ne sais pas où est-il" au lieu de "où il est" |
-| `subordination_absente` | Phrases simples juxtaposées là où une subordonnée est attendue |
-| `subordination_incorrecte` | Connecteur de subordination mal utilisé |
-
-### Ponctuation
-| Code | Description |
-|---|---|
-| `virgule_exces` | Virgules à outrance qui coupent le flux naturel |
-| `virgule_absence` | Absence de virgule là où elle est requise |
-| `point_absent` | Phrases non délimitées, texte continu sans point |
-| `ponctuation_incorrecte` | Usage erroné de ";" ":" "!" "?" |
-
-### Prépositions
-| Code | Description |
-|---|---|
-| `preposition_absente` | "je pense que c'est important aller" au lieu de "d'aller" |
-| `preposition_incorrecte` | "je rêve à partir" au lieu de "de partir" |
-| `preposition_superflue` | Préposition ajoutée inutilement |
-
-### Morphologie
-| Code | Description |
-|---|---|
-| `genre_incorrect` | "la problème" au lieu de "le problème" |
-| `nombre_incorrect` | Pluriel absent ou mal formé |
-| `negation_incomplete` | "je sais pas" au lieu de "je ne sais pas" |
-
-### Non couvert
-| Code | Description |
-|---|---|
-| `autre` | Erreur grammaticale non couverte par la taxonomie — **description obligatoire** |
-
----
-
-## Règles d'utilisation pour DeepSeek
-
-1. **Chaque erreur identifiée dans un rapport doit avoir un code de cette liste.**
-2. **Un seul code par erreur** — choisir le plus précis.
-3. **Le code `autre` est autorisé** mais exige une `description` textuelle non nulle.
-4. **Les codes `autre` observés en production** sont remontés à Hermann pour décision d'intégration.
-5. **La détection de patterns** (analyse multi-productions) agrège les codes — un pattern est confirmé si le même code apparaît dans ≥ 3 productions sur les 5 dernières.
-
----
-
-## Procédure d'enrichissement
-
-Quand un code `autre` revient ≥ 3 fois en production :
-
-1. Hermann identifie le pattern dans les logs
-2. Un nouveau code est proposé et validé
-3. `TAXONOMIE_ERREURS.md` est mis à jour (bump de version)
-4. Le prompt maître est mis à jour dans le même commit
-5. Les anciennes entrées `autre` concernées sont reclassifiées si possible
-
----
-
-## Index des codes (référence rapide)
-
-| Code | Critère |
-|---|---|
-| `hors_sujet_total` | adequation_tache |
-| `hors_sujet_partiel` | adequation_tache |
-| `information_manquante` | adequation_tache |
-| `enonce_copie` | adequation_tache |
-| `longueur_insuffisante` | adequation_tache |
-| `longueur_excessive` | adequation_tache |
-| `format_non_respecte` | adequation_tache |
-| `salutation_absente` | adequation_tache |
-| `cloture_absente` | adequation_tache |
-| `structure_absente` | adequation_tache |
-| `registre_trop_formel` | adequation_tache |
-| `registre_trop_familier` | adequation_tache |
-| `abreviations_sms` | adequation_tache |
-| `tutoiement_inadequat` | adequation_tache |
-| `introduction_absente` | coherence_cohesion |
-| `conclusion_absente` | coherence_cohesion |
-| `paragraphes_absents` | coherence_cohesion |
-| `progression_illogique` | coherence_cohesion |
-| `connecteurs_absents` | coherence_cohesion |
-| `connecteurs_repetes` | coherence_cohesion |
-| `connecteurs_inadequats` | coherence_cohesion |
-| `connecteurs_insuffisants` | coherence_cohesion |
-| `idee_non_developpee` | coherence_cohesion |
-| `repetition_idee` | coherence_cohesion |
-| `contradiction_interne` | coherence_cohesion |
-| `hors_propos` | coherence_cohesion |
-| `pronoms_ambigus` | coherence_cohesion |
-| `substitution_absente` | coherence_cohesion |
-| `rupture_temporelle` | coherence_cohesion |
-| `vocabulaire_basique` | competence_lexicale |
-| `vocabulaire_insuffisant` | competence_lexicale |
-| `registre_lexical_inadequat` | competence_lexicale |
-| `mot_imprecis` | competence_lexicale |
-| `contresens_lexical` | competence_lexicale |
-| `anglicisme` | competence_lexicale |
-| `calque_syntaxique` | competence_lexicale |
-| `repetition_lexicale` | competence_lexicale |
-| `synonymes_absents` | competence_lexicale |
-| `expressions_figees_absentes` | competence_lexicale |
-| `faute_orthographe_courante` | competence_lexicale |
-| `confusion_homophones` | competence_lexicale |
-| `majuscules_incorrectes` | competence_lexicale |
-| `accord_sujet_verbe` | competence_grammaticale |
-| `accord_adjectif_nom` | competence_grammaticale |
-| `accord_participe_passe` | competence_grammaticale |
-| `accord_determinant_nom` | competence_grammaticale |
-| `temps_verbal_inadequat` | competence_grammaticale |
-| `subjonctif_absent` | competence_grammaticale |
-| `subjonctif_incorrect` | competence_grammaticale |
-| `conditionnel_absent` | competence_grammaticale |
-| `concordance_temps` | competence_grammaticale |
-| `phrase_incomplete` | competence_grammaticale |
-| `phrase_trop_longue` | competence_grammaticale |
-| `ordre_mots_incorrect` | competence_grammaticale |
-| `subordination_absente` | competence_grammaticale |
-| `subordination_incorrecte` | competence_grammaticale |
-| `virgule_exces` | competence_grammaticale |
-| `virgule_absence` | competence_grammaticale |
-| `point_absent` | competence_grammaticale |
-| `ponctuation_incorrecte` | competence_grammaticale |
-| `preposition_absente` | competence_grammaticale |
-| `preposition_incorrecte` | competence_grammaticale |
-| `preposition_superflue` | competence_grammaticale |
-| `genre_incorrect` | competence_grammaticale |
-| `nombre_incorrect` | competence_grammaticale |
-| `negation_incomplete` | competence_grammaticale |
-
----
-
-## Historique de ce document
-
-| Version | Date | Changements |
-|---|---|---|
-| 1.0 | 2026-04-22 | Création initiale — 4 critères, 63 codes + 4 codes `autre` |
diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md
index 9ea64b8..315c96f 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.32**
+> **Document de référence — Version 1.2**
> 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.
>
@@ -24,12 +24,10 @@ Pour éviter que ce document devienne un cimetière de dette ignorée (le piège
## 1. Dettes héritées de l'audit backend (2026-04-17)
### FTD-01 — Inconsistance des codes de validation côté backend
-
**Priorité :** 🟡 Important
**Statut :** Ouvert — dépend du backend
**Estimation de session :** 2h (backend uniquement)
**Description :** Le backend utilise deux codes d'erreur pour la même classe (corps de requête invalide) :
-
- `VALIDATION_ERROR` dans `routes/simulations.ts` et `routes/corrections.ts`
- `INVALID_BODY` dans `routes/plans.ts` et `routes/stripe.ts`
@@ -43,7 +41,6 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma
---
### FTD-02 — Header `X-API-Version` envoyé mais non vérifié
-
**Priorité :** 🟡 Important
**Statut :** Ouvert
**Estimation de session :** 1h (backend) + 30min (frontend)
@@ -52,7 +49,6 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma
**Impact :** si le backend évolue de façon breaking (ex : format de réponse de `/plans/status` modifié), le frontend peut recevoir un payload incompatible sans message d'erreur clair. Symptôme : bugs silencieux en production après un déploiement backend.
**À faire :**
-
- Backend : ajouter un middleware qui lit `X-API-Version`, le log, et retourne `HTTP 426 Upgrade Required` avec code `API_VERSION_MISMATCH` si breaking change
- Frontend : gérer `API_VERSION_MISMATCH` dans `api-client.ts` → afficher un message "Une nouvelle version est disponible, veuillez rafraîchir la page"
@@ -61,16 +57,13 @@ Le frontend gère les deux de la même manière (voir `ARCHITECTURE.md` §5), ma
---
### FTD-03 — Quirk `status` dans le body des erreurs de simulations/corrections
-
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — dépend du backend
**Estimation de session :** 1h (backend uniquement)
**Description :** Les routes `POST /simulations` et `POST /corrections/ee,eo` renvoient un champ `status` dans le body JSON d'erreur, qui duplique le code HTTP :
-
```json
{ "error": true, "code": "QUOTA_REACHED", "message": "...", "status": 403 }
```
-
Vient du pattern `c.json(result, result.status)` où `result` contient déjà `status`. C'est ignorable côté frontend (on ne lit pas ce champ), mais c'est du bruit.
**À faire côté backend :** nettoyer les objets d'erreur retournés par `simulationController` et `correctionController` pour ne pas contenir de champ `status`. Tracé dans **TD-16** à créer dans `expria-backend/docs/TECH_DEBT.md`.
@@ -81,8 +74,38 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
## 2. Dettes frontend propres
-### FTD-10 — Semgrep non intégré en CI
+### FTD-04 — Documents miroir sans automatisation de synchronisation
+**Priorité :** 🟢 Mineur
+**Statut :** Ouvert — accepté par design (voir ADR 004)
+**Estimation de session :** 1 jour (mise en place monorepo)
+**Description :** Les documents `PLANS_TARIFAIRES.md`, `PARCOURS_UTILISATEURS.md`, et le fichier de code `src/entities/user/access.ts` existent à l'identique dans les deux dépôts (frontend et backend). La synchronisation est manuelle — si un changement est fait dans un dépôt sans être répercuté dans l'autre, divergence silencieuse.
+**Mitigation actuelle :**
+- Règle G de `DEVELOPMENT_PRINCIPLES.md` (modifications simultanées dans le même commit logique)
+- Commentaire `// SOURCE OF TRUTH:` en tête de `access.ts`
+- Tests de parité dans `src/entities/user/__tests__/access.test.ts` (calqués sur les tests backend)
+
+**À faire si la dette devient trop coûteuse :**
+- Migrer vers un monorepo pnpm workspaces avec package partagé `@expria/types-and-access`
+- OU ajouter un script CI qui vérifie que le hash SHA-256 de `access.ts` matche entre les deux dépôts
+
+**Condition de résolution :** après 3+ mois de production stable, ou si une divergence silencieuse cause un bug.
+
+---
+
+### FTD-05 — Ancien scaffold frontend possiblement caduc
+**Priorité :** 🟡 Important
+**Statut :** Ouvert — diagnostic en cours (session Claude Code)
+**Estimation de session :** variable selon diagnostic
+**Description :** Un scaffold frontend a été créé au démarrage du projet (fin mars 2026 ou début avril 2026), avant que les décisions architecturales récentes (entities/features/shared, auth-client/api-client découplés, pas de Zustand, etc.) ne soient prises. Le contenu actuel de `D:\expria-frontend\` peut donc contenir des fichiers qui ne matchent plus l'architecture cible.
+
+**À faire :** session Claude Code (première session frontend de la V2) qui fait un état des lieux complet et propose une stratégie (clean slate / refactor progressif / adaptation en place). Cf. prompt de session dans l'historique de conversation Claude AI du 2026-04-17.
+
+**Condition de résolution :** fin du Sprint 0 (scaffold conforme à `ARCHITECTURE.md`).
+
+---
+
+### FTD-10 — Semgrep non intégré en CI
**Priorité :** 🟢 Mineur
**Statut :** Reporté — après MVP
**Estimation de session :** 2h
@@ -91,7 +114,6 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
**Impact actuel :** `npm audit` couvre les vulnérabilités des dépendances npm, mais aucune analyse statique de sécurité (SAST) n'est faite sur le code custom du projet. Des patterns dangereux (`eval`, `innerHTML` sans DOMPurify, secrets en dur, etc.) passeraient inaperçus en CI.
**À faire :**
-
- Ajouter un step Semgrep au workflow `.github/workflows/ci.yml`
- Utiliser les rulesets `auto` + `r2c-security-audit` + `r2c-ci`
- Configurer la sortie pour bloquer sur sévérité ERROR uniquement
@@ -101,321 +123,38 @@ Vient du pattern `c.json(result, result.status)` où `result` contient déjà `s
---
-> 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.
-
----
-
-### FTD-30 — Rotation token Deepgram sans grace period
-
+### FTD-11 — `@theme` Tailwind 4 non défini
**Priorité :** 🟢 Mineur
-**Statut :** Gelé — Sprint 4c-3 (Deepgram live mis en pause au profit de Gemini batch backend)
-**Estimation de session :** 0,5 jour
-**Description :** `useDeepgramLive` redemande un token à T-60 s avant expiration et hot-swap la WebSocket. Si la nouvelle échoue à s'ouvrir avant l'expiration, des chunks peuvent être perdus. **Code dormant depuis le Sprint 4c-3** — à ré-évaluer si Deepgram live est réactivé (cf. FTD-37).
-**À faire :** retry policy explicite + maintien de l'ancienne connexion tant que la nouvelle n'a pas reçu son premier message. Hors scope tant que le hook reste dormant.
+**Statut :** Ouvert — à faire dans session design system
+**Estimation de session :** 1 jour (palette + typo + itérations design)
+**Description :** `src/index.css` a été nettoyé à l'étape 11 du Sprint 0 et réduit à la seule ligne `@import 'tailwindcss';`. L'ADR 006 (§Configuration Tailwind 4) décrit le bloc `@theme { ... }` comme le mécanisme officiel de configuration Tailwind 4 (CSS-first) :
----
+```css
+@theme {
+ --color-primary: #1B4FD8;
+ --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
+}
+```
-### FTD-31 — Page `EnregistrementEOPage` non resumable au refresh
+La palette brand Expria et la typographie ne sont pas encore décidées, donc `@theme` est volontairement absent pour ne pas poser de valeurs placeholder qu'il faudrait repasser plus tard.
-**Priorité :** 🟢 Mineur
-**Statut :** Ouvert — introduit au Sprint 4c-1
-**Estimation de session :** 0,5 jour
-**Description :** Si l'utilisateur ferme l'onglet ou recharge la page pendant l'enregistrement, le transcript live et l'audio sont perdus. La simulation côté backend reste avec `rapport=null` mais sans contenu textuel : au resume, le provider redirige vers `/simulation/eo/pre-enregistrement` et l'utilisateur doit recommencer.
-**À faire :** persister un buffer du transcript final dans `localStorage` à chaque `is_final=true`, restaurer au resume comme point de départ. Décider si on autorise la reprise « par-dessus » ou si on impose un nouveau départ.
-**Condition de résolution :** session dédiée autosave EO post-MVP.
+**Impact actuel :** les composants utilisent les couleurs Tailwind par défaut (`bg-slate-*`, `text-gray-*`). Visuellement cohérent mais pas brand.
----
+**À faire au Sprint 1 (design system) :**
+- Définir la palette brand Expria (primary, secondary, neutrals, danger, success)
+- Choisir + installer la typo (Plus Jakarta Sans ou autre) via ` ` dans `index.html` ou `@import` dans `index.css`
+- Ajouter le bloc `@theme` dans `src/index.css`
+- Mettre à jour ADR 006 avec les valeurs retenues
-### FTD-32 — `useAudioRecorder` non testé sur Safari iOS
-
-**Priorité :** 🟢 Mineur
-**Statut :** Ouvert — introduit au Sprint 4c-1
-**Estimation de session :** 0,5 jour
-**Description :** `pickMimeType()` propose un fallback `audio/mp4` pour Safari, mais aucun test manuel n'a été réalisé. Le bouton « Télécharger l'audio » nomme toujours le fichier `.webm` même quand le mime réel est `audio/mp4`.
-**À faire :** validation manuelle iOS, adapter l'extension du fichier téléchargé au mime réel via `audioMimeType`.
-**Condition de résolution :** une fois la version iPhone validée par un testeur réel.
-
----
-
-### FTD-34 — Présentation T1 stockée en clair dans `localStorage`
-
-**Priorité :** 🟢 Mineur
-**Statut :** Ouvert — introduit au Sprint 4c-2
-**Estimation de session :** 0,5 jour
-**Description :** `expria_eo_t1_presentation` contient le texte de la présentation personnelle de l'utilisateur (prénom, âge, ville, parcours, situation familiale, projet d'immigration). Stocké en clair, accessible à tout script tiers exécuté dans le contexte du domaine. Acceptable au MVP : aucune donnée sensible au sens RGPD strict (pas de mot de passe ni numéro fiscal), mais le contenu reste personnel.
-**À faire :** chiffrement AES-GCM avec clé dérivée du JWT Supabase, ou bascule vers IndexedDB chiffré (libs : `idb-keyval` + `Web Crypto API`). Étendre à toute persistance sensible si on en ajoute (transcripts, audio, etc.).
-**Condition de résolution :** quand on stocke un jour des contenus plus sensibles via le même mécanisme.
-
----
-
-> FTD-35 fermée au Sprint 5.5 (2026-04-26) — subsumée par FTD-41. Voir §5 Historique.
-
----
-
-### FTD-36 — Upload audio base64 in-memory sans indicateur de progression
-
-**Priorité :** 🟡 Important
-**Statut :** Ouvert — introduit au Sprint 4c-3
-**Estimation de session :** 1 jour
-**Description :** `EnregistrementEOPage` encode le Blob audio en base64 via `FileReader.readAsDataURL` puis envoie le résultat dans le body JSON de `POST /corrections/eo`. Pour 6 minutes d'audio webm/Opus à 32 kbps ≈ 1,5 Mo binaire ≈ 2 Mo base64. Reste sous le cap 14 Mo backend, mais : (a) tout est chargé en mémoire navigateur, (b) aucun indicateur de progression d'upload (le banner « Transcription et correction en cours » couvre les ~30-60 s totales sans distinguer upload/Gemini/DeepSeek), (c) retry impossible côté navigateur si la connexion mobile coupe en cours d'upload.
-**À faire :** passer à `multipart/form-data` avec `XMLHttpRequest.upload.onprogress` ou `fetch` + `ReadableStream` ; afficher une barre de progression upload distincte de l'état serveur.
-**Condition de résolution :** observer un cas réel de plantage mobile/edge OU avant ouverture publique.
-
----
-
-### FTD-37 — Code Deepgram live dormant à trancher
-
-**Priorité :** 🟢 Mineur
-**Statut :** Ouvert — introduit au Sprint 4c-3
-**Estimation de session :** 1 jour (réactivation) ou 0,5 jour (suppression)
-**Description :** Sprint 4c-3 a basculé la transcription EO sur Gemini batch côté backend. Les artefacts Deepgram live restent en place mais sans consommateur :
-
-- Frontend : `useDeepgramLive`, `TranscriptionDisplay`, `entities/transcription/api.ts` + tests associés
-- Backend : route `POST /transcriptions/token`, `lib/deepgram.ts` + tests associés
- **Décision de garde :** conservés 30 jours après la mise en prod du Sprint 4c-3 puis on tranche. Soit (a) réactivation pour réduire la latence perçue (transcription live pendant l'enregistrement vs attente serveur après stop), soit (b) suppression définitive si le retour utilisateur sur la latence Gemini est acceptable.
- **À faire :** trancher au plus tard 30 jours après la première mise en prod de cette session.
-
----
-
-> FTD-38, FTD-39 résolus au Sprint 5.5 (2026-04-26) — voir §5 Historique des résolutions.
-
----
-
-### FTD-40 — Conclusion rapport incohérente quand NCLC atteint > cible (backend)
-
-**Priorité :** 🟡 Important
-**Statut :** Ouvert — patch frontend temporaire en place (Sprint 4.5)
-**Estimation de session :** 1h (session backend)
-**Description :** Le prompt maître DeepSeek génère toujours un message d'encouragement vers l'objectif cible, même quand `nclcObtenu > nclcCible`. Le champ `conseil_nclc.action_prioritaire` contient alors un texte incohérent (« tu atteindras facilement le niveau 9 » pour un candidat NCLC 10). Patch frontend en place dans [ConseilNclcCallout.tsx](../src/features/simulations/components/rapport/ConseilNclcCallout.tsx) (condition `depasse` → texte générique). Fix robuste : modifier le prompt maître backend pour détecter `nclcObtenu > nclcCible` et générer un message de maintien/progression vers NCLC suivant.
-
-**Condition de résolution :** prompt backend mis à jour + patch frontend retiré.
-
----
-
-### FTD-41 — Persistance présentation EO T1 en base de données
-
-**Priorité :** 🔴 Critique
-**Statut :** Ouvert — localStorage instable (FTD-35 partiellement liée)
-**Estimation de session :** 1 jour (session fullstack)
-**Description :** La présentation générée pour EO T1 est stockée uniquement en localStorage (`expria_eo_t1_presentation`). Au refresh, une redirect prématurée dans `PresentationGenereeT1Page` (`shouldRedirect` déclenché avant hydratation async complète) efface la session. Solution retenue : nouvelle colonne `presentation_t1` (TEXT, nullable) sur la table `productions` + `PATCH /simulations/:id/presentation` + bouton « Sauvegarder » explicite dans `PresentationGenereeT1Page`. Le localStorage devient brouillon temporaire uniquement. Résout FTD-35.
-
-**Condition de résolution :** migration DB + endpoint backend + bouton frontend implémentés et testés.
-
----
-
-> FTD-42 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
-
----
-
-### FTD-43 — Race condition webhook post-redirect Stripe Checkout
-
-**Priorité :** 🟢 Mineur
-**Statut :** Ouvert — introduit Sprint 5c (2026-04-26)
-**Estimation de session :** 0,5 jour
-**Description :** Après un Stripe Checkout réussi, le frontend revient sur `/dashboard?upgrade=success`. `useUpgradeSuccessHandler` invalide `PLAN_QUERY_KEY` immédiatement. Mais le webhook `checkout.session.completed` peut arriver côté backend **après** le redirect frontend (latence réseau Stripe → Render typiquement 1-3 s). Si l'invalidation refetch trop tôt, `usePlan()` retourne encore l'ancien plan. L'utilisateur voit son ancien plan dans le dashboard pendant quelques secondes.
-
-**Mitigation actuelle :** `UpgradeSuccessBanner` affiche explicitement « Si certaines features semblent manquer, rafraîchissez la page dans quelques secondes ». Acceptable au MVP.
-
-**À faire si remonté en prod :**
-
-- Polling court : refetch automatique de `usePlan()` toutes les 2 s pendant 30 s tant que le plan reçu === ancien plan.
-- OU : ajouter un endpoint `GET /plans/status?wait_for_change=true` côté backend qui long-poll jusqu'au changement (max 10 s) et retourne le nouveau plan.
-- OU : Stripe envoie un event WebSocket via Pusher / SSE — out of scope MVP.
-
-**Condition de résolution :** observer en production. Si > 5 % des upgrades produisent un message support, prioriser.
-
----
-
-> FTD-33 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
-
----
-
-### FTD-24 — Pas de polling automatique pour exercices / modèle `pending`
-
-**Priorité :** 🟡 Important
-**Statut :** Résolu — 2026-04-23
-**Estimation de session :** 2h
-**Description :** Après soumission d'une correction EE, le backend génère la correction en bloquant (jusqu'à 45 s), puis retourne 200 dès que la correction est prête. Les jobs `modele` et `exercices` (fire-and-forget côté backend) peuvent mettre 10-30 s supplémentaires après la réponse HTTP. Pendant ce temps, `exercices_status` et `modele_status` valent `'pending'` côté `GET /simulations/:id`. Côté frontend, `RapportPage` affiche un `JobStatusFallback` invitant l'utilisateur à **rafraîchir manuellement** la page pour voir les résultats.
-
-**Impact UX :** l'utilisateur voit le rapport principal immédiatement, mais doit recharger pour voir ses exercices + production modèle. Expérience acceptable en MVP mais sous-optimale.
-
-**À faire :**
-
-- Hook `useRapport` : déclencher un polling automatique via TanStack Query `refetchInterval: 3000` si `exercices_status === 'pending' || modele_status === 'pending'`.
-- Arrêt du polling dès que les deux statuts sortent de `'pending'` (ready ou error).
-- Afficher un indicateur visuel discret pendant le polling actif (petit spinner dans JobStatusFallback).
-- Timeout de polling : max 2 minutes → message "La génération prend plus de temps que prévu" + bouton Réessayer.
-
-**Lien avec TD-15 backend :** si le process backend redémarre pendant un job, le statut reste indéfiniment `'pending'`. Le timeout frontend atténue ce problème côté UX (on arrête de poller après 2 min).
-
-**Condition de résolution :** après Sprint 3.6c (patterns) si la patience utilisateur devient un frein.
-
----
-
-### FTD-23 — `useAutosave` continue après correction → 400 VALIDATION_ERROR
-
-**Priorité :** 🟡 Important
-**Statut :** Résolu — 2026-04-23
-**Estimation de session :** 30 min
-**Description :** Le hook `useAutosave` (cf. `src/features/simulations/hooks/useAutosave.ts`) peut déclencher un `PATCH /simulations/:id/contenu` après que la correction a été persistée (colonne `rapport !== null`). Le backend refuse alors avec `400 VALIDATION_ERROR` message « Cette simulation a déjà été corrigée. » (cf. `simulationController.autosaveContenu` backend lignes 248-255).
-
-**Scénario déclencheur :**
-
-1. L'utilisateur soumet sa production → `rapport` persisté côté backend.
-2. `SimulationForm` passe `step` à `'done'`, mais :
- - Le timer d'autosave debouncé (30 s) peut encore fire après cette transition si le debounce n'est pas clear.
- - Un `beforeunload` handler peut déclencher un `flush()` final même une fois la correction reçue.
-3. `useAutosave.enabled` est calculé comme `!isSubmitting` dans `SimulationForm` — il redevient `true` après la correction (quand `isSubmitting` repasse à `false`).
-
-**À faire :**
-
-- Propager `enabled = !isSubmitting && step !== 'done' && step !== 'correcting'` depuis `SimulationForm`
-- OU : au montage, quand `rapport` devient non null après correction, clear le timeout debouncé et retirer le handler `beforeunload` immédiatement.
-- Ajouter un test regression dans `useAutosave.test.ts` qui vérifie qu'aucun `autosaveContenu` n'est appelé après `step='done'`.
-
-**Impact actuel :** erreur 400 dans les DevTools Network uniquement (pas d'impact UX — le texte est déjà corrigé, la sauvegarde n'est plus nécessaire). Pollue les logs frontend et backend.
-
-**Condition de résolution :** session dédiée — ne bloque pas le Sprint 3.6b.
-
----
-
-### FTD-25 — Mise à jour ARCHITECTURE.md §3 (arborescence réelle)
-
-**Priorité :** 🟢 Mineur
-**Statut :** Résolu — 2026-04-25
-**Estimation de session :** 1h
-**Description :** ARCHITECTURE.md §3 ne liste pas `entities/patterns`, `features/historique`, `features/progression`, `features/design-system` (ajoutés aux Sprints 3.6c et 3.7). Les composants layout (`AppLayout`, `Sidebar`, `MobileHeader`, `BottomNav`, `MaintenancePage`) sont dans `app/` alors que §3 ne prévoit que `providers`, `router`, `main` dans ce dossier.
-
-**À faire :**
-
-- Mettre à jour ARCHITECTURE.md §3 pour refléter l'arborescence réelle.
-- Formaliser `app/` comme contenant entry points + composants layout de la coquille OU déplacer vers `shared/components/layout/`.
-
-**Condition de résolution :** ARCHITECTURE.md §3 reflète l'arborescence réelle.
-
----
-
-### FTD-26 — Clarifier cohabitation `shared/ui/` vs `shared/components/ui/`
-
-**Priorité :** 🟡 Important
-**Statut :** Résolu — 2026-04-25
-**Estimation de session :** 2h
-**Description :** Deux conventions UI cohabitent sans documentation :
-
-- `src/shared/ui/{Button,Card,Badge}.tsx` (PascalCase) — wrappers Expria, 40+ imports dans les features.
-- `src/shared/components/ui/{button,dialog,input,…}.tsx` (kebab-case) — primitives shadcn/ui, 7 fichiers consommateurs.
-
-Risque : confusion pour un futur dev sur quel composant utiliser.
-
-**À faire :** documenter la convention dans ARCHITECTURE.md (distinction wrappers Expria / primitives shadcn) **OU** regrouper sous un seul dossier (ex. `shared/components/ui/primitives/` + `shared/components/ui/expria/`).
-
-**Condition de résolution :** un seul pattern documenté et appliqué.
-
----
-
-### FTD-47 — Sessions T1 Live non taguées dans l'historique
-
-**Priorité :** 🟢 Mineur
-**Statut :** Ouvert — introduit Sprint 7a (backend), découvert Sprint 7.5
-**Estimation de session :** à évaluer (cross-repo backend + frontend)
-**Description :** Contrairement à T2 Live (`tache='EO_T2_LIVE'`, tag « T2 Live » visible dans l'historique), le backend T1 Live persiste `tache='EO_T1'` / `mode='entrainement'` (`t1live.ts`) — identique à une simulation T1 batch classique. Aucun champ ou tag ne permet de distinguer une session dialoguée en direct avec l'examinateur IA d'un simple enregistrement offline. Vérifié dans `historique.ts` (`TACHE_NUMBER`) et `RapportPage.tsx` : aucun tag « Live » n'existe côté frontend pour T1.
-
-**Impact :** l'utilisateur ne peut pas retrouver ses sessions T1 Live dans son historique — confusion possible, et T1 Live (feature Premium différenciante) perd sa visibilité propre.
-
-**À faire :** décision d'architecture — nouvelle valeur `tache` (ex. `EO_T1_LIVE`, migration cross-repo comme T2) ou champ booléen dédié (`is_live`). Nécessite une session backend + frontend coordonnée. Hors scope Sprint 7.5 (doc-only).
-
-**Session concernée :** découverte Sprint 7.5, racine Sprint 7a.
-
-**Note :** la racine du problème est backend (`t1live.ts`). Une entrée miroir dans `expria-backend/docs/TECH_DEBT-backend.md` serait pertinente mais n'a pas été créée (hors scope repo de cette session).
-
-**Condition de résolution :** décision d'architecture prise + migration cross-repo implémentée.
+**Condition de résolution :** Sprint 1 — session dédiée au design system.
---
## 3. Fonctionnalités reportées
-### FTD-07 — Sentry non intégré
-
-**Priorité :** 🟡 Important
-**Statut :** Planifié — après MVP
-**Estimation de session :** 3h
-**Description :** Le monitoring frontend (erreurs JS, performances, sessions) n'est pas encore en place. Sans Sentry (ou équivalent), les bugs en production ne remontent pas — on les découvre uniquement si un utilisateur prend la peine de les signaler.
-
-**À faire :**
-
-- Créer un compte Sentry (tier gratuit suffit pour démarrer)
-- Ajouter `@sentry/react` au projet
-- Intégrer dans `src/app/providers.tsx`
-- Ajouter `VITE_SENTRY_DSN` dans les variables d'environnement
-- Configurer le filtrage des données sensibles (JWT, emails)
-- Mettre à jour SEC-11 dans `SECURITY.md`
-
-**Condition de résolution :** avant la première vague d'utilisateurs post-MVP (30 jours après lancement).
-
----
-
-### FTD-21 — Persistance session simulation
-
-**Priorité :** 🔴 Critique
-**Statut :** Partiellement résolu — `/simulation/ee` ✅ (2026-04-21)
-
-**Pages concernées par ordre de priorité :**
-
-✅ **`/simulation/ee`** (résolu 2026-04-21)
-
-- Autosave contenu toutes les 30 s (`useAutosave`)
-- Save on `beforeunload`
-- Reprise au refresh via `localStorage` (`expria_simulation_id`) + `GET /simulations/:id`
-- `PATCH /simulations/:id/contenu` + `PATCH /simulations/:id/sujet` (Option C)
-- `getById` tolère `rapport=null` (Option A)
-- `RapportPage` redirige vers `/simulation/ee` si simulation en cours
-
-🟡 **`/simulation/eo`** (Sprint 4 — ouvert)
-
-- Identique EE + état audio/enregistrement
-
-🟡 **`/examen`** (Sprint 7 — ouvert)
-
-- Autosave critique — timer inarrêtable + 3 tâches
-- Crash pendant examen = perte totale
-
-🟢 **`/sujets`** (inclus dans la résolution EE)
-
-- `localStorage simulation_id` suffit
-- Pas d'autosave (pas de données saisies)
-
-❌ **Pas nécessaire :** `/dashboard`, `/rapport/:id`, `/historique`, `/progression`
-
-**Résolution EE livrée (2026-04-21) :**
-
-Backend :
-
-- `simulationController.create` persiste `sujet_id` à la création
-- `getById` retourne `SimulationState` (tolère `rapport=null` pour resume)
-- `autosaveContenu` + `updateSujet` controllers (refuse si `rapport !== null`)
-- Routes `PATCH /simulations/:id/contenu` + `PATCH /simulations/:id/sujet`
-- CORS : `allowMethods` étendu à PATCH/PUT/DELETE
-
-Frontend :
-
-- `useAutosave` : debounce 30 s + `beforeunload` flush + dedup par contenu
-- `SimulationForm` : hydrate `initialContenu`, affiche "Sauvegardé à HH:MM"
-- `SimulationFlowProvider` : hydratation au montage depuis `localStorage` → restaure step `task-selected` si rapport null, nettoie sinon
-- `getReport` délègue à `getSimulationState` et throw `REPORT_NOT_READY` si rapport null
-
-**Condition de résolution complète :** intégration EO (Sprint 4) + examen (Sprint 7).
-
----
-
-## 3bis. Backlog gelé — post-MVP
-
-> Ces FTDs sont volontairement gelées : elles concernent des fonctionnalités non encore livrées (T2 Live, tests E2E) ou du confort utilisateur non bloquant (option `'system'` thème). Elles **ne comptent pas dans le cap de 15 FTD actives** et seront réactivées quand leur sprint arrive ou quand la condition de déblocage (post-MVP) est atteinte.
-
### FTD-06 — AudioWorklet au lieu de ScriptProcessorNode (T2 Live)
-
**Priorité :** 🟢 Mineur
-**Statut :** Gelé — post-MVP (T2 Live non encore implémenté)
+**Statut :** Reporté à après le lancement MVP
**Estimation de session :** 1 jour
**Description :** Hérité du backend (TD-09). Côté frontend, le traitement audio pour la T2 Live (capture PCM 16kHz) devra probablement utiliser `AudioWorklet` au lieu de `ScriptProcessorNode` qui est déprécié.
@@ -427,10 +166,27 @@ Frontend :
---
-### FTD-08 — Tests E2E non implémentés
+### FTD-07 — Sentry non intégré
+**Priorité :** 🟡 Important
+**Statut :** Planifié — après MVP
+**Estimation de session :** 3h
+**Description :** Le monitoring frontend (erreurs JS, performances, sessions) n'est pas encore en place. Sans Sentry (ou équivalent), les bugs en production ne remontent pas — on les découvre uniquement si un utilisateur prend la peine de les signaler.
+**À faire :**
+- Créer un compte Sentry (tier gratuit suffit pour démarrer)
+- Ajouter `@sentry/react` au projet
+- Intégrer dans `src/app/providers.tsx`
+- Ajouter `VITE_SENTRY_DSN` dans les variables d'environnement
+- Configurer le filtrage des données sensibles (JWT, emails)
+- Mettre à jour SEC-11 dans `SECURITY.md`
+
+**Condition de résolution :** avant la première vague d'utilisateurs post-MVP (30 jours après lancement).
+
+---
+
+### FTD-08 — Tests E2E non implémentés
**Priorité :** 🟢 Mineur
-**Statut :** Gelé — post-MVP (accepté par design)
+**Statut :** Reporté — accepté par design
**Estimation de session :** 2 jours (Playwright setup)
**Description :** Actuellement, les tests de bout en bout sont manuels (via `GOLDEN_DATASET.md`). Une automatisation avec Playwright permettrait de détecter les régressions UI sans effort humain.
@@ -440,83 +196,23 @@ Frontend :
---
-> FTD-09 et FTD-33 résolues au Sprint 6c (2026-04-26) — voir §5 Historique.
-
----
-
-### 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
-**Statut :** Gelé — post-MVP
-**Estimation de session :** 2h
-**Description :** Le `ThemeProvider` est bi-state (`'light' | 'dark'`). L'option `'system'` (qui suit `prefers-color-scheme` en temps réel via `MediaQueryList.addEventListener`) a été volontairement différée (décision Sprint 0.5).
-
-**À faire :**
-
-- Étendre le type `Theme` à `'light' | 'dark' | 'system'`
-- Dans `ThemeProvider`, si `theme === 'system'` : écouter `matchMedia('(prefers-color-scheme: dark)')` et appliquer/retirer `.dark` dynamiquement
-- `ThemeToggle` : cycle light → dark → system (ou un sélecteur 3 états)
-- Mettre à jour `getInitialTheme()` pour retourner `'system'` si aucune préférence stockée
-
-**Condition de résolution :** après MVP — confort utilisateur, pas bloquant.
-
----
-
-> FTD-44 résolue au Sprint 7.5 (2026-07-02) — voir §5 Historique des résolutions.
-
----
-
-### FTD-45 — Relances Gemini T1 Live hors-sujet (extension TD-23)
-
-**Priorité :** 🟡 Important
-**Statut :** Gelé — dépend de l'amont (Gemini / backend TD-23), hors contrôle frontend
-**Estimation de session :** à évaluer (chantier backend/prompt)
-**Description :** En T1 Live, l'examinateur (Gemini) formule ses relances à partir de son contexte audio interne. Au test manuel, certaines relances partent **hors-sujet** (sans rapport avec ce que le candidat vient de dire). Extension de la dette backend **TD-23** : en VAD manuel, `inputTranscription` candidat n'est flushé qu'à `activityEnd`, donc le modèle relance sans transcription token-par-token fiable.
-
-**Impact actuel :** dégrade le réalisme de l'entretien T1 ; non bloquant pour la livraison 7b (le flux fonctionne, l'évaluation finale reste correcte).
-
-**À faire :** ré-évaluer côté backend/prompt (formulation de la consigne de relance, fenêtre de contexte) une fois la transcription incrémentale repensée (Sprint 7e / TD-23). **Piste de résolution actée : voir ADR-007 (migration Gemini Live → Deepgram). Sera dégelée et réévaluée au démarrage du Sprint 7c.**
-
-**Condition de résolution :** après traitement de TD-23 (transcription live) — non actionnable côté frontend seul.
-
----
-
-### FTD-46 — Transcription Gemini Live hasardeuse (qualité audio→texte)
-
-**Priorité :** 🟡 Important
-**Statut :** Gelé — dépend de l'amont (qualité Gemini Live), hors contrôle frontend
-**Estimation de session :** à évaluer
-**Description :** La transcription produite par Gemini Live (`input/outputTranscription`) est de qualité **inégale** : mots manqués, segments approximatifs. Observé au test manuel T1 Live (et applicable au T2 Live). Affecte la fidélité du transcript utilisé pour l'évaluation et bloquera l'affichage live (Sprint 7e).
-
-**Impact actuel :** qualité du transcript variable ; non bloquant pour 7b (l'évaluation 5 critères reste exploitable).
-
-**À faire :** suivre l'évolution du modèle Gemini Live ; évaluer un post-traitement ou une source de transcription alternative si la qualité reste insuffisante au Sprint 7e. **Piste de résolution actée : voir ADR-007 (migration Gemini Live → Deepgram). Sera dégelée et réévaluée au démarrage du Sprint 7c.**
-
-**Condition de résolution :** amélioration amont (modèle) ou décision d'architecture transcription au Sprint 7e.
-
----
-
## 4. Tests à renforcer
-> FTD-09 gelée au Sprint 5.5 (2026-04-26) — voir §3bis Backlog gelé.
+### 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-12 — Tests automatisés manquants pour `api-client.ts`
-
**Priorité :** 🟡 Important
**Statut :** Ouvert — à faire avant intégration des features critiques
**Estimation de session :** 3h
@@ -525,7 +221,6 @@ Frontend :
**Impact actuel :** toute régression sur ce fichier (oubli d'un header, mauvais parsing d'une erreur, boucle de retry infinie sur un edge case) passera inaperçue jusqu'aux tests manuels ou à un bug en production.
**À faire :**
-
- Créer `src/shared/lib/__tests__/api-client.test.ts`
- Mocker globalement `fetch` via `vi.fn()`
- Couvrir :
@@ -547,69 +242,16 @@ Frontend :
## 5. Historique des résolutions
-| ID | Description | Résolu le | Comment |
-| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| FTD-11 | `@theme` Tailwind 4 non défini — palette et typographie absentes | 2026-04-18 | Résolu au Sprint 0.5 (design system). Palette Direction H complète (canvas/surface/ink/expria/deep/semantic) + typo Plus Jakarta Sans définis dans `src/index.css` via `@theme {}` et `.dark {}`. shadcn/ui remappé sur ces tokens. Règle L ajoutée dans `DEVELOPMENT_PRINCIPLES.md` pour garantir l'usage exclusif des tokens. |
-| FTD-13 | Incompatibilité Vitest 3 / Vite 8 (conflit de types `Plugin` entre le Vite 8 top-level avec Rolldown et le Vite 7 pinné de Vitest 3.2.4 ; `npm run build` cassé) | 2026-04-17 | Résolu par upgrade Vitest `3.2.4 → 4.1.4` (et `@vitest/coverage-v8` idem) à l'étape 12-bis du Sprint 0. Vitest 4.x supporte nativement Vite 8 Rolldown. Correctif complémentaire : script `typecheck` passé de `tsc --noEmit -p tsconfig.app.json` à `tsc -b --noEmit` pour couvrir aussi `tsconfig.node.json` (d'où `vite.config.ts`) et éviter qu'un bug similaire échappe à la CI. |
-| FTD-16 | `VITE_MAINTENANCE_MODE` non lu dans le code — la variable d'env était dans `env.ts` mais jamais consommée | 2026-04-18 | Résolu au Sprint 1 étape 6. Ajout de `isMaintenanceMode` dans `src/shared/config/env.ts` et garde dans `src/app/main.tsx` : `isMaintenanceMode ? : `. `MaintenancePage` est statique (aucun provider requis), tokens Direction H exclusivement. |
-| FTD-22 | Code orphelin suite à la refonte UX `/sujets` (2026-04-21) — composant `SujetSelector` et helper `selectSujet` plus référencés après bascule dropdown → page dédiée | 2026-04-23 | Résolution complète. `SujetSelector` + `selectSujet` supprimés. Éléments conservés (`choosing-subject`, `goToSubjectPicker`) sont activement utilisés par `SimulationFlowProvider` et `SimulationForm` — ce n'est plus de la dette. |
-| FTD-20 | `GET /simulations/:id` manquant dans le backend | 2026-04-22 | Implémenté au Sprint 3.6a (backend) — route complète avec auth, owner check, `REPORT_NOT_READY`. Consommé par `RapportPage` et `useAutosave`. |
-| FTD-04 | Documents miroir sans automatisation de synchronisation | 2026-04-23 | Risque accepté par design (ADR 004). Mitigation en place (Règle G, commentaire `SOURCE OF TRUTH`, tests de parité). Condition de ré-ouverture : si une divergence silencieuse cause 2+ bugs en production. |
-| FTD-05 | Ancien scaffold frontend possiblement caduc | 2026-04-23 | Audit Claude Code complet — aucun résidu scaffold Vite, aucun fichier orphelin, règles critiques (D, E, F, G, J + ADR 003/005) respectées. Désalignements documentaires traités via FTD-25 et FTD-26. |
-| FTD-29 | `.github/dependabot.yml` dans les 2 dépôts | 2026-04-23 | Fichier créé dans expria-frontend et expria-backend. Ecosystem npm, weekly, limit 10 PRs. Dependabot alerts + security updates activés via UI GitHub. |
-| FTD-27 | CI GitHub Actions pour expria-backend | 2026-04-23 | Workflow créé : npm ci → test → audit. Node 22, trigger push/PR sur main. CI verte au premier run (21s). Observations : typecheck absent (O1), ESLint absent (O2), engines.node absent (O3) — à traiter en FTDs séparées. |
-| FTD-28 | Semgrep dans CI frontend + backend | 2026-04-23 | Step `semgrep scan --config=auto --error --severity=ERROR` ajouté aux deux workflows CI. Backend vert au 1er run. Frontend vert après correction de 4 erreurs ESLint préexistantes + fix Prettier + ajout env vars CI. |
-| FTD-17 | Clé `['plan']` dupliquée entre features (`usePlan`, `SimulationPage`, `RapportPage`) | 2026-04-22 | Résolu au Sprint 3.5. Création de `src/entities/user/query-keys.ts` (constantes pures, aucun import runtime) exportant `PLAN_QUERY_KEY = ['plan'] as const`. `features/dashboard/hooks/usePlan.ts` l'importe et le re-exporte pour conserver la rétro-compatibilité de l'import `PLAN_QUERY_KEY`. `SimulationPage.tsx` et `RapportPage.tsx` remplacent leur `useQuery` inline par le hook `usePlan()` — dédup totale de la clé et de la config staleTime. |
-| FTD-18 | SimulationForm utilise encore le shadcn Button au lieu de la primitive `@/shared/ui/Button` | 2026-04-22 | Résolu au Sprint 3.5. Remplacement de l'import `@/shared/components/ui/button` par `@/shared/ui/Button` dans `SimulationForm.tsx`. Aucun variant à adapter (usage du Button sans prop `variant` → `primary` par défaut dans les deux implémentations). Les 7 autres consommateurs shadcn (`Login/RegisterPage`, `PaywallBanner`, `DesignSystemPage`, `ThemeToggle`, `dialog.tsx`) restent hors scope de cette FTD. |
-| FTD-23 | `useAutosave` continue après correction → 400 VALIDATION_ERROR | 2026-04-23 | `enabled` corrigé dans `SimulationForm` (`!isSubmitting && step !== 'done' && step !== 'correcting'`). Le `beforeunload` handler et le debounce lisent `enabled` via `latestRef` — tous deux neutralisés dès que `step` transite. 2 tests de régression ajoutés dans `useAutosave.test.ts` : (a) `enabled` true→false annule le debounce en cours, (b) `enabled=false` + `beforeunload` = aucun appel. |
-| 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). |
-| 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-44 | Hooks audio génériques empruntés à `features/t2-live/` (T1 Live) | 2026-07-02 | Sprint 7.5 Clean — `useAudioCapture`, `useAudioPlayback`, `useAudioRecording` (+ test) déplacés de `features/t2-live/hooks/` vers `shared/lib/audio/`. Imports mis à jour dans `useT2LiveSession.ts` et `useT1LiveSession.ts`, marqueurs `TODO(FTD-44)` retirés. Dérogation Règle B assumée sur la vague `useAudioRecording` (hook + test déplacés ensemble pour préserver le chemin d'import relatif `../useAudioRecording`, condition nécessaire au respect de Règle C — jamais rouge entre étapes). Validation à l'oreille manuelle : T2 Live D2-D5 verts, T1 Live parcours complet vert. 301/301 tests, 0 erreur typecheck. Commit `d9160c4`. |
-| 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`. |
+| ID | Description | Résolu le | Comment |
+|---|---|---|---|
+| FTD-13 | Incompatibilité Vitest 3 / Vite 8 (conflit de types `Plugin` entre le Vite 8 top-level avec Rolldown et le Vite 7 pinné de Vitest 3.2.4 ; `npm run build` cassé) | 2026-04-17 | Résolu par upgrade Vitest `3.2.4 → 4.1.4` (et `@vitest/coverage-v8` idem) à l'étape 12-bis du Sprint 0. Vitest 4.x supporte nativement Vite 8 Rolldown. Correctif complémentaire : script `typecheck` passé de `tsc --noEmit -p tsconfig.app.json` à `tsc -b --noEmit` pour couvrir aussi `tsconfig.node.json` (d'où `vite.config.ts`) et éviter qu'un bug similaire échappe à la CI. |
---
## 6. Historique de ce document
-| Version | Date | Changements |
-| ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1.0 | 2026-04-17 | Création initiale avec 9 FTD identifiées depuis l'audit backend et les décisions d'architecture |
-| 1.1 | 2026-04-17 | Ajout FTD-10 (Semgrep CI), FTD-11 (`@theme` Tailwind 4), FTD-12 (tests `api-client`) suite à l'étape 11 du Sprint 0 |
-| 1.2 | 2026-04-17 | Ajout FTD-13 résolu (incompatibilité Vitest 3 / Vite 8) suite à l'étape 12-bis du Sprint 0 |
-| 1.3 | 2026-04-18 | FTD-11 résolu (design system Sprint 0.5) ; ajout FTD-14 (anti-FOUC), FTD-15 (option 'system' thème) |
-| 1.4 | 2026-04-18 | FTD-16 résolu (VITE_MAINTENANCE_MODE implémenté — Sprint 1 étape 6) |
-| 1.5 | 2026-04-19 | Ajout FTD-17 (clé ['plan'] dupliquée entre features — Sprint 3 étape 14) |
-| 1.6 | 2026-04-20 | Ajout FTD-18 (SimulationForm shadcn Button — Sprint 0.5 bis D2) ; ajout FTD-19 (token --shadow-focus manquant — Sprint 0.5 bis D2) |
-| 1.7 | 2026-04-20 | Ajout FTD-20 🔴 (GET /simulations/:id manquant backend — bloque RapportPage Sprint 3 étape 15) |
-| 1.8 | 2026-04-20 | Ajout FTD-21 🔴 (persistance session simulation — prod + sujet perdus au refresh, session dédiée après G1-G5) |
-| 1.9 | 2026-04-21 | FTD-22 résolu partiellement (nettoyage code orphelin refonte `/sujets` — `SujetSelector` + `selectSujet` supprimés ; `choosing-subject` + `goToSubjectPicker` conservés) |
-| 1.10 | 2026-04-21 | FTD-21 résolu partiellement pour `/simulation/ee` (autosave 30 s + `beforeunload` + reprise via `localStorage` + `PATCH /:id/contenu` + `PATCH /:id/sujet` + `getById` tolère `rapport=null`) ; EO + examen restent ouverts |
-| 1.11 | 2026-04-22 | Sprint 3.5 Clean — FTD-17, FTD-18, FTD-19 résolus. 15 FTD actives restantes (cap de 15 respecté) |
-| 1.12 | 2026-04-22 | Sprint 3.6a — Ajout FTD-23 🟡 (useAutosave fire après correction). 16 FTD actives → cap de 15 dépassé temporairement, à revoir au prochain clean. |
-| 1.13 | 2026-04-22 | Sprint 3.6b — Ajout FTD-24 🟡 (polling auto exercices/modèle pending). 17 FTD actives → cap dépassé, un clean 3.6.5 devra résoudre FTD-23/24 ensemble. |
-| 1.14 | 2026-04-23 | Triage : FTD-04, FTD-05, FTD-20, FTD-22 fermées. FTD-25, FTD-26 ajoutées. 15 FTD actives (cap respecté). |
-| 1.15 | 2026-04-23 | Réorg sécurité : FTD-06, FTD-08, FTD-15 gelées (backlog post-MVP). FTD-27 🔴, FTD-28 🔴, FTD-29 🟡 ajoutées (sécurité). 15 FTD actives (cap respecté). |
-| 1.16 | 2026-04-23 | FTD-29 fermée (Dependabot config). 14 FTD actives. |
-| 1.17 | 2026-04-23 | FTD-27 fermée (CI backend). 13 FTD actives. |
-| 1.18 | 2026-04-23 | FTD-28 fermée (Semgrep CI). CI frontend verte pour la première fois. 12 FTD actives. |
-| 1.19 | 2026-04-23 | FTD-23 et FTD-24 fermées (clean useAutosave après correction + polling automatique jobs pending dans useRapport). 10 FTD actives (cap 15). |
-| 1.20 | 2026-04-25 | Sprint 4c-1 — Ajout FTD-30 🟡 (rotation token Deepgram sans grace period), FTD-31 🟢 (page enregistrement EO non resumable), FTD-32 🟢 (Safari iOS non testé), FTD-33 🟢 (EO_T2_LIVE verrouillé en dur). 14 FTD actives (cap 15 respecté). |
-| 1.21 | 2026-04-25 | Sprint 4c-2 — Ajout FTD-34 🟢 (présentation T1 en localStorage clair), FTD-35 🟡 (refresh sans simulation active sur PresentationGenereeT1Page). **16 FTD actives — cap dépassé temporairement, accepté par Hermann pour cette session ; clean à planifier au prochain Sprint.** |
-| 1.22 | 2026-04-25 | Sprint 4c-3 — Ajout FTD-36 🟡 (upload audio base64 sans progression), FTD-37 🟢 (code Deepgram live dormant à trancher). FTD-30 dégradée 🟡→🟢 et passée en « gelé » (Deepgram live mis en pause). **17 FTD actives — cap toujours dépassé, clean prioritaire au Sprint suivant.** |
-| 1.23 | 2026-04-25 | FTD-25 et FTD-26 fermées (ARCHITECTURE.md §3 reflète l'arborescence réelle + convention `shared/ui/` vs `shared/components/ui/` documentée). 15 FTD actives (cap respecté). |
-| 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é). |
-| 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). |
-| 1.29 | 2026-06-30 | Sprint 7b (T1 Live) — Ajout FTD-44 🟡 **gelée** (hooks audio génériques empruntés à `features/t2-live/`, réactivée au Sprint 7.5). **14 FTD actives** (inchangé — entrée gelée, ne compte pas dans le cap, même mécanique que FTD-06). |
-| 1.30 | 2026-06-30 | Sprint 7b (T1 Live, finalisation) — Ajout FTD-45 🟡 **gelée** (relances Gemini hors-sujet, extension TD-23) et FTD-46 🟡 **gelée** (transcription Gemini Live hasardeuse). Bugs amont observés au test manuel, hors contrôle frontend. **14 FTD actives** (inchangé — entrées gelées, ne comptent pas dans le cap). |
-| 1.31 | 2026-07-02 | Sprint 7.5 Clean — FTD-44 résolue (hooks audio relocalisés vers `shared/lib/audio/`, dégelée → fermée ; ne libère pas de place, une entrée gelée ne comptait pas dans le cap). Ajout FTD-47 🟢 (sessions T1 Live non taguées dans l'historique — découverte Sprint 7.5, racine Sprint 7a backend). **14 → 15 FTD actives — cap de 15 atteint.** |
-| 1.32 | 2026-07-02 | ADR-007 — décision actée de migration Gemini Live → Deepgram (T1 + T2 Live, voir Sprint 7c dans ROADMAP.md). FTD-45 et FTD-46 : champ « À faire » complété avec un renvoi vers ADR-007, restent gelées en l'état (§3bis). **15 FTD actives, inchangé — FTD-45/46 restent gelées avec renvoi ADR-007, dégel prévu à l'ouverture du Sprint 7c.** |
+| Version | Date | Changements |
+|---|---|---|
+| 1.0 | 2026-04-17 | Création initiale avec 9 FTD identifiées depuis l'audit backend et les décisions d'architecture |
+| 1.1 | 2026-04-17 | Ajout FTD-10 (Semgrep CI), FTD-11 (`@theme` Tailwind 4), FTD-12 (tests `api-client`) suite à l'étape 11 du Sprint 0 |
+| 1.2 | 2026-04-17 | Ajout FTD-13 résolu (incompatibilité Vitest 3 / Vite 8) suite à l'étape 12-bis du Sprint 0 |
diff --git a/docs/adr/006-stack-versions-2026.md b/docs/adr/006-stack-versions-2026.md
index 2194787..52a63b8 100644
--- a/docs/adr/006-stack-versions-2026.md
+++ b/docs/adr/006-stack-versions-2026.md
@@ -1,6 +1,6 @@
# ADR 006 — Stack frontend : versions 2026 (React 19, Vite 8, TypeScript 6, Tailwind 4, RR7)
-**Statut :** Accepté — mis à jour Sprint 0.5
+**Statut :** Accepté
**Date :** 2026-04-17
**Décideur :** Hermann
**Contexte :** Révélé par l'état des lieux Claude Code au démarrage du Sprint 0 frontend
@@ -105,150 +105,18 @@ Garder React 19, Vite 8, TypeScript 6 mais downgrader Tailwind 4 → 3 pour "com
### Configuration Tailwind 4
-Pas de `tailwind.config.ts`. La configuration se fait exclusivement dans `src/index.css`.
-
-#### Mode thème (mis à jour Sprint DA Charcoal — 2026-04-24)
-
-**Dark est le thème par défaut.** Les tokens de contenu (`--color-canvas`, `--color-ink-*`, etc.) sont déclarés en mode dark dans `@theme`. Une classe `.light` sur `` active le mode clair en override. Configuré via :
+Pas de `tailwind.config.ts`. La configuration se fait exclusivement dans `src/index.css` via les directives :
```css
-@custom-variant light (&:where(.light, .light *));
-```
-
-Ce variant permet d'écrire `light:bg-surface` dans les composants quand un comportement spécifique au mode clair est requis (ex. primitives shadcn où l'opacité doit être adaptée).
-
-#### Tokens @theme (DA Charcoal — validée Sprint DA Charcoal 2026-04-24)
-
-```css
-@import 'tailwindcss';
-@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
-
-@custom-variant light (&:where(.light, .light *));
+@import "tailwindcss";
@theme {
- /* ── Invariants (identiques dark + light) ── */
-
- /* Sidebar navy permanent */
- --color-sidebar-bg: #0C1528;
- --color-sidebar-border: rgba(255, 255, 255, 0.07);
- --color-sidebar-text: rgba(255, 255, 255, 0.6);
- --color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
- --color-sidebar-text-active: #FFFFFF;
- --color-sidebar-nav-hover: rgba(255, 255, 255, 0.07);
- --color-sidebar-nav-active: rgba(255, 255, 255, 0.1);
- --color-sidebar-section-label: rgba(255, 255, 255, 0.3);
-
- /* Brand */
- --color-brand: #1B4FD8;
- --color-brand-hover: #1744B8;
- --color-brand-active: #13379C;
- --color-brand-dark: #1740B0;
- --color-brand-ink: #FFFFFF;
-
- /* Semantic (invariants) */
- --color-warning: #F59E0B;
- --color-warning-soft: rgba(245, 158, 11, 0.12);
- --color-danger: #EF4444;
- --color-danger-soft: rgba(239, 68, 68, 0.12);
-
- /* Typographie */
- --font-sans: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
- --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
-
- /* Rayons (override Tailwind) */
- --radius-xs: 6px; --radius-sm: 8px; --radius-md: 12px;
- --radius-lg: 16px; --radius-xl: 20px; --radius-pill: 999px;
-
- /* Focus */
- --shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18);
-
- /* ── Dark mode (défaut) ── */
- --color-canvas: #111111;
- --color-surface: rgba(255, 255, 255, 0.035);
- --color-surface-hover: rgba(255, 255, 255, 0.055);
- --color-surface-solid: #1E1E1E;
- --color-surface-raised: #222222;
- --color-border: rgba(255, 255, 255, 0.06);
- --color-border-strong: rgba(255, 255, 255, 0.12);
-
- --color-ink-primary: #E5E5E5;
- --color-ink-secondary: rgba(255, 255, 255, 0.55);
- --color-ink-tertiary: rgba(255, 255, 255, 0.3);
- --color-ink-inverse: #111111;
-
- --color-brand-soft: rgba(27, 79, 216, 0.1);
- --color-brand-text: #7DA4F0;
-
- --color-success: #4ADE80;
- --color-success-soft: rgba(74, 222, 128, 0.12);
-
- --color-topbar-bg: rgba(17, 17, 17, 0.88);
- --color-gradient-a: rgba(27, 79, 216, 0.05);
- --color-gradient-b: rgba(27, 79, 216, 0.03);
-
- --shadow-card: none;
- --shadow-raised: none;
-}
-
-/* Light mode — override sur */
-.light {
- --color-canvas: #F3F4F6;
- --color-surface: #FFFFFF;
- --color-surface-hover: #F8F9FB;
- --color-surface-solid: #FFFFFF;
- --color-surface-raised: #FFFFFF;
- --color-border: rgba(0, 0, 0, 0.07);
- --color-border-strong: rgba(0, 0, 0, 0.14);
-
- --color-ink-primary: #0F0F1A;
- --color-ink-secondary: rgba(0, 0, 0, 0.55);
- --color-ink-tertiary: rgba(0, 0, 0, 0.3);
- --color-ink-inverse: #FFFFFF;
-
- --color-brand-soft: rgba(27, 79, 216, 0.06);
- --color-brand-text: #1B4FD8;
-
- --color-success: #16A34A;
- --color-success-soft: rgba(22, 163, 74, 0.1);
-
- --color-topbar-bg: rgba(243, 244, 246, 0.88);
- --color-gradient-a: rgba(27, 79, 216, 0.025);
- --color-gradient-b: rgba(27, 79, 216, 0.01);
-
- --shadow-card: 0 1px 2px rgba(15, 18, 32, 0.04), 0 1px 8px rgba(15, 18, 32, 0.03);
- --shadow-raised: 0 4px 16px rgba(15, 18, 32, 0.06), 0 1px 2px rgba(15, 18, 32, 0.04);
+ --color-primary: #1B4FD8; /* Couleur brand Expria */
+ --font-sans: "Plus Jakarta Sans", system-ui, sans-serif;
+ /* Autres variables de thème */
}
```
-#### Classes Tailwind générées
-
-Les tokens `@theme` créent des classes utilitaires directement utilisables :
-
-| Token | Classes Tailwind |
-|---|---|
-| `--color-canvas` | `bg-canvas`, `text-canvas`, `border-canvas` |
-| `--color-surface` | `bg-surface`, `border-surface` |
-| `--color-surface-hover` | `bg-surface-hover` |
-| `--color-sidebar-bg` | `bg-sidebar-bg` (navy permanent, identique dark+light) |
-| `--color-ink-primary` | `text-ink-primary` |
-| `--color-ink-secondary` | `text-ink-secondary` |
-| `--color-ink-tertiary` | `text-ink-tertiary` |
-| `--color-brand` | `bg-brand`, `border-brand`, `ring-brand` |
-| `--color-brand-text` | `text-brand-text` (bleu adapté au fond — `#7DA4F0` dark, `#1B4FD8` light) |
-| `--color-brand-soft` | `bg-brand-soft` (teinte chip / highlight discret) |
-| `--color-success-soft`, `-warning-soft`, `-danger-soft` | `bg-success-soft`, etc. |
-| `--shadow-card`, `--shadow-raised` | `shadow-card`, `shadow-raised` (auto dual-theme : `none` en dark, ombre en light) |
-| `--shadow-focus` | `shadow-focus` (halo bleu 3px sur `:focus-visible`) |
-
-**Conventions critiques :**
-- `bg-surface` = cards / modals / panels. `bg-canvas` = fond de page. Ne jamais inverser.
-- `bg-sidebar-bg` = navy permanent — ne change jamais entre dark et light (ancre visuelle de marque).
-- Utiliser le préfixe `light:` uniquement quand un override spécifique au mode clair est strictement nécessaire (ex. primitives shadcn où l'opacité d'une couleur sémantique diffère).
-
-#### Typographie
-
-Plus Jakarta Sans chargée via Google Fonts dans `index.html` (preconnect + stylesheet, weights 400/500/600/700). Migration vers auto-hébergement (`@fontsource/plus-jakarta-sans`) après MVP si les performances réseau deviennent un enjeu.
-
### shadcn/ui avec Tailwind 4
La CLI shadcn/ui supporte Tailwind 4 depuis début 2025 :
diff --git a/docs/adr/007-deepgram-vs-gemini-eo-live.md b/docs/adr/007-deepgram-vs-gemini-eo-live.md
deleted file mode 100644
index a1b7497..0000000
--- a/docs/adr/007-deepgram-vs-gemini-eo-live.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# ADR 007 — Migration architecture audio EO Live : Gemini Live → Deepgram
-
-**Statut :** Accepté
-**Date :** 2026-07-02
-**Décideur :** Hermann
-
----
-
-## Contexte
-
-L'Expression Orale Live (T1 + T2) repose depuis les Sprints 6-7 sur Gemini Live (modèle audio-à-audio natif, `geminiLive.ts` côté backend). Cette architecture présente un défaut rédhibitoire : une transcription de mauvaise qualité qui biaise systématiquement les corrections DeepSeek vers la sévérité (**FTD-46**) — un problème de fiabilité produit, pas cosmétique, sur un service qui évalue un examen officiel. Gemini pose également des questions de relance sans lien avec le discours du candidat (**FTD-45**, T1 Live). Ces deux défauts sont eux-mêmes une extension de la dette backend **TD-23** (en VAD manuel, `inputTranscription` n'est flushé qu'à `activityEnd`, donc sans transcription token-par-token fiable pour ancrer les relances).
-
-Un test comparatif (POC) a été mené entre l'architecture actuelle et une architecture découplée Deepgram (STT nova-3 + DeepSeek LLM + TTS Aura-2), pour évaluer si ces défauts sont propres à Gemini ou inhérents au produit.
-
-## Résultats du test comparatif (POC)
-
-- **Transcription** : à débit de parole lent du candidat, Deepgram approche la perfection sur la tâche T1 Live (quasi 100 %). Gemini produit des confusions de langue et des omissions de phrases entières en conditions réelles.
-- **Relances/interruptions** : Deepgram (LLM + orchestration) pose des questions ancrées sur le discours réel du candidat, contrairement à Gemini qui relance hors-sujet.
-- **Coût** : Deepgram + DeepSeek + Aura-2 ≈ 0,015-0,020 $/session de 2 min vs Gemini Live ≈ 0,075-0,080 $/session — 4-5x moins cher. Réserve : tarifs volatils, le calcul Gemini n'inclut pas la réaccumulation de contexte par tour — à revérifier avant tout engagement budgétaire.
-- **Seul point faible identifié** : débit vocal Aura-2 FR légèrement lent et non naturel à T2, aucun paramètre de vitesse fiable trouvé sans risque de régression. Jugé préférable malgré tout à un débit naturel qui s'accompagne de confusions de langue et d'omissions de phrases côté Gemini — compromis assumé, pas un point bloquant.
-- **Détail d'implémentation validé sur le POC** (Fable 5, agent de code) : succès one-shot sur T1 (contrainte d'ancrage sur tout le monologue, point d'échec de Gemini) ; T2 a nécessité du debug itératif mais les causes étaient des artefacts d'exécution (build obsolète), pas des limites structurelles.
-
-## Options envisagées
-
-### Option A — Statu quo : Gemini Live
-
-- Avantages : modèle unique audio-à-audio natif, pas d'orchestration multi-service, déjà en production (T1 + T2 livrés Sprints 6-7).
-- Inconvénients : transcription de qualité inégale biaisant systématiquement les corrections DeepSeek vers la sévérité (FTD-46) ; relances hors-sujet en T1 (FTD-45) ; caveat TD-23 (flush transcription à `activityEnd` seulement) bloquant l'affichage incrémental prévu au Sprint 7e.
-
-### Option B — Architecture découplée Deepgram (STT nova-3 + DeepSeek LLM + TTS Aura-2)
-
-- Avantages : transcription quasi parfaite à débit lent (T1), relances ancrées sur le discours réel du candidat, coût 4-5x inférieur, nativement streaming (résout TD-23 sans contournement), POC one-shot réussi sur T1.
-- Inconvénients : complexité d'orchestration accrue (3 services au lieu d'1), débit vocal Aura-2 FR légèrement lent/non naturel à T2 (aucun paramètre de vitesse fiable trouvé), T2 a nécessité du debug itératif (causes non structurelles), tarifs à revérifier avant engagement budgétaire.
-
-## Décision
-
-**Option B** — migration actée de Gemini Live vers Deepgram, pour **T1 ET T2 Live**.
-
-## Plan d'implémentation en deux temps
-
-1. **Temps 1 — Construction en parallèle** : construire le module Deepgram à côté de Gemini Live, activé par un flag `EO_STT_PROVIDER=gemini|deepgram`, pour permettre des tests comparatifs en conditions réelles avant toute bascule définitive.
-2. **Temps 2 — Bascule + retrait** : une fois la comparaison validée, bascule complète sur Deepgram et **retrait du code Gemini Live** (`geminiLive.ts` et son usage dans `t1live.ts` / `t2live.ts` côté backend).
-
-**Clause de non-maintien à long terme** : le flag `EO_STT_PROVIDER` est un outil de transition de sprint, pas une fonctionnalité permanente. Aucune intention de maintenir les deux architectures en parallèle à long terme.
-
-## Conséquences
-
-**Positives :**
-
-- Résout FTD-45 et FTD-46 à la racine.
-- Résout également le caveat TD-23 du Sprint 7e (flush `inputTranscription` à `activityEnd` seulement) — Deepgram est nativement streaming, l'affichage incrémental candidat devient possible sans contournement.
-
-**Négatives :**
-
-- Complexité d'orchestration accrue pendant la phase de transition (3 services au lieu d'1) — mitigée par le fait que le flag est temporaire, pas un choix d'architecture permanent à deux branches.
-- Travail de retrait à prévoir en fin de migration : suppression du code Gemini Live une fois Deepgram validé (hors scope de cette session doc-only).
-
-**À revisiter si :**
-
-- Les tarifs Deepgram / DeepSeek / Aura-2 évoluent défavorablement (réserve explicite sur la volatilité tarifaire et sur le calcul Gemini incomplet ci-dessus).
-- Un paramètre de vitesse Aura-2 FR fiable devient disponible (lèverait le seul point faible identifié).
-
-## Références
-
-- `TECH_DEBT.md` FTD-45, FTD-46 (renvoi vers cet ADR), FTD-47
-- `ROADMAP.md` Sprint 7c (migration, à planifier en détail dans une session dédiée)
-- `expria-backend/docs/TECH_DEBT-backend.md` TD-22, TD-23 (dette backend directement concernée — non modifiée dans cette session, dépôt séparé)
diff --git a/index.html b/index.html
index 6ea92b3..fa5268e 100644
--- a/index.html
+++ b/index.html
@@ -5,19 +5,6 @@
Expria
-
-
-
-
diff --git a/package-lock.json b/package-lock.json
index 29333c3..abb1ef1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,10 +13,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
- "radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
- "react-markdown": "^10.1.0",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1"
@@ -640,44 +638,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
- "node_modules/@floating-ui/core": {
- "version": "1.7.5",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
- "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/utils": "^0.2.11"
- }
- },
- "node_modules/@floating-ui/dom": {
- "version": "1.7.6",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
- "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/core": "^1.7.5",
- "@floating-ui/utils": "^0.2.11"
- }
- },
- "node_modules/@floating-ui/react-dom": {
- "version": "2.1.8",
- "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
- "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/dom": "^1.7.6"
- },
- "peerDependencies": {
- "react": ">=16.8.0",
- "react-dom": ">=16.8.0"
- }
- },
- "node_modules/@floating-ui/utils": {
- "version": "0.2.11",
- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
- "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
- "license": "MIT"
- },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -809,1504 +769,6 @@
"url": "https://github.com/sponsors/Boshen"
}
},
- "node_modules/@radix-ui/number": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
- "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
- "license": "MIT"
- },
- "node_modules/@radix-ui/primitive": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
- "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
- "license": "MIT"
- },
- "node_modules/@radix-ui/react-accessible-icon": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz",
- "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-accordion": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
- "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collapsible": "1.1.12",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-alert-dialog": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
- "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dialog": "1.1.15",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-arrow": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
- "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-aspect-ratio": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
- "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-avatar": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
- "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-is-hydrated": "0.1.0",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-checkbox": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
- "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-collapsible": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
- "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-collection": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
- "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-compose-refs": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
- "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-context": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
- "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-context-menu": {
- "version": "2.2.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
- "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-menu": "2.1.16",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-dialog": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
- "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-direction": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
- "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
- "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-dropdown-menu": {
- "version": "2.1.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
- "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-menu": "2.1.16",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-focus-guards": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
- "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-focus-scope": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
- "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-form": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz",
- "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-label": "2.1.7",
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-hover-card": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
- "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-id": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
- "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-label": {
- "version": "2.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
- "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-menu": {
- "version": "2.1.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
- "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-menubar": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz",
- "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-menu": "2.1.16",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-navigation-menu": {
- "version": "1.2.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
- "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-one-time-password-field": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz",
- "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/number": "1.1.1",
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-is-hydrated": "0.1.0",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-password-toggle-field": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz",
- "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-is-hydrated": "0.1.0"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-popover": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
- "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-popper": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
- "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/react-dom": "^2.0.0",
- "@radix-ui/react-arrow": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-rect": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1",
- "@radix-ui/rect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-presence": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
- "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-progress": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
- "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-radio-group": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
- "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-roving-focus": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
- "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-scroll-area": {
- "version": "1.2.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
- "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/number": "1.1.1",
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-select": {
- "version": "2.2.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
- "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/number": "1.1.1",
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-visually-hidden": "1.2.3",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-separator": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
- "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-slider": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
- "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/number": "1.1.1",
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-slot": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
- "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-switch": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
- "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-tabs": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
- "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toast": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
- "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toggle": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
- "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toggle-group": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
- "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-toggle": "1.1.10",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toolbar": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz",
- "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-separator": "1.1.7",
- "@radix-ui/react-toggle-group": "1.1.11"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-tooltip": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
- "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-callback-ref": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
- "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-controllable-state": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
- "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-effect-event": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
- "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-escape-keydown": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
- "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-callback-ref": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-is-hydrated": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
- "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
- "license": "MIT",
- "dependencies": {
- "use-sync-external-store": "^1.5.0"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-layout-effect": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
- "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-previous": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
- "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-rect": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
- "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/rect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-size": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
- "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-visually-hidden": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
- "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/rect": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
- "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
- "license": "MIT"
- },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
@@ -3082,15 +1544,6 @@
"assertion-error": "^2.0.1"
}
},
- "node_modules/@types/debug": {
- "version": "4.1.13",
- "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
- "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
- "license": "MIT",
- "dependencies": {
- "@types/ms": "*"
- }
- },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3102,26 +1555,9 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
"license": "MIT"
},
- "node_modules/@types/estree-jsx": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
- "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
- "license": "MIT",
- "dependencies": {
- "@types/estree": "*"
- }
- },
- "node_modules/@types/hast": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
- "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "*"
- }
- },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3129,21 +1565,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/mdast": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
- "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "*"
- }
- },
- "node_modules/@types/ms": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
- "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
- "license": "MIT"
- },
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
@@ -3157,6 +1578,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3166,18 +1588,12 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
- "node_modules/@types/unist": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
- "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
- "license": "MIT"
- },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3482,12 +1898,6 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/@ungap/structured-clone": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
- "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
- "license": "ISC"
- },
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -3742,18 +2152,6 @@
"dev": true,
"license": "Python-2.0"
},
- "node_modules/aria-hidden": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
- "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -3800,16 +2198,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/bail": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
- "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3920,16 +2308,6 @@
],
"license": "CC-BY-4.0"
},
- "node_modules/ccount": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
- "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -3957,46 +2335,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/character-entities": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
- "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/character-entities-html4": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
- "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/character-entities-legacy": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
- "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/character-reference-invalid": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
- "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -4051,16 +2389,6 @@
"node": ">= 0.8"
}
},
- "node_modules/comma-separated-tokens": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
- "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4135,6 +2463,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -4155,6 +2484,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4175,19 +2505,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/decode-named-character-reference": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
- "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
- "license": "MIT",
- "dependencies": {
- "character-entities": "^2.0.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4209,6 +2526,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4224,25 +2542,6 @@
"node": ">=8"
}
},
- "node_modules/detect-node-es": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
- "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
- "license": "MIT"
- },
- "node_modules/devlop": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
- "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
- "license": "MIT",
- "dependencies": {
- "dequal": "^2.0.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -4566,16 +2865,6 @@
"node": ">=4.0"
}
},
- "node_modules/estree-util-is-identifier-name": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
- "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -4606,12 +2895,6 @@
"node": ">=12.0.0"
}
},
- "node_modules/extend": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
- "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
- "license": "MIT"
- },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4779,15 +3062,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/get-nonce": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
- "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -4900,46 +3174,6 @@
"node": ">= 0.4"
}
},
- "node_modules/hast-util-to-jsx-runtime": {
- "version": "2.3.6",
- "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
- "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0",
- "@types/hast": "^3.0.0",
- "@types/unist": "^3.0.0",
- "comma-separated-tokens": "^2.0.0",
- "devlop": "^1.0.0",
- "estree-util-is-identifier-name": "^3.0.0",
- "hast-util-whitespace": "^3.0.0",
- "mdast-util-mdx-expression": "^2.0.0",
- "mdast-util-mdx-jsx": "^3.0.0",
- "mdast-util-mdxjs-esm": "^2.0.0",
- "property-information": "^7.0.0",
- "space-separated-tokens": "^2.0.0",
- "style-to-js": "^1.0.0",
- "unist-util-position": "^5.0.0",
- "vfile-message": "^4.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/hast-util-whitespace": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
- "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
- "license": "MIT",
- "dependencies": {
- "@types/hast": "^3.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -4977,16 +3211,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/html-url-attributes": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
- "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -5084,46 +3308,6 @@
"node": ">=8"
}
},
- "node_modules/inline-style-parser": {
- "version": "0.2.7",
- "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
- "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
- "license": "MIT"
- },
- "node_modules/is-alphabetical": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
- "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/is-alphanumerical": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
- "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
- "license": "MIT",
- "dependencies": {
- "is-alphabetical": "^2.0.0",
- "is-decimal": "^2.0.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/is-decimal": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
- "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5147,28 +3331,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-hexadecimal": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
- "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/is-plain-obj": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
- "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -5648,16 +3810,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/longest-streak": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
- "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5749,601 +3901,6 @@
"node": ">= 0.4"
}
},
- "node_modules/mdast-util-from-markdown": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
- "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
- "license": "MIT",
- "dependencies": {
- "@types/mdast": "^4.0.0",
- "@types/unist": "^3.0.0",
- "decode-named-character-reference": "^1.0.0",
- "devlop": "^1.0.0",
- "mdast-util-to-string": "^4.0.0",
- "micromark": "^4.0.0",
- "micromark-util-decode-numeric-character-reference": "^2.0.0",
- "micromark-util-decode-string": "^2.0.0",
- "micromark-util-normalize-identifier": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0",
- "unist-util-stringify-position": "^4.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/mdast-util-mdx-expression": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
- "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
- "license": "MIT",
- "dependencies": {
- "@types/estree-jsx": "^1.0.0",
- "@types/hast": "^3.0.0",
- "@types/mdast": "^4.0.0",
- "devlop": "^1.0.0",
- "mdast-util-from-markdown": "^2.0.0",
- "mdast-util-to-markdown": "^2.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/mdast-util-mdx-jsx": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
- "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
- "license": "MIT",
- "dependencies": {
- "@types/estree-jsx": "^1.0.0",
- "@types/hast": "^3.0.0",
- "@types/mdast": "^4.0.0",
- "@types/unist": "^3.0.0",
- "ccount": "^2.0.0",
- "devlop": "^1.1.0",
- "mdast-util-from-markdown": "^2.0.0",
- "mdast-util-to-markdown": "^2.0.0",
- "parse-entities": "^4.0.0",
- "stringify-entities": "^4.0.0",
- "unist-util-stringify-position": "^4.0.0",
- "vfile-message": "^4.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/mdast-util-mdxjs-esm": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
- "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
- "license": "MIT",
- "dependencies": {
- "@types/estree-jsx": "^1.0.0",
- "@types/hast": "^3.0.0",
- "@types/mdast": "^4.0.0",
- "devlop": "^1.0.0",
- "mdast-util-from-markdown": "^2.0.0",
- "mdast-util-to-markdown": "^2.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/mdast-util-phrasing": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
- "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
- "license": "MIT",
- "dependencies": {
- "@types/mdast": "^4.0.0",
- "unist-util-is": "^6.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/mdast-util-to-hast": {
- "version": "13.2.1",
- "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
- "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
- "license": "MIT",
- "dependencies": {
- "@types/hast": "^3.0.0",
- "@types/mdast": "^4.0.0",
- "@ungap/structured-clone": "^1.0.0",
- "devlop": "^1.0.0",
- "micromark-util-sanitize-uri": "^2.0.0",
- "trim-lines": "^3.0.0",
- "unist-util-position": "^5.0.0",
- "unist-util-visit": "^5.0.0",
- "vfile": "^6.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/mdast-util-to-markdown": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
- "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
- "license": "MIT",
- "dependencies": {
- "@types/mdast": "^4.0.0",
- "@types/unist": "^3.0.0",
- "longest-streak": "^3.0.0",
- "mdast-util-phrasing": "^4.0.0",
- "mdast-util-to-string": "^4.0.0",
- "micromark-util-classify-character": "^2.0.0",
- "micromark-util-decode-string": "^2.0.0",
- "unist-util-visit": "^5.0.0",
- "zwitch": "^2.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/mdast-util-to-string": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
- "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
- "license": "MIT",
- "dependencies": {
- "@types/mdast": "^4.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/micromark": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
- "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@types/debug": "^4.0.0",
- "debug": "^4.0.0",
- "decode-named-character-reference": "^1.0.0",
- "devlop": "^1.0.0",
- "micromark-core-commonmark": "^2.0.0",
- "micromark-factory-space": "^2.0.0",
- "micromark-util-character": "^2.0.0",
- "micromark-util-chunked": "^2.0.0",
- "micromark-util-combine-extensions": "^2.0.0",
- "micromark-util-decode-numeric-character-reference": "^2.0.0",
- "micromark-util-encode": "^2.0.0",
- "micromark-util-normalize-identifier": "^2.0.0",
- "micromark-util-resolve-all": "^2.0.0",
- "micromark-util-sanitize-uri": "^2.0.0",
- "micromark-util-subtokenize": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-core-commonmark": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
- "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "decode-named-character-reference": "^1.0.0",
- "devlop": "^1.0.0",
- "micromark-factory-destination": "^2.0.0",
- "micromark-factory-label": "^2.0.0",
- "micromark-factory-space": "^2.0.0",
- "micromark-factory-title": "^2.0.0",
- "micromark-factory-whitespace": "^2.0.0",
- "micromark-util-character": "^2.0.0",
- "micromark-util-chunked": "^2.0.0",
- "micromark-util-classify-character": "^2.0.0",
- "micromark-util-html-tag-name": "^2.0.0",
- "micromark-util-normalize-identifier": "^2.0.0",
- "micromark-util-resolve-all": "^2.0.0",
- "micromark-util-subtokenize": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-factory-destination": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
- "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-character": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-factory-label": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
- "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "devlop": "^1.0.0",
- "micromark-util-character": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-factory-space": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
- "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-character": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-factory-title": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
- "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-factory-space": "^2.0.0",
- "micromark-util-character": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-factory-whitespace": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
- "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-factory-space": "^2.0.0",
- "micromark-util-character": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-util-character": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
- "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-util-chunked": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
- "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-symbol": "^2.0.0"
- }
- },
- "node_modules/micromark-util-classify-character": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
- "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-character": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-util-combine-extensions": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
- "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-chunked": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-util-decode-numeric-character-reference": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
- "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-symbol": "^2.0.0"
- }
- },
- "node_modules/micromark-util-decode-string": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
- "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "decode-named-character-reference": "^1.0.0",
- "micromark-util-character": "^2.0.0",
- "micromark-util-decode-numeric-character-reference": "^2.0.0",
- "micromark-util-symbol": "^2.0.0"
- }
- },
- "node_modules/micromark-util-encode": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
- "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT"
- },
- "node_modules/micromark-util-html-tag-name": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
- "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT"
- },
- "node_modules/micromark-util-normalize-identifier": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
- "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-symbol": "^2.0.0"
- }
- },
- "node_modules/micromark-util-resolve-all": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
- "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-util-sanitize-uri": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
- "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "micromark-util-character": "^2.0.0",
- "micromark-util-encode": "^2.0.0",
- "micromark-util-symbol": "^2.0.0"
- }
- },
- "node_modules/micromark-util-subtokenize": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
- "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "devlop": "^1.0.0",
- "micromark-util-chunked": "^2.0.0",
- "micromark-util-symbol": "^2.0.0",
- "micromark-util-types": "^2.0.0"
- }
- },
- "node_modules/micromark-util-symbol": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
- "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT"
- },
- "node_modules/micromark-util-types": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
- "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
- "funding": [
- {
- "type": "GitHub Sponsors",
- "url": "https://github.com/sponsors/unifiedjs"
- },
- {
- "type": "OpenCollective",
- "url": "https://opencollective.com/unified"
- }
- ],
- "license": "MIT"
- },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -6394,6 +3951,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -6510,31 +4068,6 @@
"node": ">=6"
}
},
- "node_modules/parse-entities": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
- "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^2.0.0",
- "character-entities-legacy": "^3.0.0",
- "character-reference-invalid": "^2.0.0",
- "decode-named-character-reference": "^1.0.0",
- "is-alphanumerical": "^2.0.0",
- "is-decimal": "^2.0.0",
- "is-hexadecimal": "^2.0.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/parse-entities/node_modules/@types/unist": {
- "version": "2.0.11",
- "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
- "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
- "license": "MIT"
- },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -6680,16 +4213,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/property-information": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
- "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6700,83 +4223,6 @@
"node": ">=6"
}
},
- "node_modules/radix-ui": {
- "version": "1.4.3",
- "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
- "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-accessible-icon": "1.1.7",
- "@radix-ui/react-accordion": "1.2.12",
- "@radix-ui/react-alert-dialog": "1.1.15",
- "@radix-ui/react-arrow": "1.1.7",
- "@radix-ui/react-aspect-ratio": "1.1.7",
- "@radix-ui/react-avatar": "1.1.10",
- "@radix-ui/react-checkbox": "1.3.3",
- "@radix-ui/react-collapsible": "1.1.12",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-context-menu": "2.2.16",
- "@radix-ui/react-dialog": "1.1.15",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-dropdown-menu": "2.1.16",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-form": "0.1.8",
- "@radix-ui/react-hover-card": "1.1.15",
- "@radix-ui/react-label": "2.1.7",
- "@radix-ui/react-menu": "2.1.16",
- "@radix-ui/react-menubar": "1.1.16",
- "@radix-ui/react-navigation-menu": "1.2.14",
- "@radix-ui/react-one-time-password-field": "0.1.8",
- "@radix-ui/react-password-toggle-field": "0.1.3",
- "@radix-ui/react-popover": "1.1.15",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-progress": "1.1.7",
- "@radix-ui/react-radio-group": "1.3.8",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-scroll-area": "1.2.10",
- "@radix-ui/react-select": "2.2.6",
- "@radix-ui/react-separator": "1.1.7",
- "@radix-ui/react-slider": "1.3.6",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-switch": "1.2.6",
- "@radix-ui/react-tabs": "1.1.13",
- "@radix-ui/react-toast": "1.2.15",
- "@radix-ui/react-toggle": "1.1.10",
- "@radix-ui/react-toggle-group": "1.1.11",
- "@radix-ui/react-toolbar": "1.1.11",
- "@radix-ui/react-tooltip": "1.2.8",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-escape-keydown": "1.1.1",
- "@radix-ui/react-use-is-hydrated": "0.1.0",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1",
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@@ -6806,80 +4252,6 @@
"license": "MIT",
"peer": true
},
- "node_modules/react-markdown": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
- "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
- "license": "MIT",
- "dependencies": {
- "@types/hast": "^3.0.0",
- "@types/mdast": "^4.0.0",
- "devlop": "^1.0.0",
- "hast-util-to-jsx-runtime": "^2.0.0",
- "html-url-attributes": "^3.0.0",
- "mdast-util-to-hast": "^13.0.0",
- "remark-parse": "^11.0.0",
- "remark-rehype": "^11.0.0",
- "unified": "^11.0.0",
- "unist-util-visit": "^5.0.0",
- "vfile": "^6.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- },
- "peerDependencies": {
- "@types/react": ">=18",
- "react": ">=18"
- }
- },
- "node_modules/react-remove-scroll": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
- "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
- "license": "MIT",
- "dependencies": {
- "react-remove-scroll-bar": "^2.3.7",
- "react-style-singleton": "^2.2.3",
- "tslib": "^2.1.0",
- "use-callback-ref": "^1.3.3",
- "use-sidecar": "^1.1.3"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/react-remove-scroll-bar": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
- "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
- "license": "MIT",
- "dependencies": {
- "react-style-singleton": "^2.2.2",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/react-router": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
@@ -6918,28 +4290,6 @@
"react-dom": ">=18"
}
},
- "node_modules/react-style-singleton": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
- "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
- "license": "MIT",
- "dependencies": {
- "get-nonce": "^1.0.0",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -6954,39 +4304,6 @@
"node": ">=8"
}
},
- "node_modules/remark-parse": {
- "version": "11.0.0",
- "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
- "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
- "license": "MIT",
- "dependencies": {
- "@types/mdast": "^4.0.0",
- "mdast-util-from-markdown": "^2.0.0",
- "micromark-util-types": "^2.0.0",
- "unified": "^11.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/remark-rehype": {
- "version": "11.1.2",
- "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
- "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
- "license": "MIT",
- "dependencies": {
- "@types/hast": "^3.0.0",
- "@types/mdast": "^4.0.0",
- "mdast-util-to-hast": "^13.0.0",
- "unified": "^11.0.0",
- "vfile": "^6.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -7127,16 +4444,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/space-separated-tokens": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
- "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -7151,20 +4458,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/stringify-entities": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
- "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
- "license": "MIT",
- "dependencies": {
- "character-entities-html4": "^2.0.0",
- "character-entities-legacy": "^3.0.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -7191,24 +4484,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/style-to-js": {
- "version": "1.1.21",
- "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
- "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
- "license": "MIT",
- "dependencies": {
- "style-to-object": "1.0.14"
- }
- },
- "node_modules/style-to-object": {
- "version": "1.0.14",
- "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
- "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
- "license": "MIT",
- "dependencies": {
- "inline-style-parser": "0.2.7"
- }
- },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -7350,26 +4625,6 @@
"node": ">=18"
}
},
- "node_modules/trim-lines": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
- "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/trough": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
- "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -7446,93 +4701,6 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
- "node_modules/unified": {
- "version": "11.0.5",
- "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
- "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0",
- "bail": "^2.0.0",
- "devlop": "^1.0.0",
- "extend": "^3.0.0",
- "is-plain-obj": "^4.0.0",
- "trough": "^2.0.0",
- "vfile": "^6.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/unist-util-is": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
- "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/unist-util-position": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
- "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/unist-util-stringify-position": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
- "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/unist-util-visit": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
- "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0",
- "unist-util-is": "^6.0.0",
- "unist-util-visit-parents": "^6.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/unist-util-visit-parents": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
- "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0",
- "unist-util-is": "^6.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -7574,86 +4742,6 @@
"punycode": "^2.1.0"
}
},
- "node_modules/use-callback-ref": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
- "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/use-sidecar": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
- "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
- "license": "MIT",
- "dependencies": {
- "detect-node-es": "^1.1.0",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/use-sync-external-store": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
- "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
- "license": "MIT",
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
- "node_modules/vfile": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
- "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0",
- "vfile-message": "^4.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
- "node_modules/vfile-message": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
- "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
- "license": "MIT",
- "dependencies": {
- "@types/unist": "^3.0.0",
- "unist-util-stringify-position": "^4.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/unified"
- }
- },
"node_modules/vite": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
@@ -8005,16 +5093,6 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
- },
- "node_modules/zwitch": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
- "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
}
}
}
diff --git a/package.json b/package.json
index 4d3f25f..4e61d3f 100644
--- a/package.json
+++ b/package.json
@@ -12,8 +12,7 @@
"test:watch": "vitest",
"format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,md}\"",
- "preview": "vite preview",
- "sync:roadmap": "node scripts/sync-roadmap.mjs"
+ "preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.103.2",
@@ -21,10 +20,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
- "radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
- "react-markdown": "^10.1.0",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1"
diff --git a/public/pcm-capture-processor.js b/public/pcm-capture-processor.js
deleted file mode 100644
index 3b1a717..0000000
--- a/public/pcm-capture-processor.js
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * pcm-capture-processor.js — AudioWorklet processor pour T2 Live (Sprint 6b).
- *
- * Capture du micro à `sampleRate` natif du navigateur (typiquement 48 kHz),
- * rééchantillonnage vers 16 kHz si nécessaire, conversion Float32 → Int16
- * little-endian, envoi par chunks de ~4096 samples (≈ 256 ms à 16 kHz).
- *
- * Format de sortie attendu par Gemini Live API :
- * PCM brut, 16 kHz, 16 bits, little-endian, mono.
- *
- * Le rééchantillonnage utilise une interpolation linéaire — équivalent
- * à `resample16kTo24k` côté audio-utils.ts mais en sens inverse.
- *
- * Vanille JS (pas TS) : les AudioWorklet processors s'exécutent dans un
- * scope global isolé qui ne peut pas importer depuis le bundle TS.
- */
-
-const TARGET_SAMPLE_RATE = 16000
-const CHUNK_SIZE_16K = 4096 // ≈ 256 ms à 16 kHz
-
-class PcmCaptureProcessor extends AudioWorkletProcessor {
- constructor() {
- super()
- this.buffer16k = new Float32Array(0)
- }
-
- /**
- * Rééchantillonne un Float32 du sample rate source vers 16 kHz par
- * interpolation linéaire. Si srcRate === 16000, no-op.
- */
- resampleTo16k(input, srcRate) {
- if (srcRate === TARGET_SAMPLE_RATE) return input
- const ratio = TARGET_SAMPLE_RATE / srcRate
- const outLength = Math.floor(input.length * ratio)
- const out = new Float32Array(outLength)
- for (let i = 0; i < outLength; i++) {
- const srcIndex = i / ratio
- const srcFloor = Math.floor(srcIndex)
- const srcCeil = Math.min(srcFloor + 1, input.length - 1)
- const frac = srcIndex - srcFloor
- out[i] = input[srcFloor] * (1 - frac) + input[srcCeil] * frac
- }
- return out
- }
-
- process(inputs) {
- const input = inputs[0]
- if (!input || !input[0]) return true
-
- const channelData = input[0] // mono
-
- // Rééchantillonner d'abord vers 16 kHz puis accumuler.
- // `sampleRate` est une variable globale du scope AudioWorklet (Web Audio spec).
- const resampled = this.resampleTo16k(channelData, sampleRate)
-
- const newBuffer = new Float32Array(this.buffer16k.length + resampled.length)
- newBuffer.set(this.buffer16k)
- newBuffer.set(resampled, this.buffer16k.length)
- this.buffer16k = newBuffer
-
- while (this.buffer16k.length >= CHUNK_SIZE_16K) {
- const chunk = this.buffer16k.slice(0, CHUNK_SIZE_16K)
- this.buffer16k = this.buffer16k.slice(CHUNK_SIZE_16K)
-
- // Float32 [-1, 1] → Int16 PCM little-endian
- const pcm = new ArrayBuffer(chunk.length * 2)
- const view = new DataView(pcm)
- for (let i = 0; i < chunk.length; i++) {
- const s = Math.max(-1, Math.min(1, chunk[i]))
- view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
- }
-
- this.port.postMessage(pcm, [pcm])
- }
-
- return true
- }
-}
-
-registerProcessor('pcm-capture-processor', PcmCaptureProcessor)
diff --git a/public/pcm-record-processor.js b/public/pcm-record-processor.js
deleted file mode 100644
index da6338b..0000000
--- a/public/pcm-record-processor.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * pcm-record-processor.js — AudioWorklet processor d'ENREGISTREMENT T2 Live
- * (Sprint 6e, Voie A — tap temps réel).
- *
- * Branché en dérivation sur le `mixGain` de capture (point de convergence
- * micro + voix IA dans le contexte PARTAGÉ). Il LIT le mix au rate NATIF du
- * contexte (typiquement 48 kHz), convertit Float32 → Int16 little-endian, et
- * envoie des chunks (~4096 samples) au thread principal via `port.postMessage`.
- *
- * Aucun rééchantillonnage : on enregistre au rate natif (le WAV est écrit à ce
- * même rate côté useAudioRecording). L'alignement temporel micro/IA est natif —
- * les deux voix partagent l'horloge unique du contexte (plus de réassemblage
- * offline à base d'offsets).
- *
- * Le node est tiré par le graphe via mixGain → recordNode → gain(0) →
- * destination (sink muet) ; ce processor n'écrit rien sur ses sorties (silence),
- * il ne fait que prélever l'entrée. Le gain(0) garantit zéro résidu audible.
- *
- * Vanille JS (pas TS) : les AudioWorklet processors s'exécutent dans un scope
- * global isolé qui ne peut pas importer depuis le bundle TS.
- */
-
-const RECORD_CHUNK_SIZE = 4096
-
-class PcmRecordProcessor extends AudioWorkletProcessor {
- constructor() {
- super()
- this.buffer = new Float32Array(0)
- }
-
- process(inputs) {
- const input = inputs[0]
- if (!input || !input[0]) return true
-
- const channelData = input[0] // mono (mix micro + IA)
-
- const merged = new Float32Array(this.buffer.length + channelData.length)
- merged.set(this.buffer)
- merged.set(channelData, this.buffer.length)
- this.buffer = merged
-
- while (this.buffer.length >= RECORD_CHUNK_SIZE) {
- const chunk = this.buffer.slice(0, RECORD_CHUNK_SIZE)
- this.buffer = this.buffer.slice(RECORD_CHUNK_SIZE)
-
- // Float32 [-1, 1] → Int16 PCM little-endian
- const pcm = new ArrayBuffer(chunk.length * 2)
- const view = new DataView(pcm)
- for (let i = 0; i < chunk.length; i++) {
- const s = Math.max(-1, Math.min(1, chunk[i]))
- view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
- }
-
- this.port.postMessage(pcm, [pcm])
- }
-
- return true
- }
-}
-
-registerProcessor('pcm-record-processor', PcmRecordProcessor)
diff --git a/scripts/sync-roadmap.mjs b/scripts/sync-roadmap.mjs
deleted file mode 100644
index 18f9a0d..0000000
--- a/scripts/sync-roadmap.mjs
+++ /dev/null
@@ -1,195 +0,0 @@
-#!/usr/bin/env node
-// @ts-check
-
-/**
- * sync-roadmap.mjs — synchronise la ROADMAP frontend → backend (sens UNIQUE).
- *
- * SOURCE DE VÉRITÉ : expria-frontend/docs/ROADMAP.md (versionnée).
- * CIBLE (copie générée) : expria-backend/docs/ROADMAP.md.
- *
- * HYPOTHÈSE D'ARBORESCENCE : les deux repos sont FRÈRES sous un parent commun,
- * p.ex. D:\expria-v2\{expria-frontend, expria-backend}. Ce script ne fonctionne
- * QUE dans cette disposition. Override possible via la variable d'environnement
- * EXPRIA_BACKEND_DIR (chemin absolu vers le repo expria-backend).
- *
- * SENS UNIQUE STRICT : jamais backend → frontend. Aucun paramètre n'inverse le sens ;
- * une vérification défensive refuse d'écrire vers un chemin contenant 'expria-frontend'.
- *
- * DÉCISION (B) — bannière auto-générée : la cible reçoit, EN TÊTE, un commentaire HTML
- * (invisible au rendu Markdown) avertissant que le fichier est généré. Le diff IGNORE
- * cette bannière (comparaison corps source vs corps cible), pour rester idempotent.
- *
- * PAS DE COMMIT AUTO : le script écrit le fichier uniquement ; le commit backend reste
- * une action manuelle validée.
- *
- * USAGE (depuis expria-frontend/) :
- * npm run sync:roadmap # interactif : montre le diff, demande confirmation y/N
- * node scripts/sync-roadmap.mjs --check # dry-run : exit 1 si désynchro, n'écrit rien
- * node scripts/sync-roadmap.mjs --yes # non-interactif : écrit sans confirmation
- */
-
-import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
-import { fileURLToPath } from 'node:url'
-import { dirname, join, resolve, sep } from 'node:path'
-import { createInterface } from 'node:readline'
-
-const BANNER =
- '\n\n'
-
-const EXPECTED_HEADER = '# ROADMAP.md'
-
-/** Affiche un message d'erreur et termine en échec. */
-function fail(message) {
- console.error(`\n[sync-roadmap] ERREUR : ${message}\n`)
- process.exit(1)
-}
-
-// --- Résolution des chemins (dérivés de la position du script) -------------
-const scriptDir = dirname(fileURLToPath(import.meta.url))
-const frontendRoot = resolve(scriptDir, '..')
-const sourcePath = join(frontendRoot, 'docs', 'ROADMAP.md')
-
-const backendRoot = process.env.EXPRIA_BACKEND_DIR
- ? resolve(process.env.EXPRIA_BACKEND_DIR)
- : resolve(frontendRoot, '..', 'expria-backend')
-const backendDocsDir = join(backendRoot, 'docs')
-const targetPath = join(backendDocsDir, 'ROADMAP.md')
-
-// --- Garde-fou : sens unique strict ----------------------------------------
-// La cible ne doit JAMAIS être (ni vivre dans) le repo source. La garde qui fait
-// foi est l'identité de chemins RÉSOLUS ; le test de sous-chaîne ne reste qu'un
-// signal secondaire. Sur Windows les chemins sont insensibles à la casse.
-const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p)
-const resolvedTarget = norm(resolve(targetPath))
-const resolvedSource = norm(resolve(sourcePath))
-const resolvedFrontend = norm(resolve(frontendRoot))
-
-// 1. Garde PRIMAIRE : cible == source (identité de chemin résolu).
-if (resolvedTarget === resolvedSource) {
- fail(`refus : la cible EST la source (sens unique). Chemin = ${targetPath}`)
-}
-// 2. Garde ÉLARGIE : cible à l'intérieur du repo source (tout fichier du frontend).
-if (resolvedTarget.startsWith(resolvedFrontend + sep)) {
- fail(`refus : la cible est à l'intérieur du repo source (sens unique). Cible = ${targetPath}`)
-}
-// 3. Garde SECONDAIRE (signal de bon sens) : nom contenant 'expria-frontend'.
-if (resolvedTarget.includes('expria-frontend')) {
- fail(`refus : la cible référence le repo source (sens unique). Cible = ${targetPath}`)
-}
-
-// --- Garde-fou : arborescence backend --------------------------------------
-if (!existsSync(backendDocsDir)) {
- fail(
- `repo backend introuvable comme frère.\n` +
- ` Attendu : ${backendDocsDir}\n` +
- ` Astuce : place expria-backend à côté de expria-frontend, ` +
- `ou exporte EXPRIA_BACKEND_DIR vers le repo backend.`,
- )
-}
-
-// --- Garde-fou : source valide ---------------------------------------------
-if (!existsSync(sourcePath)) {
- fail(`source introuvable : ${sourcePath}`)
-}
-if (statSync(sourcePath).size === 0) {
- fail(`source vide : ${sourcePath} (on ne remplace jamais la cible par du vide)`)
-}
-
-const sourceBody = readFileSync(sourcePath, 'utf8')
-if (!sourceBody.trimStart().startsWith(EXPECTED_HEADER)) {
- fail(
- `la source ne commence pas par « ${EXPECTED_HEADER} » — ` +
- `format inattendu, abandon par sécurité.`,
- )
-}
-
-// --- Calcul du contenu cible (bannière + corps source) ---------------------
-const desiredTarget = BANNER + sourceBody
-
-/** Retire la bannière auto-générée d'un contenu cible, pour comparer le corps seul. */
-function stripBanner(content) {
- if (content.startsWith('')
- if (end !== -1) {
- // Saute le '-->' puis les sauts de ligne qui le suivent.
- return content.slice(end + 3).replace(/^\r?\n+/, '')
- }
- }
- return content
-}
-
-const currentTargetRaw = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null
-const currentBody = currentTargetRaw === null ? null : stripBanner(currentTargetRaw)
-
-// --- Idempotence : déjà synchro ? ------------------------------------------
-// On compare les CORPS (bannière ignorée).
-if (currentBody === sourceBody) {
- console.log('[sync-roadmap] déjà synchro — rien à faire.')
- process.exit(0)
-}
-
-// --- Diff (corps source vs corps cible, bannière exclue) -------------------
-function printDiff(oldBody, newBody) {
- const oldLines = (oldBody ?? '').split('\n')
- const newLines = newBody.split('\n')
- const max = Math.max(oldLines.length, newLines.length)
- console.log('\n[sync-roadmap] différences (corps, bannière exclue) :')
- console.log(` source : ${sourcePath}`)
- console.log(` cible : ${targetPath}\n`)
- let shown = 0
- for (let i = 0; i < max; i++) {
- if (oldLines[i] !== newLines[i]) {
- if (oldLines[i] !== undefined) console.log(` - ${oldLines[i]}`)
- if (newLines[i] !== undefined) console.log(` + ${newLines[i]}`)
- shown++
- }
- }
- if (currentTargetRaw === null) {
- console.log(" (la cible n'existe pas encore — création complète)")
- }
- console.log(`\n ${shown} ligne(s) divergente(s).`)
-}
-
-printDiff(currentBody, sourceBody)
-
-const args = new Set(process.argv.slice(2))
-
-// --- Mode --check : dry-run, signale la désynchro, n'écrit rien ------------
-if (args.has('--check')) {
- console.error('\n[sync-roadmap] --check : cible DÉSYNCHRONISÉE (aucune écriture).\n')
- process.exit(1)
-}
-
-/** Demande une confirmation y/N (refus par défaut). */
-function confirm(question) {
- return new Promise((res) => {
- const rl = createInterface({ input: process.stdin, output: process.stdout })
- rl.question(question, (answer) => {
- rl.close()
- res(/^y(es)?$/i.test(answer.trim()))
- })
- })
-}
-
-/** Écrit la cible et rappelle la marche à suivre manuelle. */
-function writeTarget() {
- writeFileSync(targetPath, desiredTarget, 'utf8')
- console.log(`\n[sync-roadmap] écrit : ${targetPath}`)
- console.log('\n[sync-roadmap] PAS de commit automatique. Marche à suivre manuelle :')
- console.log(' cd ../expria-backend')
- console.log(' git add docs/ROADMAP.md')
- console.log(' git commit -m "docs(roadmap): sync depuis frontend"\n')
-}
-
-if (args.has('--yes')) {
- writeTarget()
- process.exit(0)
-}
-
-const ok = await confirm('\n[sync-roadmap] Écrire la cible backend ? (y/N) ')
-if (!ok) {
- console.log('[sync-roadmap] annulé — aucune écriture.')
- process.exit(0)
-}
-writeTarget()
diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx
deleted file mode 100644
index f4a4798..0000000
--- a/src/app/AppLayout.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * Layout applicatif — enveloppe toutes les routes privées.
- *
- * Desktop (≥ 1024px) : Sidebar fixe 230px + Topbar sticky + zone contenu.
- * Mobile (< 1024px) : Topbar avec hamburger + drawer slide-in + BottomNav fixe.
- *
- * Le drawer mobile se ferme automatiquement à chaque changement de route
- * (useEffect sur location.pathname).
- *
- * Règle L : tokens du design system exclusivement.
- * Règle H : aucune logique métier — plan lu depuis le cache TanStack Query.
- */
-
-import { useState, useEffect } from 'react'
-import { useLocation } from 'react-router-dom'
-import { Sidebar } from './Sidebar'
-import { Topbar } from './Topbar'
-import { BottomNav } from './BottomNav'
-import { usePlan } from '@/features/dashboard/hooks/usePlan'
-import { cn } from '@/shared/lib/utils'
-import type { Plan } from '@/entities/user/lib'
-
-interface AppLayoutProps {
- children: React.ReactNode
-}
-
-export function AppLayout({ children }: AppLayoutProps) {
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
- const location = useLocation()
- const { data } = usePlan()
- const plan: Plan = data?.plan ?? 'free'
-
- // Ferme le drawer à chaque changement de route.
- useEffect(() => {
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setIsMobileMenuOpen(false)
- }, [location.pathname])
-
- const mainBackground = `
- radial-gradient(ellipse at 35% 0%, var(--color-gradient-a), transparent 55%),
- radial-gradient(ellipse at 80% 100%, var(--color-gradient-b), transparent 50%),
- var(--color-canvas)
- `
-
- return (
-
- {/* ── DESKTOP — Sidebar fixe 230px ───────────────────────────── */}
-
-
- {/* ── MOBILE — Drawer overlay ────────────────────────────────── */}
-
setIsMobileMenuOpen(false)}
- className={cn(
- 'fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ease-out lg:hidden',
- isMobileMenuOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
- )}
- />
-
- {/* ── MOBILE — Drawer panel ──────────────────────────────────── */}
-
-
-
-
- {/* ── Zone de contenu principale ─────────────────────────────── */}
- {/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */}
-
- setIsMobileMenuOpen(true)} />
- {/* Pas de padding ni de max-width ici : chaque page gère sa propre
- largeur de contenu et son propre padding (cf. HistoriquePage). */}
- {children}
-
-
- {/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}
-
-
- )
-}
diff --git a/src/app/BottomNav.tsx b/src/app/BottomNav.tsx
deleted file mode 100644
index 40821e5..0000000
--- a/src/app/BottomNav.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * Navigation mobile fixe — affichée uniquement en dessous de 1024px.
- *
- * 4 items : Accueil / Simuler / Progression / Compte.
- * "Simuler" ouvre une bottom sheet (EE / EO / Examen blanc).
- * Tap target 44×44px minimum (DESIGN_SYSTEM.md §7).
- *
- * Règle L : tokens du design system exclusivement.
- * Règle H : aucune logique métier — navigation uniquement.
- */
-
-import { useState } from 'react'
-import { Link, useLocation, useNavigate } from 'react-router-dom'
-import { Home, BookOpen, TrendingUp, User } from 'lucide-react'
-import { cn } from '@/shared/lib/utils'
-
-const SHEET_ITEMS = [
- { label: 'Expression Écrite', to: '/simulation/ee' },
- { label: 'Expression Orale', to: '/simulation/eo' },
- { label: 'Examen blanc', to: '/examen' },
-] as const
-
-export function BottomNav() {
- const [isSheetOpen, setIsSheetOpen] = useState(false)
- const location = useLocation()
- const navigate = useNavigate()
-
- const isActive = (prefix: string) => location.pathname.startsWith(prefix)
-
- function handleSheetNavigate(to: string) {
- setIsSheetOpen(false)
- navigate(to)
- }
-
- const navItemClasses = (active: boolean) =>
- cn(
- 'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
- active ? 'text-brand-text' : 'text-ink-tertiary hover:text-ink-primary',
- )
-
- return (
- <>
- {/* Bottom sheet overlay */}
- {isSheetOpen && (
-
setIsSheetOpen(false)}
- />
- )}
-
- {/* Bottom sheet */}
- {isSheetOpen && (
-
-
- Simuler
-
-
- {SHEET_ITEMS.map((item) => (
-
- handleSheetNavigate(item.to)}
- className="flex min-h-[44px] w-full items-center rounded-md px-3 text-sm text-ink-primary transition-colors duration-150 hover:bg-surface-hover"
- >
- {item.label}
-
-
- ))}
-
-
- )}
-
- {/* Bottom nav bar */}
-
- {/* Accueil */}
-
-
- Accueil
-
-
- {/* Simuler */}
- setIsSheetOpen((v) => !v)}
- className={navItemClasses(isActive('/simulation') || isSheetOpen)}
- >
-
- Simuler
-
-
- {/* Progression */}
-
-
- Progression
-
-
- {/* Compte */}
-
-
- Compte
-
-
- >
- )
-}
diff --git a/src/app/MaintenancePage.tsx b/src/app/MaintenancePage.tsx
deleted file mode 100644
index e6ce5a8..0000000
--- a/src/app/MaintenancePage.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Logo } from '@/shared/components/Logo'
-
-export function MaintenancePage() {
- return (
-
-
-
-
Maintenance en cours
-
- Expria est temporairement indisponible. Revenez dans quelques instants.
-
-
-
- Des questions ?{' '}
-
- support@expria.ca
-
-
-
- )
-}
diff --git a/src/app/Sidebar.tsx b/src/app/Sidebar.tsx
deleted file mode 100644
index 23fe068..0000000
--- a/src/app/Sidebar.tsx
+++ /dev/null
@@ -1,233 +0,0 @@
-/**
- * Sidebar desktop — navigation principale (≥ 1024px).
- *
- * DA Charcoal : navy permanent (#0C1528), identique dark et light.
- * Règle D : le verrouillage des items passe par hasAccess(),
- * jamais par if (plan === '...').
- * Règle L : tokens du design system exclusivement.
- */
-
-import { NavLink } from 'react-router-dom'
-import {
- Activity,
- ArrowUpCircle,
- BookOpen,
- Clock,
- FileText,
- LayoutGrid,
- Lock,
- Mic,
- Pencil,
- Settings,
- User as UserIcon,
- type LucideIcon,
-} from 'lucide-react'
-import { hasAccess } from '@/entities/user/lib'
-import { Logo } from '@/shared/components/Logo'
-import { ThemeToggle } from '@/shared/components/ThemeToggle'
-import { useAuth } from '@/features/auth/hooks/useAuth'
-import { cn } from '@/shared/lib/utils'
-import type { Feature, Plan } from '@/entities/user/lib'
-import type { User } from '@/shared/lib/auth-client'
-
-interface NavItem {
- label: string
- to: string
- feature: Feature | null
- Icon: LucideIcon
- /**
- * Affiche un badge upgrade (flèche bleue) à droite du label quand
- * l'utilisateur peut encore passer à un plan supérieur. Aujourd'hui
- * utilisé uniquement sur "Mon plan".
- */
- showUpgradeWhenUpgradable?: boolean
-}
-
-const PREPARE_ITEMS: readonly NavItem[] = [
- { label: 'Tableau de bord', to: '/dashboard', feature: null, Icon: LayoutGrid },
- { label: 'Expression Écrite', to: '/simulation/ee', feature: null, Icon: Pencil },
- { label: 'Expression Orale', to: '/simulation/eo', feature: null, Icon: Mic },
- { label: 'Examen blanc', to: '/examen', feature: 'exam_mode', Icon: FileText },
- { label: 'Progression', to: '/progression', feature: 'pattern_analysis', Icon: Activity },
- { label: 'Méthodologie', to: '/methodologie', feature: null, Icon: BookOpen },
- { label: 'Historique', to: '/historique', feature: 'dashboard', Icon: Clock },
-]
-
-const ACCOUNT_ITEMS: readonly NavItem[] = [
- {
- label: 'Mon plan',
- to: '/plan',
- feature: null,
- Icon: UserIcon,
- showUpgradeWhenUpgradable: true,
- },
- { label: 'Paramètres', to: '/parametres', feature: null, Icon: Settings },
-]
-
-const PLAN_LABELS: Record
= {
- free: 'Plan Découverte',
- standard: 'Plan Standard',
- premium: 'Plan Premium',
-}
-
-/**
- * Proxy "peut encore upgrader". Examen blanc est exclusif Premium
- * (PLANS_TARIFAIRES.md §4), donc son absence d'accès = plan en dessous
- * du top-tier. Utilisé uniquement pour affichage UX, pas un check de
- * permission (Règle D respectée — on passe par hasAccess).
- */
-function isUpgradable(plan: Plan): boolean {
- return !hasAccess(plan, 'exam_mode')
-}
-
-function getInitials(user: User | null): string {
- if (!user) return '··'
- const fullName = user.user_metadata?.full_name as string | undefined
- if (fullName) {
- const parts = fullName.trim().split(/\s+/)
- if (parts.length >= 2 && parts[0] && parts[parts.length - 1]) {
- return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
- }
- return fullName.slice(0, 2).toUpperCase()
- }
- const local = user.email?.split('@')[0] ?? ''
- return local.slice(0, 2).toUpperCase() || '··'
-}
-
-function getDisplayName(user: User | null): string {
- if (!user) return 'Invité'
- const fullName = user.user_metadata?.full_name as string | undefined
- if (fullName) return fullName
- return user.email?.split('@')[0] ?? 'Invité'
-}
-
-function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
- const locked = item.feature !== null && !hasAccess(plan, item.feature)
- const showUpgrade = item.showUpgradeWhenUpgradable === true && isUpgradable(plan)
- const { Icon } = item
-
- return (
-
- cn(
- 'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2',
- 'text-[13px] font-medium transition-colors',
- isActive && !locked
- ? 'bg-[var(--color-sidebar-nav-active)] font-semibold text-[var(--color-sidebar-text-active)]'
- : locked
- ? 'cursor-default text-[var(--color-sidebar-text)] opacity-40'
- : 'text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-nav-hover)] hover:text-[var(--color-sidebar-text-hover)]',
- )
- }
- >
- {({ isActive }) => (
- <>
- {isActive && !locked && (
-
- )}
-
- {item.label}
- {locked && }
- {showUpgrade && !locked && (
-
- )}
- >
- )}
-
- )
-}
-
-function SidebarSection({
- label,
- items,
- plan,
- className,
-}: {
- label: string
- items: readonly NavItem[]
- plan: Plan
- className?: string
-}) {
- return (
-
-
- {label}
-
-
- {items.map((item) => (
-
-
-
- ))}
-
-
- )
-}
-
-function UserFooter({ plan }: { plan: Plan }) {
- const { user } = useAuth()
- const initials = getInitials(user)
- const displayName = getDisplayName(user)
-
- return (
-
-
- {initials}
-
-
-
{displayName}
-
{PLAN_LABELS[plan]}
-
-
-
- )
-}
-
-interface SidebarProps {
- plan: Plan
-}
-
-export function Sidebar({ plan }: SidebarProps) {
- return (
-
- {/* Logo header */}
-
-
-
-
- EX| PRIA
-
- Préparation TCF Canada
-
-
-
- {/* Navigation */}
-
-
-
-
-
- {/* Footer — avatar + user info + ThemeToggle */}
-
-
-
-
- )
-}
diff --git a/src/app/Topbar.tsx b/src/app/Topbar.tsx
deleted file mode 100644
index 3f76202..0000000
--- a/src/app/Topbar.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * Topbar sticky au-dessus du contenu principal.
- *
- * - Breadcrumb "Expria › " (gauche).
- * - Hamburger (mobile uniquement) qui ouvre le drawer Sidebar.
- * - Barre de recherche placeholder (non fonctionnelle — visuel only).
- * - Icônes Command (raccourcis clavier) et Bell (notifications) —
- * non fonctionnelles, décoratives dans ce sprint.
- *
- * Règle L : tokens du design system exclusivement.
- * Règle H : aucune logique métier — navigation uniquement.
- */
-
-import { useLocation } from 'react-router-dom'
-import { Bell, Command, Menu, Search } from 'lucide-react'
-import { getRouteTitle } from './route-titles'
-
-interface TopbarProps {
- onMobileMenuOpen: () => void
-}
-
-export function Topbar({ onMobileMenuOpen }: TopbarProps) {
- const { pathname } = useLocation()
- const title = getRouteTitle(pathname)
-
- return (
-
- {/* Hamburger (mobile only) */}
-
-
-
-
- {/* Breadcrumb */}
-
-
- Expria
-
- ›
-
- {title}
-
-
-
- {/* Right cluster: search + command + bell */}
-
-
- )
-}
diff --git a/src/app/main.tsx b/src/app/main.tsx
index 31cd894..798dc2c 100644
--- a/src/app/main.tsx
+++ b/src/app/main.tsx
@@ -1,8 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Providers } from './providers'
-import { MaintenancePage } from './MaintenancePage'
-import { isMaintenanceMode } from '@/shared/config/env'
import '../index.css'
const container = document.getElementById('root')
@@ -11,5 +9,7 @@ if (!container) {
}
createRoot(container).render(
- {isMaintenanceMode ? : } ,
+
+
+ ,
)
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index 915515d..1a6feff 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -1,39 +1,14 @@
-import { useState, useEffect } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { queryClient } from '@/shared/lib/query-client'
-import {
- ThemeContext,
- type Theme,
- getInitialTheme,
- applyTheme,
- persistTheme,
-} from '@/shared/lib/theme'
import { AppRouter } from './router'
-function ThemeProvider({ children }: { children: React.ReactNode }) {
- const [theme, setThemeState] = useState(getInitialTheme)
-
- useEffect(() => {
- applyTheme(theme)
- persistTheme(theme)
- }, [theme])
-
- function setTheme(t: Theme) {
- setThemeState(t)
- }
-
- return {children}
-}
-
export function Providers() {
return (
-
-
-
-
-
-
-
+
+
+
+
+
)
}
diff --git a/src/app/route-titles.ts b/src/app/route-titles.ts
deleted file mode 100644
index 2699600..0000000
--- a/src/app/route-titles.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * Mapping centralisé pathname → titre de page.
- *
- * Consommé par la Topbar (breadcrumb "Expria › ") et tout composant
- * qui a besoin du libellé humain d'une route. Maintient la source unique
- * — pas de duplication dans Sidebar/Topbar/helmet.
- */
-
-const STATIC_ROUTES: Readonly> = {
- '/dashboard': 'Tableau de bord',
- '/simulation/ee': 'Expression Écrite',
- '/simulation/eo': 'Expression Orale',
- '/sujets': 'Choisir un sujet',
- '/examen': 'Examen blanc',
- '/progression': 'Progression',
- '/methodologie': 'Méthodologie',
- '/historique': 'Historique',
- '/plan': 'Mon plan',
- '/parametres': 'Paramètres',
- '/login': 'Connexion',
- '/register': 'Inscription',
- '/design-system': 'Design System',
-}
-
-export function getRouteTitle(pathname: string): string {
- const exact = STATIC_ROUTES[pathname]
- if (exact) return exact
- if (pathname.startsWith('/rapport/')) return 'Rapport'
- if (pathname === '/' || pathname === '') return 'Tableau de bord'
- return 'Expria'
-}
diff --git a/src/app/router.tsx b/src/app/router.tsx
index 54ec63c..dfe0538 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -1,138 +1,18 @@
-import React, { Suspense } from 'react'
-import { Navigate, Outlet, Routes, Route } from 'react-router-dom'
-
-import { LoginPage } from '@/features/auth/pages/LoginPage'
-import { RegisterPage } from '@/features/auth/pages/RegisterPage'
-import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
-import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
-import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
-import { SujetsPage } from '@/features/simulations/pages/SujetsPage'
-import { RapportPage } from '@/features/simulations/pages/RapportPage'
-import { SimulationEOPage } from '@/features/simulations/pages/SimulationEOPage'
-import { SujetsEOPage } from '@/features/simulations/pages/SujetsEOPage'
-import { PreEnregistrementEOPage } from '@/features/simulations/pages/PreEnregistrementEOPage'
-import { EnregistrementEOPage } from '@/features/simulations/pages/EnregistrementEOPage'
-import { ModeChoixT1Page } from '@/features/simulations/pages/ModeChoixT1Page'
-import { QuestionnaireT1Page } from '@/features/simulations/pages/QuestionnaireT1Page'
-import { PresentationGenereeT1Page } from '@/features/simulations/pages/PresentationGenereeT1Page'
-import { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
-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 { T1PreparationPage } from '@/features/t1-live/pages/T1PreparationPage'
-import { T1DialoguePage } from '@/features/t1-live/pages/T1DialoguePage'
-import { AppLayout } from './AppLayout'
-
-const DesignSystemPage = import.meta.env.DEV
- ? React.lazy(() => import('@/features/design-system/DesignSystemPage'))
- : () => null
-
-function ComingSoon() {
- return (
-
-
Page en cours de développement
-
Disponible dans une prochaine version.
-
- )
-}
-
-function PrivateLayout() {
- return (
-
-
-
-
-
- )
-}
-
-function SimulationFlowLayout() {
- return (
-
-
-
- )
-}
-
-function T2LiveLayout() {
- return (
-
-
-
- )
-}
+import { Routes, Route } from 'react-router-dom'
export function AppRouter() {
return (
- {/* ── Routes publiques ─────────────────────────────────────── */}
- } />
- } />
-
- {/* ── Routes privées — ProtectedRoute + AppLayout ──────────── */}
- }>
- } />
- } />
-
- {/* Simulation — /simulation/ee, /sujets et /rapport/:id partagent le
- SimulationFlowProvider. L'instance est préservée entre ces routes
- par React Router tant que le layout parent reste monté, ce qui
- permet à RapportPage.reset() d'agir sur le même state que
- SimulationPage (bouton « Nouvelle simulation » + breadcrumb). */}
- } />
- }>
- } />
- } />
- {/* Sprint 4c-1 — flow EO */}
- } />
- } />
- } />
- } />
- {/* Sprint 4c-2 — flux T1 (questionnaire + génération + présentation) */}
- } />
- } />
- } />
- } />
-
-
- {/* Sprint 6c — T2 Live (Premium) : sélection sujet → prépa 2min → dialogue 3:30 */}
- }>
- } />
- } />
- } />
-
-
- {/* Sprint 7b — T1 Live (Premium) : prépa 2min → dialogue 3:00 avec
- interruption non déterministe de l'examinateur. Entrée directe depuis
- la carte TaskSelector (plus de questionnaire — Patch 7a backend). */}
- } />
- } />
-
- {/* Autres sections — Sprint 4+ */}
- } />
- } />
- } />
- } />
- } />
- } />
-
-
- {/* ── Dev only ─────────────────────────────────────────────── */}
- {import.meta.env.DEV && (
- Loading… }>
-
-
- }
- />
- )}
+
} />
)
}
+
+function ScaffoldPlaceholder() {
+ return (
+
+ Expria — scaffold Sprint 0
+ Aucune feature n'est encore branchée. Les routes seront ajoutées au fil des sprints.
+
+ )
+}
diff --git a/src/entities/patterns/api.ts b/src/entities/patterns/api.ts
deleted file mode 100644
index 9005b09..0000000
--- a/src/entities/patterns/api.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Appels API du domaine `patterns`.
- *
- * Endpoint unique : `GET /users/patterns`.
- * - Plan non-Premium → 403 PLAN_INSUFFICIENT (géré côté hook via `enabled`).
- * - < 5 productions corrigées → 200 { ready: false, minimum, current }.
- * - ≥ 5 productions → 200 { ready: true, patterns, exercises, preparation_index, ... }.
- *
- * Timeout par défaut : 10 s (largement suffisant — le backend retourne sur cache
- * sauf recompute + DeepSeek qui peut prendre jusqu'à 20 s côté serveur, mais
- * reste sous le timeout HTTP côté proxy). Retry activé par défaut sur GET.
- */
-
-import { apiFetch } from '@/shared/lib/api-client'
-import type { PatternsResponse } from './types'
-
-const PATTERNS_TIMEOUT_MS = 25_000
-
-export function getPatterns(): Promise
{
- return apiFetch('/users/patterns', {
- timeoutMs: PATTERNS_TIMEOUT_MS,
- })
-}
diff --git a/src/entities/patterns/types.ts b/src/entities/patterns/types.ts
deleted file mode 100644
index a9d5212..0000000
--- a/src/entities/patterns/types.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Types publics du domaine `patterns` — Sprint 3.6c.
- *
- * Miroir de la réponse backend `GET /users/patterns` (cf. expria-backend
- * Sprint 3.6c, commit c48ae8d). Feature Premium uniquement — le gating se
- * fait via `hasAccess(plan, 'pattern_analysis')` côté frontend ; la route
- * backend renvoie 403 `PLAN_INSUFFICIENT` si plan non Premium (fallback
- * défensif).
- */
-
-import type { CritereCode } from '@/entities/report/types'
-
-export interface Pattern {
- code: string
- critere: CritereCode
- frequency: number // 3, 4 ou 5 (seuil d'agrégation : ≥ 3)
- description: string | null // non-null uniquement pour code === 'autre'
-}
-
-export interface PatternExercice {
- code: string
- critere: CritereCode
- diagnostic: string
- exercice: {
- consigne: string
- exemple: string // phrase incorrecte générique (pas du candidat)
- correction: string // version correcte
- astuce: string // procédé mnémotechnique / réflexe de relecture
- }
-}
-
-export interface PreparationIndex {
- score: number // 0-100 entier
- message: string // interprétation textuelle fixée par le backend
-}
-
-export interface PatternsReady {
- ready: true
- patterns: Pattern[]
- exercises: PatternExercice[]
- preparation_index: PreparationIndex
- analyzed_productions: number
- last_analysis: string // ISO timestamp
-}
-
-export interface PatternsNotReady {
- ready: false
- minimum: number // toujours 5 côté backend actuel
- current: number // nb de productions corrigées déjà réalisées
-}
-
-export type PatternsResponse = PatternsReady | PatternsNotReady
diff --git a/src/entities/presentation/__tests__/api.test.ts b/src/entities/presentation/__tests__/api.test.ts
deleted file mode 100644
index c8d5a69..0000000
--- a/src/entities/presentation/__tests__/api.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Tests du domaine `presentation` — Sprint 4c-2.
- *
- * Valide :
- * - succès : retourne { presentation }
- * - erreur : ApiError propagée par apiFetch
- */
-
-import { describe, expect, it, vi, beforeEach } from 'vitest'
-
-vi.mock('@/shared/lib/api-client', () => ({
- apiFetch: vi.fn(),
-}))
-
-import { apiFetch } from '@/shared/lib/api-client'
-import { generatePresentation } from '../api'
-import type { PresentationReponses } from '../types'
-
-const mocked = vi.mocked(apiFetch)
-
-const VALID_REPONSES: PresentationReponses = {
- prenom_age_ville: 'Marie, 32 ans, Douala',
- formation_metier: 'Master en gestion, comptable',
- situation_familiale: 'Mariée, deux enfants',
- loisirs: 'Lecture, cuisine',
- motivation_canada: 'Opportunités, départ 2025',
-}
-
-describe('generatePresentation', () => {
- beforeEach(() => {
- mocked.mockReset()
- })
-
- it('retourne la présentation générée et appelle le bon endpoint', async () => {
- mocked.mockResolvedValueOnce({ presentation: 'Bonjour, je m appelle Marie...' })
-
- const result = await generatePresentation(VALID_REPONSES)
-
- expect(result.presentation).toContain('Marie')
- expect(mocked).toHaveBeenCalledWith('/presentations/generate', {
- method: 'POST',
- body: { reponses: VALID_REPONSES },
- timeoutMs: 25_000,
- retry: { max: 0, baseDelayMs: 0 },
- })
- })
-
- it('propage les ApiError du backend', async () => {
- mocked.mockRejectedValueOnce({
- error: true,
- code: 'INTERNAL_ERROR',
- message: 'DeepSeek down',
- })
-
- await expect(generatePresentation(VALID_REPONSES)).rejects.toMatchObject({
- code: 'INTERNAL_ERROR',
- })
- })
-})
diff --git a/src/entities/presentation/api.ts b/src/entities/presentation/api.ts
deleted file mode 100644
index 1b04aa4..0000000
--- a/src/entities/presentation/api.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Appels API du domaine `presentation` — Sprint 4c-2.
- *
- * `POST /presentations/generate` : timeout 25 s (DeepSeek peut mettre 10-20 s),
- * retry désactivé volontairement — un POST non-idempotent qui consomme un
- * appel DeepSeek ne doit pas être rejoué silencieusement sur erreur réseau.
- */
-
-import { apiFetch } from '@/shared/lib/api-client'
-import type { PresentationGenerated, PresentationReponses } from './types'
-
-const GENERATE_TIMEOUT_MS = 25_000
-
-export function generatePresentation(
- reponses: PresentationReponses,
-): Promise {
- return apiFetch('/presentations/generate', {
- method: 'POST',
- body: { reponses },
- timeoutMs: GENERATE_TIMEOUT_MS,
- retry: { max: 0, baseDelayMs: 0 },
- })
-}
diff --git a/src/entities/presentation/types.ts b/src/entities/presentation/types.ts
deleted file mode 100644
index feade68..0000000
--- a/src/entities/presentation/types.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Types publics du domaine `presentation` — Sprint 4c-2.
- *
- * Le domaine couvre la génération assistée d'un texte de présentation
- * personnelle (Tâche 1 EO). Aucune persistance backend : le texte généré
- * est mirroré côté client (localStorage `expria_eo_t1_presentation`) et
- * porté dans le state du `SimulationFlowProvider` pour servir de
- * référence pendant l'enregistrement.
- */
-
-/** Réponses au questionnaire — alignées sur le body du backend. */
-export interface PresentationReponses {
- prenom_age_ville: string
- formation_metier: string
- situation_familiale: string
- loisirs: string
- motivation_canada: string
-}
-
-/** Réponse de `POST /presentations/generate`. */
-export interface PresentationGenerated {
- presentation: string
-}
diff --git a/src/entities/production/api.ts b/src/entities/production/api.ts
deleted file mode 100644
index ab845cc..0000000
--- a/src/entities/production/api.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-/**
- * Appels API du domaine `production`.
- *
- * Toutes les requêtes passent par `apiFetch` (Règle J / Règle F).
- * - `POST /simulations` : timeout 5s (défaut), retry désactivé (POST non-idempotent).
- * - `GET /simulations/:id` : timeout 5s, retry activé (GET idempotent).
- *
- * Erreurs notables : `QUOTA_REACHED` (Free 5/5), `PLAN_INSUFFICIENT` (exam_mode).
- */
-
-import { apiFetch } from '@/shared/lib/api-client'
-import type {
- CreateSimulationPayload,
- Production,
- SimulationState,
- SimulationsListResponse,
- SujetData,
- Tache,
-} from './types'
-
-/** Crée une nouvelle simulation. Endpoint : `POST /simulations` (HTTP 201). */
-export function createSimulation(payload: CreateSimulationPayload): Promise {
- return apiFetch('/simulations', { method: 'POST', body: payload })
-}
-
-/** Récupère une simulation existante. Endpoint : `GET /simulations/:id`. */
-export function getSimulation(id: string): Promise {
- return apiFetch(`/simulations/${id}`)
-}
-
-/**
- * Sprint 3.7 — liste paginée des simulations de l'utilisateur connecté.
- * Endpoint : `GET /simulations?page=X&limit=Y`. Tri `created_at DESC` côté backend.
- * Champs lourds exclus (contenu, rapport, exercices, modele) — cf. SimulationListItem.
- */
-export function listSimulations(page: number, limit: number): Promise {
- const qs = new URLSearchParams({ page: String(page), limit: String(limit) })
- return apiFetch(`/simulations?${qs.toString()}`)
-}
-
-/**
- * FTD-21 — récupère l'état complet d'une simulation (contenu + sujet + rapport).
- * Utilisé par `SimulationFlowProvider` pour restaurer une session depuis
- * `localStorage.expria_simulation_id` au mount.
- *
- * Si `rapport === null` → simulation en cours, restaurer `/simulation/ee`.
- * Sinon → simulation terminée, rediriger vers `/rapport/:id`.
- */
-export function getSimulationState(id: string): Promise {
- return apiFetch(`/simulations/${id}`)
-}
-
-/**
- * FTD-21 — autosave du contenu (debounce 30 s + beforeunload).
- * Endpoint : `PATCH /simulations/:id/contenu`.
- * Ne retourne rien : le client conserve déjà le texte localement.
- */
-export async function autosaveContenu(id: string, contenu: string): Promise {
- await apiFetch<{ ok: true }>(`/simulations/${id}/contenu`, {
- method: 'PATCH',
- body: { contenu },
- })
-}
-
-/**
- * FTD-21 — persiste un changement de sujet côté backend.
- * Endpoint : `PATCH /simulations/:id/sujet`.
- * Appelé depuis `SimulationFlowProvider.changeSubject` quand l'utilisateur
- * choisit un autre sujet via `/sujets`.
- */
-export async function updateSujet(id: string, sujetId: string): Promise {
- await apiFetch<{ sujet: SujetData }>(`/simulations/${id}/sujet`, {
- method: 'PATCH',
- body: { sujet_id: sujetId },
- })
-}
-
-/**
- * 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).
- *
- * 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) {
- case 'EE_T1':
- return { mode: 'EE', tacheNumber: 1 }
- case 'EE_T2':
- 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':
- return null
- }
-}
-
-/**
- * Récupère la liste des sujets actifs disponibles pour une tâche.
- * Endpoint : `GET /sujets?mode=XX&tache=N`.
- *
- * Retourne `[]` immédiatement pour les tâches sans catalogue (EO_T1).
- */
-export function getSujets(tache: Tache): Promise {
- const params = mapTacheToSujetParams(tache)
- if (!params) return Promise.resolve([])
- const qs = new URLSearchParams({ mode: params.mode, tache: String(params.tacheNumber) })
- return apiFetch<{ sujets: SujetData[] }>(`/sujets?${qs.toString()}`).then((r) => r.sujets)
-}
diff --git a/src/entities/production/lib.ts b/src/entities/production/lib.ts
deleted file mode 100644
index 47edaba..0000000
--- a/src/entities/production/lib.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Helpers purs du domaine `production`.
- * Aucune logique de plan en dur — les règles d'accès passent par entities/user/lib.
- */
-
-import type { Tache } from './types'
-
-const TACHE_LABELS: Record = {
- EE_T1: 'Expression Écrite — Tâche 1',
- 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',
-}
-
-/** Libellé long d'une tâche, à afficher dans l'UI. */
-export function formatTache(tache: Tache): string {
- return TACHE_LABELS[tache]
-}
-
-/** Vrai si la tâche est une tâche d'Expression Écrite. */
-export function isEcrit(tache: Tache): boolean {
- return tache.startsWith('EE_')
-}
-
-/** Vrai si la tâche est une tâche d'Expression Orale (T1 ou T3 — hors T2 Live). */
-export function isOral(tache: Tache): boolean {
- return tache.startsWith('EO_')
-}
diff --git a/src/entities/production/types.ts b/src/entities/production/types.ts
deleted file mode 100644
index 0cc4806..0000000
--- a/src/entities/production/types.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-/**
- * Types publics du domaine `production`.
- *
- * Une `Production` correspond à une simulation créée via `POST /simulations`.
- * Le backend renvoie directement l'objet métier (pas d'enveloppe — cf. ARCHITECTURE.md §5).
- *
- * EO_T2 (T2 Live) est exclu de ce domaine : il passe par WebSocket et est géré
- * dans `features/t2-live` (Sprint 6).
- *
- * SEC-05 : les payloads de correction contiennent du texte utilisateur brut.
- * Ne jamais les injecter comme HTML — passer par react-markdown dans les composants.
- */
-
-/**
- * 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'
-
-/**
- * Sujet d'examen (consigne + documents) associé à une production.
- *
- * Retourné par `POST /simulations` depuis la table `sujets` (filtrage actif + tâche).
- * `null` lorsque la table ne contient aucun sujet actif pour la tâche, ou pour `EO_T2_LIVE`
- * (interaction live sans sujet pré-défini). Voir TECH_DEBT FTD-21 pour la persistance
- * via `GET /simulations/:id` (pas encore branchée).
- */
-export interface SujetData {
- id: string
- consigne: string
- role: string | null
- contexte: string | null
- doc1_titre: string | null
- doc1_texte: string | null
- doc2_titre: string | null
- doc2_texte: string | null
-}
-
-/**
- * Réponse du backend pour `POST /simulations` (HTTP 201).
- * Format confirmé par l'audit backend 2026-04-17 (cf. ARCHITECTURE.md §5).
- *
- * FTD-21 : `contenu` et `sujet_id` sont persistés côté backend pour permettre
- * la restauration de session. Ils ne sont pas retournés par `POST /simulations`
- * mais peuvent être hydratés via `getSimulationState(id)` pour le resume.
- */
-export interface Production {
- id: string
- tache: Tache
- mode: Mode
- created_at: string
- sujet: SujetData | null
- contenu?: string
- sujet_id?: string
-}
-
-/** Corps de la requête `POST /simulations`. */
-export interface CreateSimulationPayload {
- tache: Tache
- mode: Mode
-}
-
-/**
- * Réponse du backend pour `GET /simulations/:id` (FTD-21).
- *
- * Contrairement au `Report` pur, cette réponse porte :
- * - le contenu textuel en cours (pour restaurer la textarea)
- * - le sujet joint (pour restaurer `SujetDisplay`)
- * - le rapport si disponible (sinon `null` → simulation en cours)
- *
- * Si `rapport === null`, le frontend restaure la session `/simulation/ee`.
- * Sinon, il redirige vers `/rapport/:id`.
- */
-export interface SimulationState {
- simulation_id: string
- tache: Tache
- mode: Mode
- created_at: string
- contenu: string | null
- sujet: SujetData | null
- rapport: SimulationRapport | null
- // Sprint 3.6a — nouveaux champs backend (null/pending si rapport non encore corrigé)
- nclc_cible: 9 | 10 | null
- exercices: SimulationExercice[] | null
- exercices_status: SimulationJobStatus
- modele: SimulationProductionModele | null
- modele_status: SimulationJobStatus
-}
-
-export type SimulationJobStatus = 'pending' | 'ready' | 'error'
-
-/**
- * Sprint 3.7 — Item léger pour la liste /historique.
- * Miroir de `ListItem` côté backend (GET /simulations — pagination).
- * Les champs lourds (contenu, rapport, exercices, modele) sont **exclus**.
- */
-export interface SimulationListItem {
- id: string
- tache: Tache
- mode: Mode
- score: number | null
- nclc: number | null
- nclc_cible: 9 | 10 | null
- created_at: string
-}
-
-export interface SimulationsListResponse {
- data: SimulationListItem[]
- pagination: {
- page: number
- limit: number
- total: number
- }
-}
-
-/**
- * Rapport tel que stocké par le backend (sans `simulation_id`, `exercices`, `modele`
- * qui sont portés par SimulationState). Miroir de `CorrectionRapport` côté backend
- * (Sprint 3.6a). Ré-exposé ici pour éviter l'import circulaire avec
- * `entities/report/types.ts`.
- */
-export interface SimulationRapport {
- score: number
- nclc: number
- nclc_cible: 9 | 10
- revelation: { croyance: string; realite: string; consequence: string }
- diagnostic: string
- criteres: {
- nom: string
- score: number
- commentaire: string
- exemple: string
- suggestion: string
- astuce: string
- }[]
- conseil_nclc: { nclc_cible: string; ecart: string; action_prioritaire: string }
- erreurs_codes: { code: string; critere: string; description: string | null }[]
-}
-
-export interface SimulationExercice {
- difficulte: 'facile' | 'intermediaire' | 'difficile'
- theme: string
- diagnostic: string
- consigne: string
- extrait: string
- indice: string
- correction: string
- explication: string
-}
-
-export interface SimulationProductionModele {
- production_modele_propre: string
- notes_pedagogiques: { passage: string; explication: string }[]
- transformations: { original: string; ameliore: string; explication: string }[]
- message: string
- nclc_modele?: number
- nclc_obtenu?: number
- score_cible?: number
- tcf_word_count?: number
- tcf_word_min?: number
- tcf_word_max?: number
- tcf_truncated?: boolean
-}
diff --git a/src/entities/report/__tests__/floutage.test.ts b/src/entities/report/__tests__/floutage.test.ts
deleted file mode 100644
index 7f39030..0000000
--- a/src/entities/report/__tests__/floutage.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * Tests — matrice de floutage Sprint 3.6b.
- *
- * Nouvelle matrice :
- * - Floutables : criteres (detailed_report), exercices + modele (tips)
- * - Toujours visibles : score, nclc, revelation, diagnostic, conseil_nclc
- *
- * Source de vérité : PLANS_TARIFAIRES.md §2.
- */
-
-import { describe, it, expect } from 'vitest'
-import { isSectionVisible, groupErreursByCritere, ecartVsCible, critereCodeFromNom } from '../lib'
-import type { ErreurCode } from '../types'
-
-describe('isSectionVisible — plan free', () => {
- it('criteres : non visible (detailed_report = false)', () => {
- expect(isSectionVisible('free', 'criteres')).toBe(false)
- })
-
- it('exercices : non visible (tips = false)', () => {
- expect(isSectionVisible('free', 'exercices')).toBe(false)
- })
-
- it('modele : non visible (tips = false)', () => {
- expect(isSectionVisible('free', 'modele')).toBe(false)
- })
-})
-
-describe('isSectionVisible — plan standard', () => {
- it('criteres : visible (detailed_report = true)', () => {
- expect(isSectionVisible('standard', 'criteres')).toBe(true)
- })
-
- it('exercices : visible (tips = true)', () => {
- expect(isSectionVisible('standard', 'exercices')).toBe(true)
- })
-
- it('modele : visible (tips = true)', () => {
- expect(isSectionVisible('standard', 'modele')).toBe(true)
- })
-})
-
-describe('isSectionVisible — plan premium', () => {
- it('criteres : visible', () => {
- expect(isSectionVisible('premium', 'criteres')).toBe(true)
- })
-
- it('exercices : visible', () => {
- expect(isSectionVisible('premium', 'exercices')).toBe(true)
- })
-
- it('modele : visible', () => {
- expect(isSectionVisible('premium', 'modele')).toBe(true)
- })
-})
-
-describe('groupErreursByCritere', () => {
- it('regroupe les erreurs par code critère', () => {
- const erreurs: ErreurCode[] = [
- { code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
- { code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
- { code: 'virgule_exces', critere: 'competence_grammaticale', description: null },
- ]
- const grouped = groupErreursByCritere(erreurs)
- expect(grouped.competence_grammaticale).toHaveLength(2)
- expect(grouped.coherence_cohesion).toHaveLength(1)
- expect(grouped.competence_lexicale).toHaveLength(0)
- expect(grouped.adequation_tache).toHaveLength(0)
- })
-
- it('retourne les 4 critères même sur liste vide', () => {
- const grouped = groupErreursByCritere([])
- expect(Object.keys(grouped)).toHaveLength(4)
- expect(grouped.competence_grammaticale).toEqual([])
- })
-})
-
-describe('ecartVsCible', () => {
- it('NCLC 9 atteint (score = 14)', () => {
- expect(ecartVsCible(14, 9)).toEqual({ points: 0, atteint: true })
- })
-
- it('NCLC 9 non atteint (score = 12)', () => {
- expect(ecartVsCible(12, 9)).toEqual({ points: 2, atteint: false })
- })
-
- it('NCLC 10 atteint (score = 18)', () => {
- expect(ecartVsCible(18, 10)).toEqual({ points: 0, atteint: true })
- })
-
- it('score supérieur à cible : atteint + points=0', () => {
- expect(ecartVsCible(20, 9)).toEqual({ points: 0, atteint: true })
- })
-})
-
-describe('critereCodeFromNom', () => {
- it('mappe les 4 libellés officiels', () => {
- expect(critereCodeFromNom('Adéquation à la tâche et au registre')).toBe('adequation_tache')
- expect(critereCodeFromNom('Cohérence et cohésion du discours')).toBe('coherence_cohesion')
- expect(critereCodeFromNom('Compétence lexicale')).toBe('competence_lexicale')
- expect(critereCodeFromNom('Compétence grammaticale')).toBe('competence_grammaticale')
- })
-
- it('retourne null sur un libellé inconnu', () => {
- expect(critereCodeFromNom('Autre chose')).toBeNull()
- })
-})
diff --git a/src/entities/report/__tests__/getMaxScorePerCritere.test.ts b/src/entities/report/__tests__/getMaxScorePerCritere.test.ts
deleted file mode 100644
index 91482b6..0000000
--- a/src/entities/report/__tests__/getMaxScorePerCritere.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { getMaxScorePerCritere, critereCodeFromNom } from '../lib'
-import type { Critere } from '../types'
-
-function critere(nom: string, score: number): Critere {
- return { nom, score, commentaire: '', exemple: '', suggestion: '', astuce: '' }
-}
-
-describe('getMaxScorePerCritere — Sprint 4.8', () => {
- it('5 critères (EO Sprint 4.8) → maxScore = 4', () => {
- const rapport = {
- criteres: [
- critere('Adéquation à la tâche', 3),
- critere('Cohérence et cohésion', 3),
- critere('Étendue et maîtrise du lexique', 2),
- critere('Maîtrise morphosyntaxique', 3),
- critere('Phonologie', 3),
- ],
- }
- expect(getMaxScorePerCritere(rapport)).toBe(4)
- })
-
- it('4 critères (EE / EO legacy) → maxScore = 5', () => {
- const rapport = {
- criteres: [
- critere('Adéquation à la tâche et au registre', 4),
- critere('Cohérence et cohésion du discours', 3),
- critere('Compétence lexicale', 3),
- critere('Compétence grammaticale', 4),
- ],
- }
- expect(getMaxScorePerCritere(rapport)).toBe(5)
- })
-
- it('0 critère (cas limite) → maxScore = 5 (défaut sécurité)', () => {
- expect(getMaxScorePerCritere({ criteres: [] })).toBe(5)
- })
-
- it('6+ critères (cas hypothétique) → maxScore = 5 (défaut sécurité)', () => {
- const rapport = {
- criteres: Array.from({ length: 6 }, (_, i) => critere(`c${i}`, 0)),
- }
- expect(getMaxScorePerCritere(rapport)).toBe(5)
- })
-})
-
-describe('critereCodeFromNom — extension Sprint 4.8 EO', () => {
- it('mappe les libellés EO Sprint 4.8 vers les codes taxonomie', () => {
- expect(critereCodeFromNom('Adéquation à la tâche')).toBe('adequation_tache')
- expect(critereCodeFromNom('Cohérence et cohésion')).toBe('coherence_cohesion')
- expect(critereCodeFromNom('Étendue et maîtrise du lexique')).toBe('competence_lexicale')
- expect(critereCodeFromNom('Maîtrise morphosyntaxique')).toBe('competence_grammaticale')
- })
-
- it("Phonologie n'a pas de code taxonomie → null", () => {
- expect(critereCodeFromNom('Phonologie')).toBeNull()
- })
-
- it('libellés EE legacy toujours mappés (rétrocompat)', () => {
- expect(critereCodeFromNom('Adéquation à la tâche et au registre')).toBe('adequation_tache')
- expect(critereCodeFromNom('Compétence lexicale')).toBe('competence_lexicale')
- })
-})
diff --git a/src/entities/report/api.ts b/src/entities/report/api.ts
deleted file mode 100644
index 36f8bca..0000000
--- a/src/entities/report/api.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * Appels API du domaine `report`.
- *
- * Toutes les requêtes passent par `apiFetch` (Règle J / Règle F).
- * Timeout 30 s : DeepSeek (EE) et Gemini (EO) peuvent mettre jusqu'à 20-25 s.
- * Retry désactivé : POST non-idempotent — une double soumission créerait
- * deux corrections facturées sur le quota Free.
- *
- * Erreurs notables : SIMULATION_NOT_FOUND (404), AUTH_REQUIRED (401),
- * QUOTA_REACHED (403 — côté simulation, pas correction).
- */
-
-import { apiFetch } from '@/shared/lib/api-client'
-import { getSimulationState } from '@/entities/production/api'
-import type { CorrectEePayload, CorrectEoPayload, Report } from './types'
-
-/**
- * Récupère un rapport existant. Endpoint : `GET /simulations/:id`.
- *
- * FTD-21 : depuis l'assouplissement de `getById` côté backend (tolère `rapport=null`
- * pour permettre le resume), on unwrap le champ `rapport` du `SimulationState`.
- * Si la simulation est encore en cours (`rapport === null`), on lève une erreur
- * typée `REPORT_NOT_READY` que RapportPage catche pour rediriger vers /simulation/ee.
- */
-export function getReport(id: string): Promise {
- return getSimulationState(id).then((state) => {
- if (state.rapport === null) {
- throw {
- error: true,
- code: 'REPORT_NOT_READY',
- message: 'Simulation en cours — rédaction pas encore corrigée.',
- }
- }
- // Sprint 3.6b : reconstruit un Report en combinant rapport (correction) +
- // exercices / modele (jobs fire-and-forget, portés par SimulationState).
- return {
- ...state.rapport,
- simulation_id: state.simulation_id,
- tache: state.tache,
- erreurs_codes: state.rapport.erreurs_codes as Report['erreurs_codes'],
- exercices: state.exercices as Report['exercices'],
- exercices_status: state.exercices_status,
- modele: state.modele as Report['modele'],
- modele_status: state.modele_status,
- }
- })
-}
-
-// Sprint 3.6a — le nouveau prompt maître (taxonomie + revelation + diagnostic +
-// criteres×6 champs + conseil_nclc + erreurs_codes) produit un JSON long ;
-// DeepSeek met typiquement 25-45 s pour répondre. Backend abort à 55 s.
-const CORRECTION_EE_TIMEOUT_MS = 60_000
-
-// Sprint 4b.3 — EO en mode audio enchaîne Gemini transcribe (jusqu'à 60 s,
-// 30 s + 1 retry de 30 s) puis DeepSeek correction (55 s côté backend).
-// Pire cas serveur ≈ 115 s : on alloue 120 s côté client pour ne pas couper
-// avant que la mutation aboutisse (le rapport apparaissait sinon dans
-// l'historique sans navigation vers /rapport/:id).
-const CORRECTION_EO_TIMEOUT_MS = 120_000
-
-/** Soumet une production écrite pour correction. Endpoint : `POST /corrections/ee`.
- * Payload : { simulationId, contenu, tache }
- */
-export function correctEe(payload: CorrectEePayload): Promise {
- return apiFetch('/corrections/ee', {
- method: 'POST',
- body: payload,
- timeoutMs: CORRECTION_EE_TIMEOUT_MS,
- })
-}
-
-/**
- * Soumet une production orale pour correction. Endpoint : `POST /corrections/eo`.
- * Payload : { simulationId, transcript, tache } — transcript implémenté Sprint 4.
- */
-export function correctEo(payload: CorrectEoPayload): Promise {
- return apiFetch('/corrections/eo', {
- method: 'POST',
- body: payload,
- timeoutMs: CORRECTION_EO_TIMEOUT_MS,
- })
-}
-
-const IDEES_TIMEOUT_MS = 15_000
-
-/**
- * Récupère 5 suggestions d'idées DeepSeek pour prolonger la rédaction en cours.
- * Endpoint : `POST /sujets/idees`. Tâche G5.
- * Contraintes backend : sujet_consigne non vide + contenu_partiel ≥ 30 mots.
- */
-export function getIdees(consigne: string, contenu: string): Promise {
- return apiFetch<{ idees: string[] }>('/sujets/idees', {
- method: 'POST',
- body: { sujet_consigne: consigne, contenu_partiel: contenu },
- timeoutMs: IDEES_TIMEOUT_MS,
- }).then((res) => res.idees)
-}
diff --git a/src/entities/report/lib.ts b/src/entities/report/lib.ts
deleted file mode 100644
index b629e7a..0000000
--- a/src/entities/report/lib.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * Logique de floutage du domaine `report`.
- *
- * Import cross-entity volontaire et documenté :
- * entities/report → entities/user/lib (hasAccess, Plan)
- * Justification : la logique de floutage dépend intrinsèquement des permissions
- * utilisateur. Exception validée — cf. ARCHITECTURE.md §3.
- *
- * Règle D : aucun `if (plan === 'xxx')` — tout passe par hasAccess().
- * Règle H : cette logique vit ici, jamais dans les composants features/.
- *
- * Sprint 3.6b : nouvelle matrice — `revelation`, `diagnostic`, `conseil_nclc`
- * sont visibles tous plans (pas listés ici). Seuls `criteres`, `exercices`,
- * `modele` sont floutés selon le plan (cf. types.ts BlurableSection).
- */
-
-import { hasAccess } from '@/entities/user/lib'
-import type { Plan } from '@/entities/user/lib'
-import type { BlurableSection, Critere, ErreurCode, CritereCode, Report } from './types'
-
-const SECTION_FEATURE: Record = {
- criteres: 'detailed_report',
- exercices: 'tips',
- modele: 'tips',
-}
-
-/**
- * Indique si une section du rapport est visible pour un plan donné.
- *
- * @example
- * isSectionVisible('free', 'criteres') // false
- * isSectionVisible('standard', 'modele') // true
- */
-export function isSectionVisible(plan: Plan, section: BlurableSection): boolean {
- return hasAccess(plan, SECTION_FEATURE[section])
-}
-
-/**
- * Regroupe les codes d'erreurs par critère pour affichage dans les cartes critère.
- *
- * Le backend retourne `erreurs_codes` en top-level ; l'UI les affiche
- * contextualisés dans chaque carte critère correspondante.
- */
-export function groupErreursByCritere(
- erreursCodes: ErreurCode[],
-): Record {
- const acc: Record = {
- adequation_tache: [],
- coherence_cohesion: [],
- competence_lexicale: [],
- competence_grammaticale: [],
- }
- for (const err of erreursCodes) {
- acc[err.critere].push(err)
- }
- return acc
-}
-
-/**
- * Mappe le nom d'un critère (libellé humain backend) vers son code taxonomie,
- * pour rattacher `erreurs_codes` à la bonne carte critère côté UI.
- */
-const CRITERE_NOM_TO_CODE: Record = {
- // Libellés EE (CRITERE_LABELS backend)
- 'Adéquation à la tâche et au registre': 'adequation_tache',
- 'Cohérence et cohésion du discours': 'coherence_cohesion',
- 'Compétence lexicale': 'competence_lexicale',
- 'Compétence grammaticale': 'competence_grammaticale',
- // Libellés EO Sprint 4.8 (CRITERE_LABELS_EO backend) — mappés vers les
- // mêmes codes taxonomie pour rattacher les `erreurs_codes`. La 5e dimension
- // « Phonologie » n'a pas de CritereCode (aucune erreur taxonomie associée
- // côté backend) : `critereCodeFromNom('Phonologie')` retourne donc `null`.
- 'Adéquation à la tâche': 'adequation_tache',
- 'Cohérence et cohésion': 'coherence_cohesion',
- 'Étendue et maîtrise du lexique': 'competence_lexicale',
- 'Maîtrise morphosyntaxique': 'competence_grammaticale',
-}
-
-export function critereCodeFromNom(nom: string): CritereCode | null {
- return CRITERE_NOM_TO_CODE[nom] ?? null
-}
-
-/**
- * Sprint 4.8 — Détecte le score maximum par critère selon le format du rapport.
- *
- * - Rapports EO Sprint 4.8 : 5 critères × /4 (4 textuels DeepSeek + Phonologie Gemini).
- * - Rapports EE et EO legacy : 4 critères × /5.
- *
- * Détection sur la donnée elle-même (pas sur la tâche) pour rester rétrocompatible
- * avec les rapports EO en base d'avant Sprint 4.8.
- *
- * Défaut sécurité : tout autre nombre de critères → 5.
- */
-export function getMaxScorePerCritere(rapport: Pick): 4 | 5 {
- return rapport.criteres.length === 5 ? 4 : 5
-}
-
-/**
- * Calcule l'écart en points /20 entre le score obtenu et l'objectif NCLC cible.
- * Barème TCF Canada (cf. Prompt_maître.md §Barème) : NCLC 9 → 14/20, NCLC 10 → 16/20.
- */
-const NCLC_MIN_SCORE: Record = { 7: 10, 8: 12, 9: 14, 10: 16 }
-
-export function ecartVsCible(
- score: number,
- nclcCible: number,
-): {
- points: number
- atteint: boolean
-} {
- const minScore = NCLC_MIN_SCORE[nclcCible] ?? NCLC_MIN_SCORE[9]!
- const points = Math.max(0, minScore - score)
- return { points, atteint: score >= minScore }
-}
-
-export type { Critere }
-
-/**
- * Libellés officiels des 4 critères TCF Canada — miroir de backend
- * `src/lib/taxonomieErreurs.ts` CRITERE_LABELS. Utilisé par les listes de
- * patterns et tout affichage nécessitant le libellé humain à partir du code.
- */
-export const CRITERE_LABELS: Record = {
- adequation_tache: 'Adéquation à la tâche et au registre',
- coherence_cohesion: 'Cohérence et cohésion du discours',
- competence_lexicale: 'Compétence lexicale',
- competence_grammaticale: 'Compétence grammaticale',
-}
diff --git a/src/entities/report/types.ts b/src/entities/report/types.ts
deleted file mode 100644
index 1b61fff..0000000
--- a/src/entities/report/types.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-/**
- * Types publics du domaine `report` — Sprint 3.6b.
- *
- * Structure alignée sur le backend Sprint 3.6a (prompt maître + production
- * modèle + exercices fire-and-forget). Aucune enveloppe : le payload est
- * retourné directement (cf. ARCHITECTURE.md §5).
- *
- * Visibilité par section selon le plan (cf. PLANS_TARIFAIRES.md §2) :
- * - score, nclc, nclc_cible, revelation, diagnostic, conseil_nclc → tous plans
- * - criteres (exemple/suggestion/astuce) → detailed_report (Standard+)
- * - exercices, modele → tips (Standard+)
- *
- * SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
- */
-
-import type { Tache } from '@/entities/production/types'
-
-/** Codes taxonomie d'erreurs — valeurs exhaustives dans `TAXONOMIE_ERREURS.md` v1.0. */
-export type CritereCode =
- | 'adequation_tache'
- | 'coherence_cohesion'
- | 'competence_lexicale'
- | 'competence_grammaticale'
-
-export interface ErreurCode {
- code: string
- critere: CritereCode
- description: string | null
-}
-
-export interface Critere {
- nom: string
- score: number // 0-4 (EO Sprint 4.8 — 5 critères) | 0-5 (EE, EO legacy 4 critères)
- commentaire: string
- exemple: string
- suggestion: string
- astuce: string
-}
-
-export interface Revelation {
- croyance: string
- realite: string
- consequence: string
-}
-
-export interface ConseilNclc {
- nclc_cible: string // ex. "NCLC 9"
- ecart: string
- action_prioritaire: string
-}
-
-export type Difficulte = 'facile' | 'intermediaire' | 'difficile'
-
-/** Libellés affichés pour chaque niveau de difficulté. */
-export const DIFFICULTE_LABEL: Record = {
- facile: 'Facile',
- intermediaire: 'Moyen',
- difficile: 'Difficile',
-}
-
-export interface Exercice {
- difficulte: Difficulte
- theme: string
- diagnostic: string
- consigne: string
- extrait: string
- indice: string
- correction: string
- explication: string
-}
-
-export interface NotePedagogique {
- passage: string
- explication: string
-}
-
-export interface Transformation {
- original: string
- ameliore: string
- explication: string
-}
-
-export interface ProductionModele {
- production_modele_propre: string
- notes_pedagogiques: NotePedagogique[]
- transformations: Transformation[]
- message: string
- // Métadonnées backend — non affichées côté UI mais exposées pour complétude.
- nclc_modele?: number
- nclc_obtenu?: number
- score_cible?: number
- tcf_word_count?: number
- tcf_word_min?: number
- tcf_word_max?: number
- tcf_truncated?: boolean
-}
-
-export type JobStatus = 'pending' | 'ready' | 'error'
-export type NclcCible = 9 | 10
-
-/**
- * Rapport de correction renvoyé par `GET /simulations/:id` (et ressources dérivées).
- */
-export interface Report {
- simulation_id: string
- /**
- * Tâche d'origine (propagée depuis `SimulationState`). Permet de router le
- * retour « Nouvelle simulation » vers la bonne sélection (EO vs EE) sans
- * plomberie de query param. Optionnel : absent des payloads `POST /corrections/*`.
- */
- tache?: Tache
- score: number // /20
- nclc: number // NCLC atteint — ex. 8
- nclc_cible: NclcCible
- revelation: Revelation
- diagnostic: string
- criteres: Critere[]
- conseil_nclc: ConseilNclc
- erreurs_codes: ErreurCode[] // top-level — regroupés par critère côté UI
- exercices: Exercice[] | null
- exercices_status: JobStatus
- modele: ProductionModele | null
- modele_status: JobStatus
-}
-
-/** Corps de `POST /corrections/ee`. */
-export interface CorrectEePayload {
- simulationId: string
- contenu: string
- tache: string
- nclc_cible?: NclcCible // défaut backend : 9
-}
-
-/**
- * Corps de `POST /corrections/eo`.
- * transcript : transcription audio envoyée au backend (implémenté Sprint 4).
- */
-/**
- * Corps de `POST /corrections/eo`.
- *
- * Modes (XOR — exactement un des deux) :
- * - `transcript` (Sprint 4) : transcription texte fournie directement par le client.
- * - `audioBase64` + `mimeType` (Sprint 4b.2) : audio brut, le backend transcrit
- * via Gemini batch puis poursuit le pipeline correction.
- */
-export interface CorrectEoPayload {
- simulationId: string
- tache: string
- /** Sprint 4a backend — cible NCLC (9 par défaut, 10 pour viser plus haut). */
- nclc_cible?: 9 | 10
- transcript?: string
- audioBase64?: string
- /** MIME du payload audio (audio/webm | audio/mp4 | audio/wav). */
- mimeType?: string
-}
-
-/**
- * Sections du rapport dont la visibilité dépend du plan (Sprint 3.6b).
- *
- * - `criteres` → gate `detailed_report` : floute les champs exemple/suggestion/astuce
- * + codes d'erreurs pour les utilisateurs Free.
- * - `exercices` / `modele` → gate `tips`.
- *
- * `revelation`, `diagnostic`, `conseil_nclc`, `score`, `nclc` ne sont **pas**
- * des `BlurableSection` : elles sont visibles pour tous les plans (cf. PLANS_TARIFAIRES.md §2).
- */
-export type BlurableSection = 'criteres' | 'exercices' | 'modele'
diff --git a/src/entities/transcription/__tests__/api.test.ts b/src/entities/transcription/__tests__/api.test.ts
deleted file mode 100644
index cd407cb..0000000
--- a/src/entities/transcription/__tests__/api.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * Tests du domaine `transcription` — Sprint 4c-1.
- *
- * Valide :
- * - succès : retourne le token et expires_in
- * - erreur : ApiError propagée par apiFetch
- */
-
-import { describe, expect, it, vi, beforeEach } from 'vitest'
-
-vi.mock('@/shared/lib/api-client', () => ({
- apiFetch: vi.fn(),
-}))
-
-import { apiFetch } from '@/shared/lib/api-client'
-import { requestDeepgramToken } from '../api'
-
-const mocked = vi.mocked(apiFetch)
-
-describe('requestDeepgramToken', () => {
- beforeEach(() => {
- mocked.mockReset()
- })
-
- it('retourne le token et expires_in en cas de succès', async () => {
- mocked.mockResolvedValueOnce({ token: 'dg-temp-abc', expires_in: 600 })
-
- const result = await requestDeepgramToken()
-
- expect(result).toEqual({ token: 'dg-temp-abc', expires_in: 600 })
- expect(mocked).toHaveBeenCalledWith('/transcriptions/token', {
- method: 'POST',
- timeoutMs: 10_000,
- retry: { max: 0, baseDelayMs: 0 },
- })
- })
-
- it('propage les ApiError du backend', async () => {
- mocked.mockRejectedValueOnce({
- error: true,
- code: 'AUTH_REQUIRED',
- message: 'Auth required',
- })
-
- await expect(requestDeepgramToken()).rejects.toMatchObject({
- code: 'AUTH_REQUIRED',
- })
- })
-})
diff --git a/src/entities/transcription/api.ts b/src/entities/transcription/api.ts
deleted file mode 100644
index cbc7c94..0000000
--- a/src/entities/transcription/api.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Appels API du domaine `transcription`.
- *
- * `POST /transcriptions/token` : timeout 10 s, retry désactivé.
- * Le retry est désactivé volontairement : un POST non-idempotent qui
- * consomme un crédit Deepgram à chaque appel ne doit pas être rejoué
- * silencieusement en cas d'erreur réseau transitoire.
- */
-
-import { apiFetch } from '@/shared/lib/api-client'
-import type { TranscriptionToken } from './types'
-
-const TOKEN_TIMEOUT_MS = 10_000
-
-export function requestDeepgramToken(): Promise {
- return apiFetch('/transcriptions/token', {
- method: 'POST',
- timeoutMs: TOKEN_TIMEOUT_MS,
- retry: { max: 0, baseDelayMs: 0 },
- })
-}
diff --git a/src/entities/transcription/types.ts b/src/entities/transcription/types.ts
deleted file mode 100644
index 6807dd3..0000000
--- a/src/entities/transcription/types.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * Types publics du domaine `transcription`.
- *
- * Le frontend obtient un token Deepgram éphémère via le backend
- * (`POST /transcriptions/token`) puis ouvre une connexion WebSocket
- * directe vers Deepgram pour la transcription live. La clé maître
- * Deepgram reste côté backend (cf. SECURITY.md).
- */
-
-export interface TranscriptionToken {
- /** JWT éphémère Deepgram (durée de vie ~10 min). */
- token: string
- /** Durée de validité du token, en secondes. */
- expires_in: number
-}
diff --git a/src/entities/user/api.ts b/src/entities/user/api.ts
deleted file mode 100644
index 3866873..0000000
--- a/src/entities/user/api.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * Appels API du domaine `user`.
- *
- * Toutes les requêtes passent par `apiFetch` (Règle J) qui gère auth, retry,
- * timeout et erreurs typées. Les consommateurs consomment ces fonctions via
- * TanStack Query (cf. `features/dashboard/hooks/usePlan`).
- */
-
-import { apiFetch } from '@/shared/lib/api-client'
-import type { PlanStatus } from './types'
-
-/**
- * Récupère le statut courant du plan de l'utilisateur connecté.
- *
- * Endpoint : `GET /plans/status`
- * Auth : JWT Bearer requis (ajouté automatiquement par `apiFetch`).
- *
- * @throws ApiError — notamment `AUTH_REQUIRED` si le JWT est absent/expiré.
- */
-export function getPlanStatus(): Promise {
- return apiFetch('/plans/status')
-}
diff --git a/src/entities/user/query-keys.ts b/src/entities/user/query-keys.ts
deleted file mode 100644
index 9267797..0000000
--- a/src/entities/user/query-keys.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * Clés TanStack Query partagées pour le domaine `user`.
- *
- * Source unique — importée par `features/dashboard/hooks/usePlan`,
- * `features/simulations/pages/SimulationPage`, `features/simulations/pages/RapportPage`,
- * et tout futur consommateur du statut de plan. Une clé locale inline briserait
- * silencieusement le cache partagé de TanStack Query à la moindre faute de frappe.
- *
- * Ce module ne contient que des constantes pures — aucun import React, TanStack
- * ou autre dépendance runtime.
- */
-
-export const PLAN_QUERY_KEY = ['plan'] as const
diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts
deleted file mode 100644
index e86ae34..0000000
--- a/src/entities/user/types.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Types publics du domaine `user`.
- *
- * Porte d'entrée unique pour les consommateurs frontend : toute UI ou hook
- * qui manipule un plan ou une permission importe depuis ce fichier (ou `./lib`
- * pour les fonctions), jamais directement depuis `./access` (cf. ADR 005).
- */
-
-import type { Feature, Plan } from './access'
-
-/**
- * Réponse du backend pour `GET /plans/status`.
- *
- * Format confirmé par l'audit backend 2026-04-17 (cf. ARCHITECTURE.md §5).
- *
- * - `permissions` : dictionnaire booléen par feature. Les consommateurs
- * doivent passer par `hasAccess(plan, feature)` plutôt que lire ce champ
- * directement (Règle D / ADR 005). Il est exposé ici uniquement parce que
- * le backend le renvoie et qu'il peut servir à du debug côté DevTools.
- * - `simulations_remaining` : `null` si le plan est illimité (standard/premium),
- * sinon nombre de simulations restantes sur le quota à vie (Free : 5).
- * - `plan_expires_at` : ISO 8601 pour un plan payant actif, `null` pour Free.
- */
-export interface PlanStatus {
- plan: Plan
- permissions: Record
- simulations_used: number
- simulations_remaining: number | null
- plan_expires_at: string | null
-}
-
-export type { Feature, Plan }
diff --git a/src/features/account/pages/ParametresPage.tsx b/src/features/account/pages/ParametresPage.tsx
deleted file mode 100644
index 4499239..0000000
--- a/src/features/account/pages/ParametresPage.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * Page Paramètres — Sprint 5d.
- *
- * Page minimale conteneur pour les sections de gestion du compte :
- * - Abonnement (`AccountBillingSection`) — Stripe.
- * - Session — bouton de déconnexion.
- * Future : préférences langue, sécurité (changement mot de passe),
- * suppression compte, etc.
- *
- * Wrapper layout standard 1100px (cohérent avec convention Sprint 4.7).
- */
-
-import { useNavigate } from 'react-router-dom'
-import { useQueryClient } from '@tanstack/react-query'
-import { LogOut } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { Card } from '@/shared/ui/Card'
-import { signOut } from '@/shared/lib/auth-client'
-import { AccountBillingSection } from '@/features/billing/components/AccountBillingSection'
-
-export function ParametresPage() {
- const navigate = useNavigate()
- const queryClient = useQueryClient()
-
- async function handleSignOut() {
- await signOut()
- queryClient.clear()
- navigate('/login', { replace: true })
- }
-
- return (
-
-
-
-
-
-
-
-
- }
- onClick={handleSignOut}
- >
- Se déconnecter
-
-
-
-
- )
-}
diff --git a/src/features/auth/components/ProtectedRoute.tsx b/src/features/auth/components/ProtectedRoute.tsx
deleted file mode 100644
index 0e793cd..0000000
--- a/src/features/auth/components/ProtectedRoute.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Wrapper de route qui exige un utilisateur authentifié.
- *
- * - Pendant le chargement de la session : affiche un spinner centré.
- * - Si non authentifié : redirige vers `/login` avec replace (pas d'entrée
- * parasite dans l'historique navigateur).
- * - Si authentifié : rend les `children`.
- *
- * Le backend reste l'autorité finale : cette garde est de l'UX. Les routes
- * sensibles sont protégées par les middlewares Hono côté API (ADR 002).
- */
-
-import { Navigate } from 'react-router-dom'
-import { Loader2 } from 'lucide-react'
-import { useAuth } from '../hooks/useAuth'
-
-interface ProtectedRouteProps {
- children: React.ReactNode
-}
-
-export function ProtectedRoute({ children }: ProtectedRouteProps) {
- const { isLoading, isAuthenticated } = useAuth()
-
- if (isLoading) {
- return (
-
-
-
- )
- }
-
- if (!isAuthenticated) {
- return
- }
-
- return <>{children}>
-}
diff --git a/src/features/auth/hooks/useAuth.ts b/src/features/auth/hooks/useAuth.ts
deleted file mode 100644
index 6c3e1df..0000000
--- a/src/features/auth/hooks/useAuth.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * Hook source de vérité pour l'état d'authentification dans toute l'app.
- *
- * Au mount : récupère la session courante depuis Supabase (cookie + localStorage).
- * S'abonne ensuite à `onAuthStateChange` pour réagir aux login/logout/refresh
- * token ; se désabonne au unmount.
- *
- * Consommé par `ProtectedRoute` (redirect si non authentifié) et par toute page
- * qui a besoin du profil Supabase (ex. prénom affiché dans le header).
- */
-
-import { useEffect, useState } from 'react'
-import { getCurrentSession, subscribeToAuthChanges, type User } from '@/shared/lib/auth-client'
-
-interface UseAuthResult {
- user: User | null
- isLoading: boolean
- isAuthenticated: boolean
-}
-
-export function useAuth(): UseAuthResult {
- const [user, setUser] = useState(null)
- const [isLoading, setIsLoading] = useState(true)
-
- useEffect(() => {
- let cancelled = false
-
- getCurrentSession().then((session) => {
- if (cancelled) return
- setUser(session?.user ?? null)
- setIsLoading(false)
- })
-
- const unsubscribe = subscribeToAuthChanges((session) => {
- setUser(session?.user ?? null)
- setIsLoading(false)
- })
-
- return () => {
- cancelled = true
- unsubscribe()
- }
- }, [])
-
- return {
- user,
- isLoading,
- isAuthenticated: user !== null,
- }
-}
diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx
deleted file mode 100644
index 5e474bf..0000000
--- a/src/features/auth/pages/LoginPage.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * Page de connexion.
- *
- * Formulaire email + mot de passe, appel de `signIn` (Supabase via auth-client).
- * Si la session devient active (arrivée déjà connecté OU succès de signIn),
- * `useAuth` propage l'état et le useEffect redirige vers /dashboard. On évite
- * ainsi un double `navigate` concurrent depuis le handler de submit.
- */
-
-import { useEffect, useState, type FormEvent } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
-import { Loader2 } from 'lucide-react'
-
-import { Button } from '@/shared/components/ui/button'
-import { Input } from '@/shared/components/ui/input'
-import { Label } from '@/shared/components/ui/label'
-import { signIn } from '@/shared/lib/auth-client'
-import { useAuth } from '../hooks/useAuth'
-
-function mapSignInError(message: string | undefined): string {
- if (!message) return 'Connexion impossible. Réessayez dans quelques instants.'
- if (message === 'Invalid login credentials') {
- return 'Email ou mot de passe incorrect.'
- }
- if (message.includes('Email not confirmed')) {
- return 'Email non confirmé. Vérifiez votre boîte mail.'
- }
- return 'Connexion impossible. Réessayez dans quelques instants.'
-}
-
-export function LoginPage() {
- const navigate = useNavigate()
- const { isAuthenticated, isLoading } = useAuth()
-
- const [email, setEmail] = useState('')
- const [password, setPassword] = useState('')
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [error, setError] = useState(null)
-
- useEffect(() => {
- if (!isLoading && isAuthenticated) {
- navigate('/dashboard', { replace: true })
- }
- }, [isLoading, isAuthenticated, navigate])
-
- if (isLoading || isAuthenticated) {
- return (
-
-
-
- )
- }
-
- async function handleSubmit(e: FormEvent) {
- e.preventDefault()
- setError(null)
- setIsSubmitting(true)
- try {
- const { error: signInError } = await signIn(email, password)
- if (signInError) {
- setError(mapSignInError(signInError.message))
- }
- } finally {
- setIsSubmitting(false)
- }
- }
-
- return (
-
-
- Se connecter
- Accédez à votre espace Expria.
-
- {error && (
-
- {error}
-
- )}
-
-
-
-
- Pas encore de compte ?{' '}
-
- Créer un compte
-
-
-
-
- )
-}
-
-export default LoginPage
diff --git a/src/features/auth/pages/RegisterPage.tsx b/src/features/auth/pages/RegisterPage.tsx
deleted file mode 100644
index 99c0b34..0000000
--- a/src/features/auth/pages/RegisterPage.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-/**
- * Page d'inscription.
- *
- * Formulaire email + mot de passe + confirmation, validation Zod côté client,
- * appel de `signUp` (Supabase via auth-client). Supabase envoie un email de
- * confirmation par défaut : on n'a donc pas de session active après succès.
- * On affiche un message de confirmation invitant à vérifier la boîte mail,
- * puis à revenir sur /login.
- */
-
-import { useState, type FormEvent } from 'react'
-import { Link } from 'react-router-dom'
-import { Loader2 } from 'lucide-react'
-import { z } from 'zod'
-
-import { Button } from '@/shared/components/ui/button'
-import { Input } from '@/shared/components/ui/input'
-import { Label } from '@/shared/components/ui/label'
-import { signUp } from '@/shared/lib/auth-client'
-
-const registerSchema = z
- .object({
- email: z.string().email('Email invalide'),
- password: z.string().min(8, 'Le mot de passe doit contenir au moins 8 caractères'),
- confirmPassword: z.string(),
- })
- .refine((data) => data.password === data.confirmPassword, {
- path: ['confirmPassword'],
- message: 'Les mots de passe ne correspondent pas',
- })
-
-type FieldErrors = Partial>
-
-function mapSignUpError(message: string | undefined): string {
- if (!message) return 'Inscription impossible. Réessayez dans quelques instants.'
- if (message.toLowerCase().includes('already registered')) {
- return 'Un compte existe déjà avec cet email.'
- }
- if (/password/i.test(message)) {
- return 'Mot de passe refusé par le serveur. Choisissez un mot de passe plus robuste.'
- }
- return 'Inscription impossible. Réessayez dans quelques instants.'
-}
-
-export function RegisterPage() {
- const [email, setEmail] = useState('')
- const [password, setPassword] = useState('')
- const [confirmPassword, setConfirmPassword] = useState('')
- const [fieldErrors, setFieldErrors] = useState({})
- const [formError, setFormError] = useState(null)
- const [successMessage, setSuccessMessage] = useState(null)
- const [isSubmitting, setIsSubmitting] = useState(false)
-
- async function handleSubmit(e: FormEvent) {
- e.preventDefault()
- setFieldErrors({})
- setFormError(null)
-
- const parsed = registerSchema.safeParse({ email, password, confirmPassword })
- if (!parsed.success) {
- const flat = parsed.error.flatten().fieldErrors
- setFieldErrors({
- email: flat.email?.[0],
- password: flat.password?.[0],
- confirmPassword: flat.confirmPassword?.[0],
- })
- return
- }
-
- setIsSubmitting(true)
- try {
- const { error: signUpError } = await signUp(parsed.data.email, parsed.data.password)
- if (signUpError) {
- setFormError(mapSignUpError(signUpError.message))
- return
- }
- setSuccessMessage(
- 'Compte créé. Vérifiez votre email pour confirmer votre inscription, puis connectez-vous.',
- )
- } finally {
- setIsSubmitting(false)
- }
- }
-
- return (
-
-
- Créer un compte
- Commencez votre préparation TCF Canada.
-
- {successMessage ? (
- <>
-
- {successMessage}
-
-
-
- Retour à la connexion
-
-
- >
- ) : (
- <>
- {formError && (
-
- {formError}
-
- )}
-
-
-
-
- Déjà un compte ?{' '}
-
- Se connecter
-
-
- >
- )}
-
-
- )
-}
-
-export default RegisterPage
diff --git a/src/features/billing/__tests__/AccountBillingSection.test.tsx b/src/features/billing/__tests__/AccountBillingSection.test.tsx
deleted file mode 100644
index c76b604..0000000
--- a/src/features/billing/__tests__/AccountBillingSection.test.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe, it, expect, vi, afterEach } from 'vitest'
-import { render, screen, cleanup } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { MemoryRouter } from 'react-router-dom'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-
-// ─── Mocks ───────────────────────────────────────────────────────────────────
-
-const { usePlanMock, useCustomerPortalMock } = vi.hoisted(() => ({
- usePlanMock: vi.fn(),
- useCustomerPortalMock: vi.fn(),
-}))
-
-vi.mock('@/features/dashboard/hooks/usePlan', () => ({
- usePlan: usePlanMock,
-}))
-
-vi.mock('../hooks/useCustomerPortal', () => ({
- useCustomerPortal: useCustomerPortalMock,
-}))
-
-import { AccountBillingSection } from '../components/AccountBillingSection'
-
-afterEach(() => {
- cleanup()
- usePlanMock.mockReset()
- useCustomerPortalMock.mockReset()
-})
-
-function renderSection() {
- const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
- })
- return render(
-
-
-
-
- ,
- )
-}
-
-function mockPlan(plan: 'free' | 'standard' | 'premium') {
- usePlanMock.mockReturnValue({
- data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null },
- isLoading: false,
- })
-}
-
-function mockPortal(overrides: Partial> = {}) {
- const openPortal = vi.fn()
- useCustomerPortalMock.mockReturnValue({
- openPortal,
- isLoading: false,
- error: null,
- ...overrides,
- })
- return openPortal
-}
-
-// ─── Tests ───────────────────────────────────────────────────────────────────
-
-describe('AccountBillingSection — plan free', () => {
- it('affiche le badge Découverte + lien "Voir les plans" → /plan', () => {
- mockPlan('free')
- mockPortal()
- renderSection()
-
- expect(screen.getByText('Plan Découverte')).toBeInTheDocument()
- const link = screen.getByRole('link', { name: /voir les plans/i })
- expect(link).toHaveAttribute('href', '/plan')
- expect(screen.queryByRole('button', { name: /gérer mon abonnement/i })).not.toBeInTheDocument()
- })
-})
-
-describe('AccountBillingSection — plan standard', () => {
- it('clic sur "Gérer mon abonnement" appelle openPortal', async () => {
- const user = userEvent.setup()
- mockPlan('standard')
- const openPortal = mockPortal()
- renderSection()
-
- expect(screen.getByText('Plan Standard')).toBeInTheDocument()
- const btn = screen.getByRole('button', { name: /gérer mon abonnement/i })
- expect(btn).toBeEnabled()
-
- await user.click(btn)
- expect(openPortal).toHaveBeenCalledTimes(1)
- expect(screen.queryByRole('link', { name: /voir les plans/i })).not.toBeInTheDocument()
- })
-
- it("affiche un callout d'erreur quand le hook expose error", () => {
- mockPlan('premium')
- mockPortal({ error: 'Aucun abonnement actif trouvé.' })
- renderSection()
-
- expect(screen.getByRole('alert')).toHaveTextContent(/Aucun abonnement actif trouvé/i)
- })
-})
diff --git a/src/features/billing/__tests__/PricingPage.test.tsx b/src/features/billing/__tests__/PricingPage.test.tsx
deleted file mode 100644
index fa076fc..0000000
--- a/src/features/billing/__tests__/PricingPage.test.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { describe, it, expect, vi, afterEach } from 'vitest'
-import { render, screen, cleanup, waitFor } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { MemoryRouter } from 'react-router-dom'
-
-// ─── Mocks ───────────────────────────────────────────────────────────────────
-
-const { usePlanMock, createCheckoutSessionMock, createCustomerPortalSessionMock } = vi.hoisted(
- () => ({
- usePlanMock: vi.fn(),
- createCheckoutSessionMock: vi.fn(),
- createCustomerPortalSessionMock: vi.fn(),
- }),
-)
-
-vi.mock('@/features/dashboard/hooks/usePlan', () => ({
- usePlan: usePlanMock,
-}))
-
-vi.mock('../api', () => ({
- createCheckoutSession: createCheckoutSessionMock,
- createCustomerPortalSession: createCustomerPortalSessionMock,
-}))
-
-import { PricingPage } from '../pages/PricingPage'
-
-afterEach(() => {
- cleanup()
- usePlanMock.mockReset()
- createCheckoutSessionMock.mockReset()
- createCustomerPortalSessionMock.mockReset()
-})
-
-function renderPage() {
- const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
- })
- return render(
-
-
-
-
- ,
- )
-}
-
-function mockPlan(plan: 'free' | 'standard' | 'premium') {
- usePlanMock.mockReturnValue({
- data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null },
- isLoading: false,
- isError: false,
- })
-}
-
-// ─── Tests ───────────────────────────────────────────────────────────────────
-
-describe('PricingPage — plan free', () => {
- it('CTA Standard et Premium actifs (plein tarif), Découverte = "Plan actuel" disabled', () => {
- mockPlan('free')
- renderPage()
-
- expect(
- screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }),
- ).toBeEnabled()
- expect(screen.getByRole('button', { name: /Choisir Premium — 39,90 €\/4 sem\./ })).toBeEnabled()
-
- const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
- expect(planActuelButtons).toHaveLength(1)
- expect(planActuelButtons[0]).toBeDisabled()
- })
-})
-
-describe('PricingPage — plan standard', () => {
- it('Standard désactivé "Plan actuel" ; Premium actif "Passer en Premium" + hint prorata', () => {
- mockPlan('standard')
- renderPage()
-
- expect(screen.getByRole('button', { name: /Passer en Premium$/ })).toBeEnabled()
- expect(
- screen.queryByRole('button', { name: /Choisir Premium — 39,90 €/ }),
- ).not.toBeInTheDocument()
-
- expect(screen.getByText(/Stripe calculera automatiquement le prorata/i)).toBeInTheDocument()
-
- const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
- expect(planActuelButtons).toHaveLength(1)
- expect(planActuelButtons[0]).toBeDisabled()
- })
-})
-
-describe('PricingPage — plan premium', () => {
- it('tous les CTA payants désactivés', () => {
- mockPlan('premium')
- renderPage()
-
- const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
- expect(planActuelButtons).toHaveLength(1)
- expect(planActuelButtons[0]).toBeDisabled()
-
- expect(
- screen.queryByRole('button', { name: /Choisir Standard|Passer en Premium|Choisir Premium/ }),
- ).not.toBeInTheDocument()
-
- const inferieurButtons = screen.getAllByRole('button', { name: /Inférieur à votre plan/i })
- expect(inferieurButtons.length).toBeGreaterThanOrEqual(1)
- inferieurButtons.forEach((btn) => expect(btn).toBeDisabled())
- })
-})
-
-describe('PricingPage — interaction', () => {
- it('clic sur "Choisir Standard" appelle createCheckoutSession("standard")', async () => {
- const user = userEvent.setup()
- mockPlan('free')
- // Promesse non résolue : on veut juste vérifier l'appel, pas la redirection.
- createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
-
- renderPage()
- await user.click(screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }))
-
- await waitFor(() => {
- expect(createCheckoutSessionMock).toHaveBeenCalledTimes(1)
- })
- // TanStack Query injecte un 2e arg (mutationContext) → on vérifie uniquement le 1er.
- expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard')
- })
-
- it('Standard user clique "Passer en Premium" → createCustomerPortalSession (PAS createCheckoutSession)', async () => {
- const user = userEvent.setup()
- mockPlan('standard')
- createCustomerPortalSessionMock.mockReturnValue(new Promise(() => {}))
-
- renderPage()
- await user.click(screen.getByRole('button', { name: /Passer en Premium$/ }))
-
- await waitFor(() => {
- expect(createCustomerPortalSessionMock).toHaveBeenCalledTimes(1)
- })
- expect(createCheckoutSessionMock).not.toHaveBeenCalled()
- })
-
- it("erreur de mutation → callout d'erreur affiché", async () => {
- const user = userEvent.setup()
- mockPlan('free')
- createCheckoutSessionMock.mockRejectedValue(new Error('Configuration Stripe manquante.'))
-
- renderPage()
- await user.click(screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }))
-
- await waitFor(() => {
- expect(screen.getByRole('alert')).toHaveTextContent(/Configuration Stripe manquante/i)
- })
- })
-})
diff --git a/src/features/billing/__tests__/useCustomerPortal.test.tsx b/src/features/billing/__tests__/useCustomerPortal.test.tsx
deleted file mode 100644
index 4e510fd..0000000
--- a/src/features/billing/__tests__/useCustomerPortal.test.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { describe, it, expect, vi, afterEach } from 'vitest'
-import { renderHook, act, waitFor } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import type { ReactNode } from 'react'
-
-// ─── Mocks ───────────────────────────────────────────────────────────────────
-
-const { createCustomerPortalSessionMock } = vi.hoisted(() => ({
- createCustomerPortalSessionMock: vi.fn(),
-}))
-
-vi.mock('../api', () => ({
- createCustomerPortalSession: createCustomerPortalSessionMock,
-}))
-
-import { useCustomerPortal } from '../hooks/useCustomerPortal'
-
-afterEach(() => {
- createCustomerPortalSessionMock.mockReset()
-})
-
-function wrapper({ children }: { children: ReactNode }) {
- const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
- })
- return {children}
-}
-
-// ─── Tests ───────────────────────────────────────────────────────────────────
-
-describe('useCustomerPortal', () => {
- it("openPortal() succès → window.location.href set sur l'URL portal", async () => {
- const originalLocation = window.location
- const hrefSetter = vi.fn()
- Object.defineProperty(window, 'location', {
- configurable: true,
- writable: true,
- value: {
- ...originalLocation,
- get href() {
- return originalLocation.href
- },
- set href(v: string) {
- hrefSetter(v)
- },
- },
- })
-
- createCustomerPortalSessionMock.mockResolvedValue({
- url: 'https://billing.stripe.com/p/session/abc',
- })
-
- const { result } = renderHook(() => useCustomerPortal(), { wrapper })
- act(() => {
- result.current.openPortal()
- })
-
- await waitFor(() => {
- expect(hrefSetter).toHaveBeenCalledWith('https://billing.stripe.com/p/session/abc')
- })
-
- Object.defineProperty(window, 'location', {
- configurable: true,
- writable: true,
- value: originalLocation,
- })
- })
-
- it('erreur backend → message backend propagé dans `error`', async () => {
- createCustomerPortalSessionMock.mockRejectedValue(
- new Error('Aucun abonnement actif trouvé. Souscrivez d’abord à un plan.'),
- )
- const { result } = renderHook(() => useCustomerPortal(), { wrapper })
-
- act(() => {
- result.current.openPortal()
- })
-
- await waitFor(() => {
- expect(result.current.error).toMatch(/Aucun abonnement actif trouvé/)
- })
- })
-
- it('isLoading vrai pendant la mutation pending', async () => {
- createCustomerPortalSessionMock.mockReturnValue(new Promise(() => {}))
- const { result } = renderHook(() => useCustomerPortal(), { wrapper })
-
- expect(result.current.isLoading).toBe(false)
- act(() => {
- result.current.openPortal()
- })
-
- await waitFor(() => {
- expect(result.current.isLoading).toBe(true)
- })
- })
-})
diff --git a/src/features/billing/__tests__/useStripeCheckout.test.tsx b/src/features/billing/__tests__/useStripeCheckout.test.tsx
deleted file mode 100644
index d280bbf..0000000
--- a/src/features/billing/__tests__/useStripeCheckout.test.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { describe, it, expect, vi, afterEach } from 'vitest'
-import { renderHook, act, waitFor } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import type { ReactNode } from 'react'
-
-// ─── Mocks ───────────────────────────────────────────────────────────────────
-
-const { createCheckoutSessionMock } = vi.hoisted(() => ({
- createCheckoutSessionMock: vi.fn(),
-}))
-
-vi.mock('../api', () => ({
- createCheckoutSession: createCheckoutSessionMock,
-}))
-
-import { useStripeCheckout } from '../hooks/useStripeCheckout'
-
-afterEach(() => {
- createCheckoutSessionMock.mockReset()
-})
-
-function wrapper({ children }: { children: ReactNode }) {
- const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
- })
- return {children}
-}
-
-// ─── Tests ───────────────────────────────────────────────────────────────────
-
-describe('useStripeCheckout', () => {
- it('checkout(priceType) appelle createCheckoutSession avec le bon argument', async () => {
- createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
- const { result } = renderHook(() => useStripeCheckout(), { wrapper })
-
- act(() => {
- result.current.checkout('standard')
- })
-
- await waitFor(() => {
- expect(createCheckoutSessionMock).toHaveBeenCalledTimes(1)
- })
- expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard')
- })
-
- it('expose pendingPriceType pendant la mutation', async () => {
- createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
- const { result } = renderHook(() => useStripeCheckout(), { wrapper })
-
- expect(result.current.pendingPriceType).toBeNull()
- act(() => {
- result.current.checkout('premium')
- })
- expect(result.current.pendingPriceType).toBe('premium')
- })
-
- it("redirige window.location.href vers l'URL Stripe au succès", async () => {
- const originalLocation = window.location
- const hrefSetter = vi.fn()
- Object.defineProperty(window, 'location', {
- configurable: true,
- writable: true,
- value: {
- ...originalLocation,
- get href() {
- return originalLocation.href
- },
- set href(v: string) {
- hrefSetter(v)
- },
- },
- })
-
- createCheckoutSessionMock.mockResolvedValue({
- url: 'https://checkout.stripe.com/pay/cs_xyz',
- })
-
- const { result } = renderHook(() => useStripeCheckout(), { wrapper })
- act(() => {
- result.current.checkout('standard')
- })
-
- await waitFor(() => {
- expect(hrefSetter).toHaveBeenCalledWith('https://checkout.stripe.com/pay/cs_xyz')
- })
-
- Object.defineProperty(window, 'location', {
- configurable: true,
- writable: true,
- value: originalLocation,
- })
- })
-
- it("expose error et reset pendingPriceType en cas d'échec", async () => {
- createCheckoutSessionMock.mockRejectedValue(new Error('Configuration Stripe manquante.'))
- const { result } = renderHook(() => useStripeCheckout(), { wrapper })
-
- act(() => {
- result.current.checkout('standard')
- })
-
- await waitFor(() => {
- expect(result.current.error).toMatch(/Configuration Stripe manquante/)
- })
- expect(result.current.pendingPriceType).toBeNull()
- })
-})
diff --git a/src/features/billing/api.ts b/src/features/billing/api.ts
deleted file mode 100644
index 4e259a7..0000000
--- a/src/features/billing/api.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * Sprint 5b — API client billing.
- *
- * Wrappers TanStack-Query-friendly autour des endpoints Stripe :
- * - `POST /stripe/checkout` (création session paiement plein tarif)
- * - `POST /stripe/customer-portal` (Sprint 5d — Customer Portal Stripe)
- *
- * Le frontend ne stocke jamais de clé Stripe privée. Les `price_id` (publics
- * par nature, comme la clé Supabase anon) sont injectés via les variables
- * d'env `VITE_STRIPE_PRICE_*` — leur absence au runtime déclenche une erreur
- * explicite côté CTA, pas un crash silencieux.
- */
-
-import { apiFetch } from '@/shared/lib/api-client'
-import { env } from '@/shared/config/env'
-
-export type PriceType = 'standard' | 'premium'
-
-interface CheckoutResponse {
- url: string
-}
-
-interface CustomerPortalResponse {
- url: string
-}
-
-function resolvePriceId(priceType: PriceType): string {
- const id =
- priceType === 'standard' ? env.VITE_STRIPE_PRICE_STANDARD : env.VITE_STRIPE_PRICE_PREMIUM
- if (!id) {
- throw new Error(
- 'Configuration Stripe manquante. Veuillez réessayer plus tard ou contacter le support.',
- )
- }
- return id
-}
-
-export async function createCheckoutSession(priceType: PriceType): Promise {
- return apiFetch('/stripe/checkout', {
- method: 'POST',
- body: { priceId: resolvePriceId(priceType), planName: priceType },
- timeoutMs: 30_000,
- })
-}
-
-export async function createCustomerPortalSession(): Promise {
- return apiFetch('/stripe/customer-portal', {
- method: 'POST',
- timeoutMs: 15_000,
- })
-}
diff --git a/src/features/billing/components/AccountBillingSection.tsx b/src/features/billing/components/AccountBillingSection.tsx
deleted file mode 100644
index 535d5f5..0000000
--- a/src/features/billing/components/AccountBillingSection.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * Sprint 5d — Section Abonnement de la page Paramètres.
- *
- * Affiche le plan actuel + un CTA contextuel :
- * - Plan free → lien « Voir les plans » vers `/plan`.
- * - Plan payant → bouton « Gérer mon abonnement » → Stripe Customer Portal.
- *
- * Règle D : aucune comparaison `plan === 'xxx'` exposée hors d'un mapping
- * explicite (ici la branche est binaire free vs payant).
- * Règle L : tokens DA Charcoal exclusivement.
- */
-
-import { Link } from 'react-router-dom'
-import { Button } from '@/shared/ui/Button'
-import { Card } from '@/shared/ui/Card'
-import { Badge } from '@/shared/ui/Badge'
-import { usePlan } from '@/features/dashboard/hooks/usePlan'
-import { useCustomerPortal } from '../hooks/useCustomerPortal'
-
-const PLAN_LABEL: Record<'free' | 'standard' | 'premium', string> = {
- free: 'Plan Découverte',
- standard: 'Plan Standard',
- premium: 'Plan Premium',
-}
-
-export function AccountBillingSection() {
- const { data: planData, isLoading: isPlanLoading } = usePlan()
- const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal()
-
- if (isPlanLoading || !planData) {
- return (
-
-
-
- )
- }
-
- const plan = planData.plan as 'free' | 'standard' | 'premium'
- const isSubscribed = plan !== 'free'
-
- return (
-
-
- Abonnement
-
- {PLAN_LABEL[plan]}
-
-
-
- {isSubscribed ? (
- <>
-
- Modifier votre plan, mettre à jour votre moyen de paiement, ou consulter vos factures.
-
-
- Gérer mon abonnement
-
- {portalError && (
-
- {portalError}
-
- )}
- >
- ) : (
- <>
-
- Vous utilisez actuellement le plan gratuit (5 simulations à vie). Découvrez les plans
- payants pour un entraînement illimité avec correction détaillée.
-
-
-
- Voir les plans
-
-
- >
- )}
-
- )
-}
diff --git a/src/features/billing/components/PlanCard.tsx b/src/features/billing/components/PlanCard.tsx
deleted file mode 100644
index 8c13c57..0000000
--- a/src/features/billing/components/PlanCard.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * Carte plan tarifaire — Sprint 5b.
- *
- * Présentationnel pur (Règle H). Tokens DA Charcoal exclusivement (Règle L).
- * La logique CTA (qui est désactivé, qui est "Plan actuel", etc.) vit dans
- * `PricingPage.tsx`.
- */
-
-import { Check } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-
-export interface PlanCardCta {
- label: string
- variant: 'primary' | 'secondary'
- disabled?: boolean
- loading?: boolean
- onClick?: () => void
-}
-
-interface Props {
- title: string
- price: string
- priceCadence?: string
- description?: string
- features: string[]
- highlighted?: boolean
- currentBadge?: boolean
- cta: PlanCardCta
- /** Texte additionnel sous le bouton (ex. info prorata). */
- ctaHint?: string
- /** Message d'erreur affiché en bas de carte (ex. erreur mutation Stripe). */
- errorMessage?: string
-}
-
-export function PlanCard({
- title,
- price,
- priceCadence,
- description,
- features,
- highlighted = false,
- currentBadge = false,
- cta,
- ctaHint,
- errorMessage,
-}: Props) {
- const borderClass = highlighted
- ? 'border-brand shadow-[0_0_0_1px_var(--color-brand)]'
- : 'border-border'
-
- return (
-
- {currentBadge && (
-
- Plan actuel
-
- )}
-
-
-
{title}
- {description &&
{description}
}
-
-
-
-
- {price}
-
- {priceCadence && {priceCadence} }
-
-
-
- {features.map((feature) => (
-
-
- {feature}
-
- ))}
-
-
-
-
- {cta.label}
-
- {ctaHint &&
{ctaHint}
}
- {errorMessage && (
-
- {errorMessage}
-
- )}
-
-
- )
-}
diff --git a/src/features/billing/hooks/useCustomerPortal.ts b/src/features/billing/hooks/useCustomerPortal.ts
deleted file mode 100644
index c04b6cf..0000000
--- a/src/features/billing/hooks/useCustomerPortal.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * Sprint 5d — Hook Stripe Customer Portal.
- *
- * Wrap la mutation `createCustomerPortalSession` + redirect full-page vers
- * la session Customer Portal Stripe. Utilisé par :
- * - `AccountBillingSection` (page Paramètres) : bouton « Gérer mon abonnement ».
- * - `PricingPage` (Standard→Premium) : redirige vers le portal qui gère
- * nativement l'upgrade prorata + confirmation du montant.
- *
- * Erreur 400 `NO_ACTIVE_SUBSCRIPTION` propagée telle quelle (le backend
- * fournit déjà un message FR exploitable directement).
- */
-
-import { useState } from 'react'
-import { useMutation } from '@tanstack/react-query'
-import { createCustomerPortalSession } from '../api'
-
-export interface UseCustomerPortalResult {
- openPortal: () => void
- isLoading: boolean
- error: string | null
-}
-
-const FALLBACK_ERROR_MESSAGE =
- 'Impossible d’ouvrir l’espace abonnement. Réessayez dans quelques instants.'
-
-export function useCustomerPortal(): UseCustomerPortalResult {
- const [error, setError] = useState(null)
-
- const mutation = useMutation({
- mutationFn: createCustomerPortalSession,
- onSuccess: (data) => {
- // Redirect full-page : l'utilisateur reviendra sur ${APP_URL}/dashboard
- // (cf. backend `return_url`). Le query param `?upgrade=success` n'est PAS
- // ajouté par le portal — pas de banner de bienvenue dans ce flow,
- // seulement une éventuelle invalidation au refresh manuel.
- window.location.href = data.url
- },
- onError: (err: unknown) => {
- const message = err instanceof Error && err.message ? err.message : FALLBACK_ERROR_MESSAGE
- setError(message)
- },
- })
-
- function openPortal(): void {
- setError(null)
- mutation.mutate()
- }
-
- return {
- openPortal,
- isLoading: mutation.isPending,
- error,
- }
-}
diff --git a/src/features/billing/hooks/useStripeCheckout.ts b/src/features/billing/hooks/useStripeCheckout.ts
deleted file mode 100644
index 4d83c8c..0000000
--- a/src/features/billing/hooks/useStripeCheckout.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Sprint 5c — Hook checkout Stripe.
- *
- * Encapsule la mutation `createCheckoutSession` + la redirection full-page
- * vers Stripe Checkout. Expose `pendingPriceType` pour permettre aux pages
- * (ex. PricingPage) d'afficher un loading par carte sans state local.
- *
- * Usage typique :
- * const { checkout, pendingPriceType, error } = useStripeCheckout()
- * checkout('standard')}>
- * Choisir Standard
- *
- */
-
-import { useState } from 'react'
-import { useMutation } from '@tanstack/react-query'
-import { createCheckoutSession, type PriceType } from '../api'
-
-export interface UseStripeCheckoutResult {
- checkout: (priceType: PriceType) => void
- isLoading: boolean
- pendingPriceType: PriceType | null
- error: string | null
-}
-
-const FALLBACK_ERROR_MESSAGE =
- 'Impossible de démarrer le paiement. Réessayez dans quelques instants.'
-
-export function useStripeCheckout(): UseStripeCheckoutResult {
- const [pendingPriceType, setPendingPriceType] = useState(null)
- const [error, setError] = useState(null)
-
- const mutation = useMutation({
- mutationFn: createCheckoutSession,
- onSuccess: (data) => {
- // Redirection full-page vers Stripe Checkout. L'utilisateur reviendra
- // sur /dashboard?upgrade=success après paiement réussi (cf. backend
- // success_url) ou /plan?upgrade=cancelled en cas d'annulation.
- window.location.href = data.url
- },
- onError: (err: Error) => {
- setError(err.message || FALLBACK_ERROR_MESSAGE)
- setPendingPriceType(null)
- },
- })
-
- function checkout(priceType: PriceType): void {
- setError(null)
- setPendingPriceType(priceType)
- mutation.mutate(priceType)
- }
-
- return {
- checkout,
- isLoading: mutation.isPending,
- pendingPriceType,
- error,
- }
-}
diff --git a/src/features/billing/pages/PricingPage.tsx b/src/features/billing/pages/PricingPage.tsx
deleted file mode 100644
index 326aadc..0000000
--- a/src/features/billing/pages/PricingPage.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-/**
- * Page tarifaire `/plan` — Sprint 5b.
- *
- * 3 colonnes (Découverte / Standard / Premium). Le CTA de chaque carte dépend
- * du plan actuel de l'utilisateur :
- * - free → CTA Standard et Premium actifs (plein tarif).
- * - standard → Standard désactivé "Plan actuel" ; Premium = "Passer en Premium"
- * (sans prix affiché — Stripe calcule le prorata côté serveur).
- * - premium → Tous désactivés ; Premium marqué "Plan actuel".
- *
- * Le clic sur un CTA payant déclenche `createCheckoutSession(priceType)` puis
- * redirige le navigateur en full-page vers l'URL Stripe Checkout retournée.
- *
- * Règle D : aucun `plan === 'xxx'` exposé — la sélection du CTA passe par
- * une fonction `getCtaConfig(plan)` qui mappe explicitement chaque plan vers
- * ses CTA, sans `if/else` éparpillés.
- */
-
-import { useState } from 'react'
-import { usePlan } from '@/features/dashboard/hooks/usePlan'
-import { type PriceType } from '../api'
-import { useStripeCheckout } from '../hooks/useStripeCheckout'
-import { useCustomerPortal } from '../hooks/useCustomerPortal'
-import { PlanCard, type PlanCardCta } from '../components/PlanCard'
-
-type Plan = 'free' | 'standard' | 'premium'
-
-interface PlanColumn {
- key: 'free' | 'standard' | 'premium'
- title: string
- price: string
- priceCadence?: string
- description: string
- features: string[]
- highlighted: boolean
-}
-
-const COLUMNS: PlanColumn[] = [
- {
- key: 'free',
- title: 'Découverte',
- price: 'Gratuit',
- description: 'Goûter le produit, voir comment ça marche.',
- features: [
- '5 simulations à vie',
- 'Score global et niveau NCLC',
- 'Feedback court (2-3 lignes)',
- 'Accès EE T1, T2, T3 et EO T1, T3',
- ],
- highlighted: false,
- },
- {
- key: 'standard',
- title: 'Standard',
- price: '19,90 €',
- priceCadence: '/ 4 semaines',
- description: 'Progression sérieuse — toutes les corrections détaillées.',
- features: [
- 'Simulations illimitées',
- 'Rapport détaillé par critère',
- 'Suggestions, exercices, production modèle',
- 'Historique complet et dashboard',
- 'Indice de préparation après 5 productions',
- ],
- highlighted: true,
- },
- {
- key: 'premium',
- title: 'Premium',
- price: '39,90 €',
- priceCadence: '/ 4 semaines',
- description: 'Tout Standard, plus les outils de simulation réelle.',
- features: [
- 'Tout le plan Standard',
- 'Mode Examen (60 min EE / 12 min EO)',
- 'EO Tâche 2 — dialogue live avec l’examinateur IA',
- 'Analyse des patterns sur 5 dernières productions',
- 'Exercices long terme personnalisés',
- ],
- highlighted: false,
- },
-]
-
-interface CtaConfigs {
- standard: { cta: PlanCardCta; hint?: string }
- premium: { cta: PlanCardCta; hint?: string }
-}
-
-function buildCtaConfigs(
- plan: Plan,
- isStandardPending: boolean,
- isPremiumPending: boolean,
- onUpgrade: (priceType: PriceType) => void,
-): CtaConfigs {
- const anyPending = isStandardPending || isPremiumPending
-
- if (plan === 'free') {
- return {
- standard: {
- cta: {
- label: 'Choisir Standard — 19,90 €/4 sem.',
- variant: 'primary',
- loading: isStandardPending,
- disabled: anyPending,
- onClick: () => onUpgrade('standard'),
- },
- },
- premium: {
- cta: {
- label: 'Choisir Premium — 39,90 €/4 sem.',
- variant: 'primary',
- loading: isPremiumPending,
- disabled: anyPending,
- onClick: () => onUpgrade('premium'),
- },
- },
- }
- }
-
- if (plan === 'standard') {
- return {
- standard: {
- cta: { label: 'Plan actuel', variant: 'secondary', disabled: true },
- },
- premium: {
- cta: {
- label: 'Passer en Premium',
- variant: 'primary',
- loading: isPremiumPending,
- disabled: anyPending,
- onClick: () => onUpgrade('premium'),
- },
- hint: 'Stripe calculera automatiquement le prorata sur votre abonnement en cours.',
- },
- }
- }
-
- // premium
- return {
- standard: {
- cta: { label: 'Inférieur à votre plan', variant: 'secondary', disabled: true },
- },
- premium: {
- cta: { label: 'Plan actuel', variant: 'secondary', disabled: true },
- },
- }
-}
-
-export function PricingPage() {
- const { data: planData, isLoading } = usePlan()
- const { checkout, pendingPriceType, error: checkoutError } = useStripeCheckout()
- const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal()
- // Mémorise le dernier priceType cliqué pour rattacher l'erreur (checkout OU
- // portal) à la bonne carte. L'utilisateur ne clique qu'un CTA à la fois.
- const [lastClicked, setLastClicked] = useState(null)
-
- const plan = (planData?.plan as Plan | undefined) ?? 'free'
-
- // Sprint 5d — branche Standard→Premium via Customer Portal (Stripe affiche
- // le montant prorata + confirmation native). Free→* reste sur Checkout direct.
- function handleUpgrade(priceType: PriceType) {
- setLastClicked(priceType)
- if (plan === 'standard') {
- openPortal()
- } else {
- checkout(priceType)
- }
- }
-
- // Loading par carte : combine la source pertinente selon le plan utilisateur.
- const isStandardPending = pendingPriceType === 'standard'
- const isPremiumPending = plan === 'standard' ? isPortalLoading : pendingPriceType === 'premium'
-
- const ctaConfigs = buildCtaConfigs(plan, isStandardPending, isPremiumPending, handleUpgrade)
- const effectiveError = checkoutError ?? portalError
- const errorByType: Partial> =
- effectiveError && lastClicked ? { [lastClicked]: effectiveError } : {}
-
- return (
-
-
-
-
- {COLUMNS.map((col) => {
- if (col.key === 'free') {
- return (
-
- )
- }
- const config = ctaConfigs[col.key]
- return (
-
- )
- })}
-
-
- {isLoading && (
-
- Chargement de votre plan…
-
- )}
-
- )
-}
diff --git a/src/features/dashboard/components/DashboardFreeView.tsx b/src/features/dashboard/components/DashboardFreeView.tsx
deleted file mode 100644
index 3765d4d..0000000
--- a/src/features/dashboard/components/DashboardFreeView.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * DashboardFreeView — vue Dashboard pour le plan Découverte.
- *
- * Spécificités Free :
- * - Pas d'appel `useSimulationsList` (gate 'dashboard' à false côté backend).
- * - Hero NCLC en état placeholder (pas d'historique lisible).
- * - Stat cards avec "NCLC estimé —" et "Dernier score —".
- * - Recommandation statique vers la première simulation EE T2.
- * - Bannière upsell Standard en bas.
- *
- * Règle D : aucun `plan === 'free'` — c'est le parent (DashboardPage) qui
- * route vers cette vue via hasAccess.
- * Règle H : aucune logique métier — les données viennent des props.
- * Règle L : tokens du design system exclusivement.
- */
-
-import { useNavigate } from 'react-router-dom'
-import { Plus } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { Badge } from '@/shared/ui/Badge'
-import { NclcHero } from './NclcHero'
-import { StatCards } from './StatCards'
-import { NextStepCard } from './NextStepCard'
-import { PaywallBanner } from './PaywallBanner'
-
-interface DashboardFreeViewProps {
- displayName: string
- simulationsUsed: number
- simulationsRemaining: number
- canStartSimulation: boolean
-}
-
-const FREE_CONSEIL =
- "Commencez par une simulation d'Expression Écrite pour découvrir votre niveau. " +
- 'Le rapport détaillé et le suivi NCLC se débloquent avec le plan Standard.'
-
-export function DashboardFreeView({
- displayName,
- simulationsUsed,
- simulationsRemaining,
- canStartSimulation,
-}: DashboardFreeViewProps) {
- const navigate = useNavigate()
-
- return (
-
- {/* Header */}
-
-
-
Bonjour, {displayName}
-
- Plan Découverte
-
-
-
- navigate('/plan')}>
- Voir les plans
-
- }
- disabled={!canStartSimulation}
- onClick={() => navigate('/simulation/ee')}
- >
- Nouvelle simulation
-
-
-
-
- {/* Hero NCLC — placeholder en Free */}
-
-
- {/* Stat cards — NCLC et dernier score vides */}
-
-
- {/* Prochaine étape + (pas de simulations récentes en Free) */}
-
-
-
- Pour bien démarrer
-
- Votre première simulation
-
- Choisissez une tâche d'Expression Écrite pour obtenir un premier score et une estimation
- NCLC. Vos 5 simulations gratuites vous attendent.
-
-
-
-
-
-
- {/* Bannière upsell */}
-
-
- )
-}
diff --git a/src/features/dashboard/components/DashboardPremiumView.tsx b/src/features/dashboard/components/DashboardPremiumView.tsx
deleted file mode 100644
index 6f7fabd..0000000
--- a/src/features/dashboard/components/DashboardPremiumView.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * DashboardPremiumView — vue Dashboard pour le plan Premium.
- *
- * Spécificités Premium :
- * - Historique via `useSimulationsList`.
- * - NCLC = dernière simulation (comme Standard).
- * - Indice de préparation 0–100 via `MonProfilPreparation` (patterns).
- * - Pas de CTA "Passer en Premium" — déjà au top-tier.
- *
- * Règle D : aucun `plan === 'premium'` — routing via hasAccess côté parent.
- * Règle H : logique d'affichage uniquement.
- * Règle L : tokens du design system exclusivement.
- */
-
-import { useNavigate } from 'react-router-dom'
-import { Plus } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { Badge } from '@/shared/ui/Badge'
-import { useSimulationsList } from '@/features/historique/hooks/useSimulationsList'
-import { NclcHero } from './NclcHero'
-import { StatCards } from './StatCards'
-import { RecentSimulations } from './RecentSimulations'
-import { NextStepCard } from './NextStepCard'
-import { MonProfilPreparation } from './MonProfilPreparation'
-
-interface DashboardPremiumViewProps {
- displayName: string
- simulationsUsed: number
-}
-
-const PREMIUM_CONSEIL =
- 'Votre préparation est avancée. Enchaînez un Examen blanc chaque semaine pour verrouiller votre NCLC cible.'
-
-export function DashboardPremiumView({ displayName, simulationsUsed }: DashboardPremiumViewProps) {
- const navigate = useNavigate()
- const { data } = useSimulationsList(1, 5)
- const recent = data?.data ?? []
- const totalCount = data?.pagination.total ?? 0
-
- const firstWithNclc = recent.find((s) => s.nclc !== null) ?? null
- const lastNclc = firstWithNclc?.nclc ?? null
-
- const firstWithScore = recent.find((s) => s.score !== null) ?? null
- const lastScore =
- firstWithScore && firstWithScore.score !== null
- ? { value: firstWithScore.score, max: 20 }
- : null
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/src/features/dashboard/components/DashboardStandardView.tsx b/src/features/dashboard/components/DashboardStandardView.tsx
deleted file mode 100644
index 5f281dd..0000000
--- a/src/features/dashboard/components/DashboardStandardView.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * DashboardStandardView — vue Dashboard pour le plan Standard.
- *
- * Spécificités Standard :
- * - Historique lisible via `useSimulationsList`.
- * - NCLC estimé = NCLC de la dernière simulation (premier item avec nclc non-null).
- * - Pas de `MonProfilPreparation` (pattern_analysis gated Premium).
- * - CTA "Passer en Premium →" + "+ Nouvelle simulation".
- *
- * Règle D : aucun `plan === 'standard'` — c'est le parent (DashboardPage) qui
- * route vers cette vue via hasAccess.
- * Règle H : logique d'affichage uniquement.
- * Règle L : tokens du design system exclusivement.
- */
-
-import { useNavigate } from 'react-router-dom'
-import { Plus } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { Badge } from '@/shared/ui/Badge'
-import { useSimulationsList } from '@/features/historique/hooks/useSimulationsList'
-import { NclcHero } from './NclcHero'
-import { StatCards } from './StatCards'
-import { RecentSimulations } from './RecentSimulations'
-import { NextStepCard } from './NextStepCard'
-
-interface DashboardStandardViewProps {
- displayName: string
- simulationsUsed: number
-}
-
-const STD_CONSEIL =
- 'Votre préparation avance. Continuez la régularité — visez une simulation tous les deux jours pour sécuriser votre NCLC cible.'
-
-export function DashboardStandardView({
- displayName,
- simulationsUsed,
-}: DashboardStandardViewProps) {
- const navigate = useNavigate()
- const { data } = useSimulationsList(1, 5)
- const recent = data?.data ?? []
- const totalCount = data?.pagination.total ?? 0
-
- const firstWithNclc = recent.find((s) => s.nclc !== null) ?? null
- const lastNclc = firstWithNclc?.nclc ?? null
-
- const firstWithScore = recent.find((s) => s.score !== null) ?? null
- const lastScore =
- firstWithScore && firstWithScore.score !== null
- ? { value: firstWithScore.score, max: 20 }
- : null
-
- return (
-
-
-
-
Bonjour, {displayName}
-
- Plan Standard
-
-
-
- navigate('/plan')}>
- Passer en Premium →
-
- }
- onClick={() => navigate('/simulation/ee')}
- >
- Nouvelle simulation
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/src/features/dashboard/components/MonProfilPreparation.tsx b/src/features/dashboard/components/MonProfilPreparation.tsx
deleted file mode 100644
index 7ca440c..0000000
--- a/src/features/dashboard/components/MonProfilPreparation.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * MonProfilPreparation — Sprint 3.6c.
- *
- * Section compacte du Dashboard Premium qui résume l'analyse des patterns :
- * - Premium + ready → indice de préparation + « N erreurs récurrentes » + CTA
- * - Premium + not-ready → message compact « Encore X simulations »
- * - Free + Standard → ne rend rien (composant court-circuite)
- *
- * Le hook `usePatterns` court-circuite déjà la requête côté client si
- * !hasAccess(plan, 'pattern_analysis'), donc aucun appel backend parasite
- * pour Free/Standard. La garde locale ici empêche aussi un flash de contenu
- * si le composant est monté par erreur.
- *
- * Règle D : gating via hasAccess, jamais `plan === 'premium'`.
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { Link } from 'react-router-dom'
-import { ArrowRight } from 'lucide-react'
-import { Card } from '@/shared/ui/Card'
-import { Button } from '@/shared/ui/Button'
-import { hasAccess, type Plan } from '@/entities/user/lib'
-import { usePatterns } from '@/features/progression/hooks/usePatterns'
-
-interface Props {
- plan: Plan
-}
-
-function gaugeColor(score: number): string {
- if (score < 40) return 'bg-danger'
- if (score <= 70) return 'bg-warning'
- return 'bg-success'
-}
-
-export function MonProfilPreparation({ plan }: Props) {
- // Hook appelé inconditionnellement (règle React). Il court-circuite la
- // requête backend via `enabled: hasAccess(plan, 'pattern_analysis')`,
- // donc aucun appel parasite pour Free/Standard.
- const { data, isLoading, isError } = usePatterns(plan)
-
- // Garde explicite après le hook pour éviter un flash de contenu.
- if (!hasAccess(plan, 'pattern_analysis')) return null
-
- if (isLoading || isError || !data) {
- return (
-
-
- Mon profil de préparation
-
-
- {isError ? 'Profil temporairement indisponible.' : 'Chargement…'}
-
-
- )
- }
-
- if (!data.ready) {
- const remaining = Math.max(0, data.minimum - data.current)
- return (
-
-
- Mon profil de préparation
-
-
- Encore {remaining} {' '}
- {remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil.
-
-
- {data.current}/{data.minimum} simulations corrigées
-
-
- )
- }
-
- const patternsCount = data.patterns.length
- const pct = Math.max(0, Math.min(100, data.preparation_index.score))
- const color = gaugeColor(pct)
-
- return (
-
-
-
-
- Indice de préparation
-
-
- {data.preparation_index.score}
- /100
-
-
-
- {data.preparation_index.message}
-
-
-
-
-
-
- {patternsCount === 0
- ? 'Aucune erreur récurrente identifiée — continuez !'
- : `${patternsCount} ${patternsCount > 1 ? 'erreurs récurrentes identifiées' : 'erreur récurrente identifiée'}.`}
-
-
-
-
- Voir mon profil de préparation
-
-
-
-
- )
-}
diff --git a/src/features/dashboard/components/NclcHero.tsx b/src/features/dashboard/components/NclcHero.tsx
deleted file mode 100644
index f91c7bb..0000000
--- a/src/features/dashboard/components/NclcHero.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * NclcHero — carte principale du Dashboard.
- *
- * Affiche :
- * - l'indice NCLC courant (via valeur passée par le parent — usePatterns
- * en Premium, dernière simu en Standard, null en Free) ;
- * - l'objectif NCLC (défaut 9) et le conseil personnalisé ;
- * - la jauge horizontale 5 → 10 avec position actuelle + marqueur cible ;
- * - le dernier score dans un anneau SVG (facultatif).
- *
- * Règle H : aucune logique métier — les valeurs sont calculées par le parent.
- * Règle L : tokens du design system exclusivement.
- */
-
-import { Card } from '@/shared/ui/Card'
-
-interface LastScore {
- value: number
- max: number
-}
-
-interface NclcHeroProps {
- /** NCLC actuel (5–10). `null` = pas de donnée (Free ou historique vide). */
- currentNclc: number | null
- /** Libellé du NCLC (ex. "NCLC estimé", "NCLC dernière simulation"). */
- nclcLabel?: string
- /** NCLC cible (défaut 9). */
- targetNclc?: number
- /** Texte conseil affiché sous le NCLC. */
- conseil: string
- /** Dernier score pour l'anneau SVG (optionnel). */
- lastScore?: LastScore | null
-}
-
-const NCLC_MIN = 5
-const NCLC_MAX = 10
-const CIRCLE_RADIUS = 44
-const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * CIRCLE_RADIUS
-
-function clamp(value: number, min: number, max: number): number {
- return Math.max(min, Math.min(max, value))
-}
-
-function formatNclc(n: number): string {
- return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
-}
-
-function nclcToPct(n: number): number {
- const clamped = clamp(n, NCLC_MIN, NCLC_MAX)
- return ((clamped - NCLC_MIN) / (NCLC_MAX - NCLC_MIN)) * 100
-}
-
-function ScoreRing({ score }: { score: LastScore }) {
- const pct = clamp((score.value / score.max) * 100, 0, 100)
- const offset = CIRCLE_CIRCUMFERENCE * (1 - pct / 100)
-
- return (
-
-
-
-
-
-
- {score.value}
- /{score.max}
-
- Dernier score
-
-
-
- )
-}
-
-export function NclcHero({
- currentNclc,
- nclcLabel = 'NCLC estimé',
- targetNclc = 9,
- conseil,
- lastScore = null,
-}: NclcHeroProps) {
- const hasNclc = currentNclc !== null
- const currentPct = hasNclc ? nclcToPct(currentNclc) : 0
- const targetPct = nclcToPct(targetNclc)
-
- return (
-
-
- {/* Left block */}
-
-
- Indice de préparation TCF Canada
-
-
-
-
- {hasNclc ? `NCLC ${formatNclc(currentNclc)}` : 'NCLC —'}
-
-
- Objectif NCLC {targetNclc}+
-
-
-
-
{conseil}
-
- {/* Jauge 5 → 10 */}
-
-
- NCLC {NCLC_MIN}
- NCLC {NCLC_MAX}
-
-
- {hasNclc && (
-
- )}
- {/* Marqueur cible */}
-
-
-
-
-
- {/* Right block — score ring */}
- {lastScore &&
}
-
-
- )
-}
diff --git a/src/features/dashboard/components/NextStepCard.tsx b/src/features/dashboard/components/NextStepCard.tsx
deleted file mode 100644
index 5b5cd4a..0000000
--- a/src/features/dashboard/components/NextStepCard.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * NextStepCard — encart "Prochaine étape" affiché à droite des simulations.
- *
- * Contenu statique par plan pour ce sprint (pas d'endpoint "recommandation"
- * en V1). Le parent construit le texte, les tags et la route CTA.
- *
- * Règle H : aucune logique métier — affichage pur.
- * Règle L : tokens du design system exclusivement.
- */
-
-import { Link } from 'react-router-dom'
-import { ArrowRight, Sparkles } from 'lucide-react'
-import { Card } from '@/shared/ui/Card'
-
-interface NextStepCardProps {
- title: string
- conseil: string
- tags: readonly string[]
- ctaLabel: string
- ctaTo: string
-}
-
-export function NextStepCard({ title, conseil, tags, ctaLabel, ctaTo }: NextStepCardProps) {
- return (
-
-
- Recommandé
-
-
-
-
- {tags.length > 0 && (
-
- {tags.map((tag) => (
-
- {tag}
-
- ))}
-
- )}
-
-
- {ctaLabel}
-
-
-
- )
-}
diff --git a/src/features/dashboard/components/PaywallBanner.tsx b/src/features/dashboard/components/PaywallBanner.tsx
deleted file mode 100644
index b0bf7c7..0000000
--- a/src/features/dashboard/components/PaywallBanner.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Bannière inline affichée en bas du dashboard pour les utilisateurs Free.
- * Présente les features débloquées par Standard et oriente vers /plan.
- *
- * DA Charcoal : surface-solid + border-border, icône + dans cercle brand.
- * Intégrée dans le flux de la page (pas de modale) — cf. PARCOURS_UTILISATEURS §2.
- */
-
-import { Link } from 'react-router-dom'
-import { Plus } from 'lucide-react'
-
-export function PaywallBanner() {
- return (
-
-
-
-
-
-
-
- Débloque le rapport complet et l'IA de correction détaillée
-
-
- Plan Standard · simulations illimitées · suivi NCLC dans le temps · 19,90 € / 4 semaines
-
-
-
-
- Voir les plans
-
-
- )
-}
diff --git a/src/features/dashboard/components/RecentSimulations.tsx b/src/features/dashboard/components/RecentSimulations.tsx
deleted file mode 100644
index 2af42c6..0000000
--- a/src/features/dashboard/components/RecentSimulations.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * RecentSimulations — liste des 3 dernières simulations sur le Dashboard.
- *
- * Chaque item est cliquable (→ /rapport/:id). Badge NCLC coloré selon le niveau,
- * score /20, date relative, type court (EE · T2 / EO · T1).
- *
- * Règle H : aucune logique métier — les données viennent du parent.
- * Règle L : tokens du design system exclusivement.
- */
-
-import { Link } from 'react-router-dom'
-import { ChevronRight } from 'lucide-react'
-import { Card } from '@/shared/ui/Card'
-import { formatRelativeDate } from '@/shared/lib/date'
-import { isEcrit } from '@/entities/production/lib'
-import type { SimulationListItem, Tache } from '@/entities/production/types'
-
-interface RecentSimulationsProps {
- /** Items récents (max 3 affichés). */
- items: readonly SimulationListItem[]
- /** Total historique — affiché en badge à droite du titre. */
- totalCount: number
-}
-
-function shortTacheLabel(tache: Tache): string {
- const [prefix, num] = tache.split('_')
- return `${prefix} · ${num}`
-}
-
-function nclcBadgeClasses(nclc: number | null): string {
- if (nclc === null) return 'bg-surface-hover text-ink-tertiary'
- if (nclc >= 9) return 'bg-success-soft text-success'
- if (nclc >= 7) return 'bg-brand-soft text-brand-text'
- return 'bg-warning-soft text-warning'
-}
-
-function formatNclc(n: number): string {
- return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
-}
-
-export function RecentSimulations({ items, totalCount }: RecentSimulationsProps) {
- const visible = items.slice(0, 3)
-
- return (
-
-
-
3 dernières simulations
- {totalCount > 0 && (
-
- {totalCount} au total
-
- )}
-
-
- {visible.length === 0 ? (
-
- Aucune simulation pour l'instant.
-
- ) : (
-
- {visible.map((item) => {
- const type = isEcrit(item.tache) ? 'EE' : 'EO'
- return (
-
-
-
- {shortTacheLabel(item.tache)}
- · {type}
-
-
{formatRelativeDate(item.created_at)}
-
-
- {item.nclc !== null && (
-
- NCLC {formatNclc(item.nclc)}
-
- )}
-
-
- {item.score === null ? '—' : `${item.score}/20`}
-
-
-
-
- )
- })}
-
- )}
-
- )
-}
diff --git a/src/features/dashboard/components/StatCards.tsx b/src/features/dashboard/components/StatCards.tsx
deleted file mode 100644
index 53c1ede..0000000
--- a/src/features/dashboard/components/StatCards.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-/**
- * StatCards — trois cartes synthétiques affichées sur le Dashboard.
- *
- * - Simulations restantes (barre de progression pour Free, "Illimitées" ailleurs)
- * - NCLC estimé (dernière simulation)
- * - Dernier score (+ delta vs précédent)
- *
- * Règle H : aucune logique métier de gating ici — le parent décide du rendu
- * global via hasAccess. Ce composant ne fait que formater les
- * valeurs déjà fournies.
- * Règle L : tokens du design system exclusivement.
- */
-
-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 { hasAccess, type Plan } from '@/entities/user/lib'
-
-interface StatCardsProps {
- plan: Plan
- simulationsUsed: number
- /** null = illimité (Standard/Premium), number = reste (Free). */
- simulationsRemaining: number | null
- /** Liste des dernières simulations (index 0 = la plus récente). */
- recentSimulations: readonly SimulationListItem[]
-}
-
-function formatNclc(n: number): string {
- return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
-}
-
-function formatScore(value: number): string {
- return value.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
-}
-
-function StatShell({ label, children }: { label: string; children: React.ReactNode }) {
- return (
-
-
- {label}
-
- {children}
-
- )
-}
-
-function SimulationsRestantesCard({
- plan,
- simulationsUsed,
- simulationsRemaining,
-}: {
- plan: Plan
- simulationsUsed: number
- simulationsRemaining: number | null
-}) {
- if (simulationsRemaining === null) {
- return (
-
- Illimitées
-
- {simulationsUsed} effectuée{simulationsUsed > 1 ? 's' : ''}
-
-
- )
- }
-
- const total = simulationsUsed + simulationsRemaining
- const pct = total > 0 ? (simulationsUsed / total) * 100 : 0
-
- return (
-
-
- {simulationsRemaining}
- /{total}
-
-
- {!hasAccess(plan, 'dashboard') && (
- Renouvellement offert à l'upgrade
- )}
-
- )
-}
-
-function NclcCard({ lastSim }: { lastSim: SimulationListItem | null }) {
- if (!lastSim || lastSim.nclc === null) {
- return (
-
- —
-
- Démarrez une simulation pour estimer votre niveau.
-
-
- )
- }
-
- const nclc = lastSim.nclc
- const inTarget = nclc >= 7
-
- return (
-
- {formatNclc(nclc)}
-
- {inTarget ? 'Dans la cible CLB 7+' : 'Visez la cible CLB 7+'}
-
-
- )
-}
-
-function DernierScoreCard({
- recentSimulations,
-}: {
- recentSimulations: readonly SimulationListItem[]
-}) {
- const lastWithScore = recentSimulations.find((s) => s.score !== null) ?? null
- if (!lastWithScore || lastWithScore.score === null) {
- return (
-
- —
- Aucun score enregistré.
-
- )
- }
-
- // Précédente simulation avec score, pour calculer le delta.
- const previous =
- recentSimulations.filter((s) => s.id !== lastWithScore.id && s.score !== null).at(0) ?? null
- const delta = previous && previous.score !== null ? lastWithScore.score - previous.score : null
-
- const type = isEcrit(lastWithScore.tache) ? 'Écrit' : 'Oral'
- const relative = formatRelativeDate(lastWithScore.created_at)
-
- return (
-
-
- {formatScore(lastWithScore.score)}
- /20
-
-
- {type}
-
- ·
-
- {relative}
- {delta !== null && delta !== 0 && (
- 0 ? 'font-semibold text-success' : 'font-semibold text-warning'}>
- {delta > 0 ? '+' : ''}
- {formatScore(delta)} vs précédent
-
- )}
-
-
- )
-}
-
-export function StatCards({
- plan,
- simulationsUsed,
- simulationsRemaining,
- recentSimulations,
-}: StatCardsProps) {
- const lastSim = recentSimulations.at(0) ?? null
-
- return (
-
- )
-}
diff --git a/src/features/dashboard/components/UpgradeSuccessBanner.tsx b/src/features/dashboard/components/UpgradeSuccessBanner.tsx
deleted file mode 100644
index 7fd8bc3..0000000
--- a/src/features/dashboard/components/UpgradeSuccessBanner.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * Sprint 5c — Banner affiché au retour de Stripe Checkout réussi.
- *
- * Présentationnel pur (Règle H). Le déclenchement et le nettoyage URL sont
- * gérés par `useUpgradeSuccessHandler` côté DashboardPage.
- *
- * Tokens DA Charcoal exclusivement (Règle L).
- */
-
-import { CheckCircle2, X } from 'lucide-react'
-
-interface Props {
- onDismiss: () => void
-}
-
-export function UpgradeSuccessBanner({ onDismiss }: Props) {
- return (
-
-
-
-
Bienvenue ! Votre plan a été mis à jour.
-
- Si certaines features semblent manquer, rafraîchissez la page dans quelques secondes — la
- confirmation Stripe peut prendre un instant.
-
-
-
-
-
-
- )
-}
diff --git a/src/features/dashboard/components/__tests__/MonProfilPreparation.test.tsx b/src/features/dashboard/components/__tests__/MonProfilPreparation.test.tsx
deleted file mode 100644
index 0f0f85f..0000000
--- a/src/features/dashboard/components/__tests__/MonProfilPreparation.test.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * Tests — MonProfilPreparation (Sprint 3.6c).
- *
- * Couvre le gating plan : absent Free/Standard, visible Premium (ready + not-ready).
- * Le hook `usePatterns` est mocké pour isoler la présentation.
- */
-
-import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
-import { render, screen, cleanup } from '@testing-library/react'
-import { MemoryRouter } from 'react-router-dom'
-
-vi.mock('@/features/progression/hooks/usePatterns', () => ({
- usePatterns: vi.fn(),
-}))
-
-import { usePatterns } from '@/features/progression/hooks/usePatterns'
-import { MonProfilPreparation } from '../MonProfilPreparation'
-
-beforeEach(() => {
- // Mock par défaut — usePatterns est appelé inconditionnellement depuis le
- // composant (Règle des hooks). Les tests Premium surchargent ce mock.
- vi.mocked(usePatterns).mockReturnValue({
- data: undefined,
- isLoading: false,
- isError: false,
- } as unknown as ReturnType)
-})
-
-afterEach(cleanup)
-
-function renderWithRouter(ui: React.ReactNode) {
- return render({ui} )
-}
-
-describe('MonProfilPreparation — gating plan', () => {
- it('plan free → ne rend rien', () => {
- const { container } = renderWithRouter( )
- expect(container).toBeEmptyDOMElement()
- })
-
- it('plan standard → ne rend rien', () => {
- const { container } = renderWithRouter( )
- expect(container).toBeEmptyDOMElement()
- })
-})
-
-describe('MonProfilPreparation — plan premium', () => {
- it('ready: true → affiche score, message, nb patterns, CTA /progression', () => {
- vi.mocked(usePatterns).mockReturnValue({
- data: {
- ready: true,
- patterns: [
- {
- code: 'accord_sujet_verbe',
- critere: 'competence_grammaticale',
- frequency: 4,
- description: null,
- },
- {
- code: 'connecteurs_repetes',
- critere: 'coherence_cohesion',
- frequency: 3,
- description: null,
- },
- {
- code: 'repetition_lexicale',
- critere: 'competence_lexicale',
- frequency: 3,
- description: null,
- },
- ],
- exercises: [],
- preparation_index: { score: 72, message: 'Vous êtes en bonne voie pour NCLC 9+' },
- analyzed_productions: 5,
- last_analysis: '2026-04-22T12:00:00Z',
- },
- isLoading: false,
- isError: false,
- } as unknown as ReturnType)
-
- renderWithRouter( )
-
- expect(screen.getByText('72')).toBeInTheDocument()
- expect(screen.getByText(/NCLC 9/)).toBeInTheDocument()
- expect(screen.getByText(/3 erreurs récurrentes identifiées/i)).toBeInTheDocument()
- expect(screen.getByRole('link', { name: /voir mon profil de préparation/i })).toHaveAttribute(
- 'href',
- '/progression',
- )
- })
-
- it('ready: false → message compact "Encore X simulations"', () => {
- vi.mocked(usePatterns).mockReturnValue({
- data: { ready: false, minimum: 5, current: 2 },
- isLoading: false,
- isError: false,
- } as unknown as ReturnType)
-
- renderWithRouter( )
-
- expect(screen.getByText(/encore/i)).toBeInTheDocument()
- // Le nombre restant (3) est dans un span séparé du mot "simulations"
- expect(screen.getByText('3')).toBeInTheDocument()
- expect(screen.getByText(/pour débloquer votre profil/i)).toBeInTheDocument()
- expect(screen.getByText(/2\/5 simulations corrigées/i)).toBeInTheDocument()
- })
-
- it('isLoading → placeholder "Chargement"', () => {
- vi.mocked(usePatterns).mockReturnValue({
- data: undefined,
- isLoading: true,
- isError: false,
- } as unknown as ReturnType)
-
- renderWithRouter( )
-
- expect(screen.getByText(/chargement/i)).toBeInTheDocument()
- })
-
- it('isError → message "temporairement indisponible"', () => {
- vi.mocked(usePatterns).mockReturnValue({
- data: undefined,
- isLoading: false,
- isError: true,
- } as unknown as ReturnType)
-
- renderWithRouter( )
-
- expect(screen.getByText(/temporairement indisponible/i)).toBeInTheDocument()
- })
-
- it('ready: true avec 0 pattern → message "Aucune erreur récurrente"', () => {
- vi.mocked(usePatterns).mockReturnValue({
- data: {
- ready: true,
- patterns: [],
- exercises: [],
- preparation_index: { score: 85, message: 'Vous êtes en bonne voie pour NCLC 9+' },
- analyzed_productions: 5,
- last_analysis: '2026-04-22T12:00:00Z',
- },
- isLoading: false,
- isError: false,
- } as unknown as ReturnType)
-
- renderWithRouter( )
-
- expect(screen.getByText(/aucune erreur récurrente/i)).toBeInTheDocument()
- expect(screen.getByText('85')).toBeInTheDocument()
- })
-})
diff --git a/src/features/dashboard/hooks/__tests__/useUpgradeSuccessHandler.test.tsx b/src/features/dashboard/hooks/__tests__/useUpgradeSuccessHandler.test.tsx
deleted file mode 100644
index c60e517..0000000
--- a/src/features/dashboard/hooks/__tests__/useUpgradeSuccessHandler.test.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { renderHook, act } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import type { ReactNode } from 'react'
-
-import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
-import { useUpgradeSuccessHandler } from '../useUpgradeSuccessHandler'
-
-let invalidateSpy: ReturnType
-let queryClient: QueryClient
-
-function wrapper({ children }: { children: ReactNode }) {
- return {children}
-}
-
-function setLocation(search: string) {
- window.history.replaceState(null, '', `/dashboard${search}`)
-}
-
-beforeEach(() => {
- queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false } },
- })
- invalidateSpy = vi.fn().mockResolvedValue(undefined)
- // Spy sur la méthode invalidateQueries pour vérifier la clé exacte.
- queryClient.invalidateQueries = invalidateSpy as unknown as typeof queryClient.invalidateQueries
-})
-
-afterEach(() => {
- setLocation('')
-})
-
-describe('useUpgradeSuccessHandler', () => {
- it('?upgrade=success → showSuccess=true, invalidate(PLAN_QUERY_KEY) appelé, URL nettoyée', () => {
- setLocation('?upgrade=success')
-
- const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
-
- expect(result.current.showSuccess).toBe(true)
- expect(invalidateSpy).toHaveBeenCalledTimes(1)
- expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: PLAN_QUERY_KEY })
- // URL nettoyée : plus de `upgrade` dans la query string.
- expect(window.location.search).toBe('')
- })
-
- it('absence de query param → showSuccess=false, invalidate non appelé', () => {
- setLocation('')
-
- const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
-
- expect(result.current.showSuccess).toBe(false)
- expect(invalidateSpy).not.toHaveBeenCalled()
- })
-
- it("?upgrade=cancelled (autre valeur) → showSuccess=false, pas d'action", () => {
- setLocation('?upgrade=cancelled')
-
- const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
-
- expect(result.current.showSuccess).toBe(false)
- expect(invalidateSpy).not.toHaveBeenCalled()
- // URL conservée intacte (autre valeur, hors scope du nettoyage).
- expect(window.location.search).toBe('?upgrade=cancelled')
- })
-
- it('dismiss() bascule showSuccess à false', () => {
- setLocation('?upgrade=success')
-
- const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
- expect(result.current.showSuccess).toBe(true)
-
- act(() => {
- result.current.dismiss()
- })
- expect(result.current.showSuccess).toBe(false)
- })
-
- it('conserve les autres query params (utm_*, etc.) lors du nettoyage', () => {
- setLocation('?upgrade=success&utm_source=email&ref=abc')
-
- renderHook(() => useUpgradeSuccessHandler(), { wrapper })
-
- const params = new URLSearchParams(window.location.search)
- expect(params.has('upgrade')).toBe(false)
- expect(params.get('utm_source')).toBe('email')
- expect(params.get('ref')).toBe('abc')
- })
-})
diff --git a/src/features/dashboard/hooks/usePlan.ts b/src/features/dashboard/hooks/usePlan.ts
deleted file mode 100644
index 4a221ca..0000000
--- a/src/features/dashboard/hooks/usePlan.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Hook TanStack Query sur le statut du plan utilisateur.
- *
- * Source unique de vérité côté frontend pour `plan`, `permissions`, et
- * compteurs de simulations. Consommé par `DashboardPage`, les gardes de
- * permission dans les pages simulations/t2-live, et le router.
- *
- * `staleTime: 5 min` — le plan change peu (upgrade Stripe, expiration). Les
- * flux d'upgrade appellent `queryClient.invalidateQueries(['plan'])` pour
- * forcer un refetch immédiat après webhook.
- */
-
-import { useQuery } from '@tanstack/react-query'
-import { getPlanStatus } from '@/entities/user/api'
-import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
-
-export { PLAN_QUERY_KEY }
-
-export function usePlan() {
- return useQuery({
- queryKey: PLAN_QUERY_KEY,
- queryFn: getPlanStatus,
- staleTime: 5 * 60 * 1000,
- })
-}
diff --git a/src/features/dashboard/hooks/useUpgradeSuccessHandler.ts b/src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
deleted file mode 100644
index 1873ece..0000000
--- a/src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * Sprint 5c — Détection retour Stripe Checkout réussi.
- *
- * Lit `?upgrade=success` au mount de la page Dashboard, déclenche :
- * 1. invalidation du cache plan (`PLAN_QUERY_KEY`) → refetch automatique
- * du plan mis à jour par le webhook backend `checkout.session.completed`,
- * 2. affichage d'un banner de succès (consommé par DashboardPage),
- * 3. nettoyage du query param via `history.replaceState` (un refresh ne
- * doit pas re-déclencher le banner).
- *
- * Indépendant de react-router (lit `window.location.search` directement)
- * pour faciliter les tests sans MemoryRouter.
- *
- * Race connue (Sprint 5c) : le webhook Stripe peut arriver après le
- * redirect frontend (latence ~1-3 s). Si l'invalidation refetch trop tôt,
- * `usePlan()` retourne encore l'ancien plan. Mitigation MVP : message
- * neutre + refresh manuel résoud. Polling/retry à tracer en FTD si
- * problème observé en production.
- */
-
-import { useEffect, useState } from 'react'
-import { useQueryClient } from '@tanstack/react-query'
-import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
-
-export interface UseUpgradeSuccessHandlerResult {
- showSuccess: boolean
- dismiss: () => void
-}
-
-const QUERY_PARAM = 'upgrade'
-const SUCCESS_VALUE = 'success'
-
-export function useUpgradeSuccessHandler(): UseUpgradeSuccessHandlerResult {
- const [showSuccess, setShowSuccess] = useState(false)
- const queryClient = useQueryClient()
-
- useEffect(() => {
- if (typeof window === 'undefined') return
-
- const params = new URLSearchParams(window.location.search)
- if (params.get(QUERY_PARAM) !== SUCCESS_VALUE) return
-
- setShowSuccess(true)
- void queryClient.invalidateQueries({ queryKey: PLAN_QUERY_KEY })
-
- // Nettoyage URL : retire UNIQUEMENT le param `upgrade`, conserve les autres
- // (utm_*, etc.). `replaceState` ne déclenche pas de remount React Router.
- params.delete(QUERY_PARAM)
- const remaining = params.toString()
- const newSearch = remaining.length > 0 ? `?${remaining}` : ''
- const newUrl = window.location.pathname + newSearch + window.location.hash
- window.history.replaceState(null, '', newUrl)
- }, [queryClient])
-
- function dismiss(): void {
- setShowSuccess(false)
- }
-
- return { showSuccess, dismiss }
-}
diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx
deleted file mode 100644
index cbaa59a..0000000
--- a/src/features/dashboard/pages/DashboardPage.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * DashboardPage — orchestrateur minimal : charge le plan et route vers
- * la vue appropriée (Free / Standard / Premium).
- *
- * Le routing par plan passe exclusivement par `hasAccess()` — jamais de
- * `plan === '...'` (Règles D et H).
- */
-
-import { useQueryClient } from '@tanstack/react-query'
-import { Button } from '@/shared/ui/Button'
-import { hasAccess, canSimulate } from '@/entities/user/lib'
-import { useAuth } from '@/features/auth/hooks/useAuth'
-import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
-import { useUpgradeSuccessHandler } from '../hooks/useUpgradeSuccessHandler'
-import { DashboardFreeView } from '../components/DashboardFreeView'
-import { DashboardStandardView } from '../components/DashboardStandardView'
-import { DashboardPremiumView } from '../components/DashboardPremiumView'
-import { UpgradeSuccessBanner } from '../components/UpgradeSuccessBanner'
-
-function getDisplayName(
- user: { user_metadata?: { full_name?: string }; email?: string } | null,
-): string {
- const fullName = user?.user_metadata?.full_name
- if (fullName) return fullName.split(' ')[0]
- const email = user?.email
- if (email) return email.split('@')[0]
- return 'vous'
-}
-
-function DashboardSkeleton() {
- return (
-
- )
-}
-
-function DashboardContent() {
- const { user } = useAuth()
- const { data, isLoading, isError } = usePlan()
- const queryClient = useQueryClient()
-
- if (isLoading) return
-
- if (isError || !data) {
- return (
-
-
- Impossible de charger votre tableau de bord. Réessayez dans quelques instants.
-
-
queryClient.refetchQueries({ queryKey: PLAN_QUERY_KEY })}
- >
- Réessayer
-
-
- )
- }
-
- const displayName = getDisplayName(user)
- const plan = data.plan
-
- // Route : Free → preview ; Premium (pattern_analysis) → full ; sinon Standard.
- if (!hasAccess(plan, 'dashboard')) {
- const simulationsRemaining = data.simulations_remaining ?? 0
- const canStart = canSimulate(plan, data.simulations_used).allowed
- return (
-
- )
- }
-
- if (hasAccess(plan, 'pattern_analysis')) {
- return (
-
- )
- }
-
- return
-}
-
-export function DashboardPage() {
- const { showSuccess, dismiss } = useUpgradeSuccessHandler()
- return (
-
- {showSuccess && }
-
-
- )
-}
diff --git a/src/features/design-system/DesignSystemPage.tsx b/src/features/design-system/DesignSystemPage.tsx
deleted file mode 100644
index 69ca475..0000000
--- a/src/features/design-system/DesignSystemPage.tsx
+++ /dev/null
@@ -1,389 +0,0 @@
-import React, { useState } from 'react'
-import { useTheme } from '@/shared/hooks/useTheme'
-import { Button } from '@/shared/components/ui/button'
-import { Badge } from '@/shared/components/ui/badge'
-import { Input } from '@/shared/components/ui/input'
-import { Label } from '@/shared/components/ui/label'
-import { Separator } from '@/shared/components/ui/separator'
-import { Progress } from '@/shared/components/ui/progress'
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
- AvatarGroup,
- AvatarGroupCount,
-} from '@/shared/components/ui/avatar'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from '@/shared/components/ui/dialog'
-
-// ─── palette data — DA Charcoal ──────────────────────────────────────────────
-
-interface PaletteEntry {
- token: string
- cssVar: string
- dark: string
- light: string
- group: 'Invariants' | 'Dark default' | 'Light override'
-}
-
-const PALETTE: PaletteEntry[] = [
- // Invariants
- {
- token: 'sidebar-bg',
- cssVar: '--color-sidebar-bg',
- dark: '#0C1528',
- light: '#0C1528',
- group: 'Invariants',
- },
- {
- token: 'brand',
- cssVar: '--color-brand',
- dark: '#1B4FD8',
- light: '#1B4FD8',
- group: 'Invariants',
- },
- {
- token: 'brand-hover',
- cssVar: '--color-brand-hover',
- dark: '#1744B8',
- light: '#1744B8',
- group: 'Invariants',
- },
- {
- token: 'brand-active',
- cssVar: '--color-brand-active',
- dark: '#13379C',
- light: '#13379C',
- group: 'Invariants',
- },
- {
- token: 'warning',
- cssVar: '--color-warning',
- dark: '#F59E0B',
- light: '#F59E0B',
- group: 'Invariants',
- },
- {
- token: 'danger',
- cssVar: '--color-danger',
- dark: '#EF4444',
- light: '#EF4444',
- group: 'Invariants',
- },
- // Dual-theme (valeurs différentes dark/light)
- {
- token: 'canvas',
- cssVar: '--color-canvas',
- dark: '#111111',
- light: '#F3F4F6',
- group: 'Dark default',
- },
- {
- token: 'surface',
- cssVar: '--color-surface',
- dark: 'rgba(255,255,255,.035)',
- light: '#FFFFFF',
- group: 'Dark default',
- },
- {
- token: 'surface-hover',
- cssVar: '--color-surface-hover',
- dark: 'rgba(255,255,255,.055)',
- light: '#F8F9FB',
- group: 'Dark default',
- },
- {
- token: 'surface-solid',
- cssVar: '--color-surface-solid',
- dark: '#1E1E1E',
- light: '#FFFFFF',
- group: 'Dark default',
- },
- {
- token: 'surface-raised',
- cssVar: '--color-surface-raised',
- dark: '#222222',
- light: '#FFFFFF',
- group: 'Dark default',
- },
- {
- token: 'border',
- cssVar: '--color-border',
- dark: 'rgba(255,255,255,.06)',
- light: 'rgba(0,0,0,.07)',
- group: 'Dark default',
- },
- {
- token: 'border-strong',
- cssVar: '--color-border-strong',
- dark: 'rgba(255,255,255,.12)',
- light: 'rgba(0,0,0,.14)',
- group: 'Dark default',
- },
- {
- token: 'ink-primary',
- cssVar: '--color-ink-primary',
- dark: '#E5E5E5',
- light: '#0F0F1A',
- group: 'Dark default',
- },
- {
- token: 'ink-secondary',
- cssVar: '--color-ink-secondary',
- dark: 'rgba(255,255,255,.55)',
- light: 'rgba(0,0,0,.55)',
- group: 'Dark default',
- },
- {
- token: 'ink-tertiary',
- cssVar: '--color-ink-tertiary',
- dark: 'rgba(255,255,255,.3)',
- light: 'rgba(0,0,0,.3)',
- group: 'Dark default',
- },
- {
- token: 'brand-soft',
- cssVar: '--color-brand-soft',
- dark: 'rgba(27,79,216,.1)',
- light: 'rgba(27,79,216,.06)',
- group: 'Dark default',
- },
- {
- token: 'brand-text',
- cssVar: '--color-brand-text',
- dark: '#7DA4F0',
- light: '#1B4FD8',
- group: 'Dark default',
- },
- {
- token: 'success',
- cssVar: '--color-success',
- dark: '#4ADE80',
- light: '#16A34A',
- group: 'Dark default',
- },
- {
- token: 'success-soft',
- cssVar: '--color-success-soft',
- dark: 'rgba(74,222,128,.12)',
- light: 'rgba(22,163,74,.1)',
- group: 'Dark default',
- },
- {
- token: 'warning-soft',
- cssVar: '--color-warning-soft',
- dark: 'rgba(245,158,11,.12)',
- light: 'rgba(245,158,11,.12)',
- group: 'Dark default',
- },
- {
- token: 'danger-soft',
- cssVar: '--color-danger-soft',
- dark: 'rgba(239,68,68,.12)',
- light: 'rgba(239,68,68,.12)',
- group: 'Dark default',
- },
-]
-
-// ─── section wrapper ─────────────────────────────────────────────────────────
-
-function Section({ title, children }: { title: string; children: React.ReactNode }) {
- return (
-
-
- {title}
-
-
- {children}
-
- )
-}
-
-// ─── main page ───────────────────────────────────────────────────────────────
-
-export default function DesignSystemPage() {
- const { theme, setTheme } = useTheme()
- const [dialogOpen, setDialogOpen] = useState(false)
-
- return (
-
- {/* ── header ── */}
-
-
- {/* ── palette ── */}
-
-
- {PALETTE.map(({ token, cssVar, light, dark }) => (
-
-
-
-
{token}
-
☾ {dark}
-
☀ {light}
-
-
- ))}
-
-
-
- {/* ── typography ── */}
-
-
-
Display / 40px Bold
-
Heading 1 / 24px Semibold
-
Heading 2 / 20px Semibold
-
Heading 3 / 17px Medium
-
Body / 14px Regular — Plus Jakarta Sans
-
Small / 13px Regular — secondary copy
-
Caption / 11px Regular — labels, metadata
-
Mono / 11px — token names, code
-
-
-
- {/* ── buttons ── */}
-
-
-
- Default
- Secondary
- Outline
- Ghost
- Destructive
- Link
-
-
- Large
- Default
- Small
- +
-
-
- Disabled
-
- Outline disabled
-
-
-
-
-
- {/* ── badges ── */}
-
-
- Default
- Secondary
- Outline
- Destructive
-
-
-
- {/* ── inputs / forms ── */}
-
-
-
- Email
-
-
-
- Password
-
-
-
- Invalid field
-
-
-
-
-
Content below separator
-
-
-
- {/* ── avatar ── */}
-
-
-
-
-
-
-
- {['AB', 'CD', 'EF'].map((initials) => (
-
- {initials}
-
- ))}
- +5
-
-
group
-
-
-
-
- {/* ── dialog ── */}
-
-
-
-
- Open dialog
-
-
-
- Example dialog
-
- This dialog uses DA Charcoal tokens — bg-surface-solid, border-border,
- text-ink-secondary. Toggle the theme to see it adapt.
-
-
-
- setDialogOpen(false)}>Confirm
-
-
-
-
-
-
- )
-}
diff --git a/src/features/historique/__tests__/lib.test.ts b/src/features/historique/__tests__/lib.test.ts
deleted file mode 100644
index 84da8d9..0000000
--- a/src/features/historique/__tests__/lib.test.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import {
- applyFilters,
- computeStats,
- computeTrend,
- formatShortDate,
- formatTaskLabel,
- nclcChipVariant,
-} from '../lib/historique'
-import type { SimulationListItem } from '@/entities/production/types'
-
-const NOW = new Date('2026-04-25T12:00:00Z')
-
-function item(
- overrides: Partial & { id: string; created_at: string },
-): SimulationListItem {
- return {
- id: overrides.id,
- tache: overrides.tache ?? 'EE_T1',
- mode: overrides.mode ?? 'entrainement',
- score: overrides.score ?? null,
- nclc: overrides.nclc ?? null,
- nclc_cible: overrides.nclc_cible ?? null,
- created_at: overrides.created_at,
- }
-}
-
-describe('applyFilters', () => {
- const items: SimulationListItem[] = [
- item({ id: 'a', tache: 'EE_T1', created_at: '2026-04-22T10:00:00Z', score: 14 }),
- item({ id: 'b', tache: 'EE_T2', created_at: '2026-04-10T10:00:00Z', score: 12 }),
- item({ id: 'c', tache: 'EO_T1', created_at: '2026-02-15T10:00:00Z', score: 16 }),
- item({ id: 'd', tache: 'EO_T3', created_at: '2025-12-01T10:00:00Z', score: 10 }),
- ]
-
- it('task=all + period=all → tous les items', () => {
- expect(applyFilters(items, { task: 'all', period: 'all' }, NOW).map((i) => i.id)).toEqual([
- 'a',
- 'b',
- 'c',
- 'd',
- ])
- })
-
- it('filtre par tâche', () => {
- expect(applyFilters(items, { task: 'EE_T1', period: 'all' }, NOW).map((i) => i.id)).toEqual([
- 'a',
- ])
- })
-
- it("period=this-month garde uniquement les items d'avril 2026", () => {
- expect(
- applyFilters(items, { task: 'all', period: 'this-month' }, NOW).map((i) => i.id),
- ).toEqual(['a', 'b'])
- })
-
- it('period=3-months exclut les items > 90 jours', () => {
- expect(applyFilters(items, { task: 'all', period: '3-months' }, NOW).map((i) => i.id)).toEqual([
- 'a',
- 'b',
- 'c',
- ])
- })
-
- it('combine tâche + période', () => {
- expect(
- applyFilters(items, { task: 'EE_T2', period: 'this-month' }, NOW).map((i) => i.id),
- ).toEqual(['b'])
- })
-})
-
-describe('computeStats', () => {
- it('dataset vide → all null', () => {
- expect(computeStats([], NOW)).toEqual({
- total: 0,
- thisMonth: 0,
- average: null,
- best: null,
- })
- })
-
- it('ignore les scores null pour average + best', () => {
- const items = [
- item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 12 }),
- item({ id: 'b', created_at: '2026-04-21T10:00:00Z', score: null }),
- item({ id: 'c', created_at: '2026-04-22T10:00:00Z', score: 18 }),
- ]
- const s = computeStats(items, NOW)
- expect(s.total).toBe(3)
- expect(s.thisMonth).toBe(3)
- expect(s.average).toBe(15)
- expect(s.best?.score).toBe(18)
- expect(s.best?.created_at).toBe('2026-04-22T10:00:00Z')
- })
-
- it('thisMonth ne compte que le mois courant', () => {
- const items = [
- item({ id: 'a', created_at: '2026-04-22T10:00:00Z', score: 14 }),
- item({ id: 'b', created_at: '2026-03-22T10:00:00Z', score: 14 }),
- ]
- expect(computeStats(items, NOW).thisMonth).toBe(1)
- })
-})
-
-describe('computeTrend', () => {
- it('retourne null si fenêtre récente vide', () => {
- const items = [item({ id: 'a', created_at: '2026-02-15T10:00:00Z', score: 10 })]
- expect(computeTrend(items, NOW)).toBeNull()
- })
-
- it('retourne null si fenêtre précédente vide', () => {
- const items = [item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 14 })]
- expect(computeTrend(items, NOW)).toBeNull()
- })
-
- it('détecte une tendance up', () => {
- const items = [
- // récents (0–30j) : moyenne 15
- item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 16 }),
- item({ id: 'b', created_at: '2026-04-10T10:00:00Z', score: 14 }),
- // précédents (30–60j) : moyenne 12
- item({ id: 'c', created_at: '2026-03-20T10:00:00Z', score: 12 }),
- item({ id: 'd', created_at: '2026-03-10T10:00:00Z', score: 12 }),
- ]
- const t = computeTrend(items, NOW)
- expect(t?.direction).toBe('up')
- expect(t?.delta).toBeCloseTo(3, 5)
- })
-
- it('détecte une tendance down', () => {
- const items = [
- item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 10 }),
- item({ id: 'b', created_at: '2026-03-15T10:00:00Z', score: 14 }),
- ]
- const t = computeTrend(items, NOW)
- expect(t?.direction).toBe('down')
- expect(t?.delta).toBeCloseTo(4, 5)
- })
-
- it('ignore les scores null', () => {
- const items = [
- item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: null }),
- item({ id: 'b', created_at: '2026-04-10T10:00:00Z', score: 14 }),
- item({ id: 'c', created_at: '2026-03-15T10:00:00Z', score: 12 }),
- ]
- const t = computeTrend(items, NOW)
- expect(t?.direction).toBe('up')
- expect(t?.delta).toBeCloseTo(2, 5)
- })
-})
-
-describe('formatShortDate', () => {
- it('formate "22 avr." en fr-FR', () => {
- expect(formatShortDate('2026-04-22T10:00:00Z')).toMatch(/22 avr/)
- })
-
- it('retourne chaîne vide pour ISO invalide', () => {
- expect(formatShortDate('not-a-date')).toBe('')
- })
-})
-
-describe('formatTaskLabel', () => {
- it('entraînement → "EE · Tâche 3"', () => {
- expect(formatTaskLabel({ tache: 'EE_T3', mode: 'entrainement' })).toBe('EE · Tâche 3')
- })
-
- it('examen EE → "Examen blanc EE"', () => {
- expect(formatTaskLabel({ tache: 'EE_T1', mode: 'examen' })).toBe('Examen blanc EE')
- })
-
- it('examen EO → "Examen blanc EO"', () => {
- expect(formatTaskLabel({ tache: 'EO_T3', mode: 'examen' })).toBe('Examen blanc EO')
- })
-})
-
-describe('nclcChipVariant', () => {
- it('≥ 9 → ok', () => {
- expect(nclcChipVariant(9)).toBe('ok')
- expect(nclcChipVariant(12)).toBe('ok')
- })
- it('7-8 → warn', () => {
- expect(nclcChipVariant(7)).toBe('warn')
- expect(nclcChipVariant(8)).toBe('warn')
- })
- it('≤ 6 → err', () => {
- expect(nclcChipVariant(6)).toBe('err')
- expect(nclcChipVariant(0)).toBe('err')
- })
-})
diff --git a/src/features/historique/components/HistoriqueFilters.tsx b/src/features/historique/components/HistoriqueFilters.tsx
deleted file mode 100644
index 4e38033..0000000
--- a/src/features/historique/components/HistoriqueFilters.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * Filtres de la page /historique — refonte Sprint 4.7 + correction theming.
- *
- * Dropdowns custom (div + état ouvert/fermé) — zéro lib externe — pour
- * garantir la lisibilité en dark/light. Tokens DA Charcoal (Règle L).
- *
- * Accessibilité minimale : button aria-haspopup, fermeture sur clic
- * extérieur ou Escape, options atteignables au clic.
- */
-
-import { useEffect, useRef, useState } from 'react'
-import { ChevronDown } from 'lucide-react'
-import type { PeriodFilter, TaskFilter } from '../lib/historique'
-
-interface Props {
- task: TaskFilter
- period: PeriodFilter
- onTaskChange: (task: TaskFilter) => void
- onPeriodChange: (period: PeriodFilter) => void
-}
-
-const TASK_OPTIONS: { value: TaskFilter; label: string }[] = [
- { value: 'all', label: 'Toutes les tâches' },
- { value: 'EE_T1', label: 'EE T1' },
- { value: 'EE_T2', label: 'EE T2' },
- { value: 'EE_T3', label: 'EE T3' },
- { value: 'EO_T1', label: 'EO T1' },
- { value: 'EO_T3', label: 'EO T3' },
-]
-
-const PERIOD_OPTIONS: { value: PeriodFilter; label: string }[] = [
- { value: 'this-month', label: 'Ce mois' },
- { value: '3-months', label: '3 mois' },
- { value: 'all', label: 'Tout' },
-]
-
-interface DropdownProps {
- value: T
- options: { value: T; label: string }[]
- onChange: (value: T) => void
- ariaLabel: string
-}
-
-function Dropdown({ value, options, onChange, ariaLabel }: DropdownProps) {
- const [open, setOpen] = useState(false)
- const rootRef = useRef(null)
-
- useEffect(() => {
- if (!open) return
- function onDocClick(e: MouseEvent) {
- if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
- }
- function onKey(e: KeyboardEvent) {
- if (e.key === 'Escape') setOpen(false)
- }
- document.addEventListener('mousedown', onDocClick)
- document.addEventListener('keydown', onKey)
- return () => {
- document.removeEventListener('mousedown', onDocClick)
- document.removeEventListener('keydown', onKey)
- }
- }, [open])
-
- const selected = options.find((o) => o.value === value) ?? options[0]
-
- return (
-
-
setOpen((o) => !o)}
- className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-[14px] py-[7px] text-[12.5px] font-semibold text-ink-primary hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
- >
- {selected.label}
-
-
-
- {open && (
-
- {options.map((opt) => {
- const isActive = opt.value === value
- return (
-
- {
- onChange(opt.value)
- setOpen(false)
- }}
- className={`block w-full whitespace-nowrap px-[14px] py-2 text-left text-[12.5px] font-medium hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none ${
- isActive ? 'text-brand-text' : 'text-ink-primary'
- }`}
- >
- {opt.label}
-
-
- )
- })}
-
- )}
-
- )
-}
-
-export function HistoriqueFilters({ task, period, onTaskChange, onPeriodChange }: Props) {
- return (
-
-
-
-
- )
-}
diff --git a/src/features/historique/components/HistoriqueStats.tsx b/src/features/historique/components/HistoriqueStats.tsx
deleted file mode 100644
index 52621b2..0000000
--- a/src/features/historique/components/HistoriqueStats.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * 3 cartes métriques en haut de /historique — Sprint 4.7.
- *
- * Total simulations / Score moyen / Meilleur score. Recalculées à chaque
- * changement de filtres (les filtres sont appliqués en amont par la page).
- *
- * Règle L : tokens DA Charcoal exclusivement.
- * Règle H : purement présentationnel.
- */
-
-import {
- formatShortDate,
- formatTaskLabel,
- type HistoriqueStats,
- type Trend,
-} from '../lib/historique'
-
-interface Props {
- stats: HistoriqueStats
- trend: Trend | null
-}
-
-const NUMBER_FR = new Intl.NumberFormat('fr-FR', {
- minimumFractionDigits: 1,
- maximumFractionDigits: 1,
-})
-
-function Label({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- )
-}
-
-function Value({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- )
-}
-
-function Unit({ children }: { children: React.ReactNode }) {
- return {children}
-}
-
-function Footer({ children }: { children: React.ReactNode }) {
- return {children}
-}
-
-function TrendChip({ trend }: { trend: Trend }) {
- const isUp = trend.direction === 'up'
- const sign = isUp ? '+' : '-'
- const label = `${sign}${NUMBER_FR.format(trend.delta)} en 30j`
- const colorClasses = isUp
- ? 'bg-success-soft text-success border-success/30'
- : 'bg-danger-soft text-danger border-danger/30'
- return (
-
- {isUp ? '↑' : '↓'}
- {label}
-
- )
-}
-
-export function HistoriqueStatsCards({ stats, trend }: Props) {
- return (
-
-
-
Total simulations
-
- {stats.total}
-
-
dont {stats.thisMonth} ce mois
-
-
-
-
Score moyen
-
- {stats.average !== null ? (
- <>
- {NUMBER_FR.format(stats.average)}
- /20
- >
- ) : (
- —
- )}
-
- {trend ?
:
}
-
-
-
-
Meilleur score
-
- {stats.best !== null ? (
- <>
- {stats.best.score}
- /20
- >
- ) : (
- —
- )}
-
- {stats.best !== null ? (
-
- {formatTaskLabel({ tache: stats.best.tache, mode: 'entrainement' })} ·{' '}
- {formatShortDate(stats.best.created_at)}
-
- ) : (
-
- )}
-
-
- )
-}
diff --git a/src/features/historique/components/SimulationListItem.tsx b/src/features/historique/components/SimulationListItem.tsx
deleted file mode 100644
index 88d86b5..0000000
--- a/src/features/historique/components/SimulationListItem.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * Item d'une ligne de la liste /historique — réécrit Sprint 4.7 selon maquette.
- *
- * Layout flex : Date · Libellé · Badge NCLC · Score · Chevron.
- * Couleur du badge NCLC selon seuil (cf. `nclcChipVariant`).
- *
- * Règle L : tokens DA Charcoal exclusivement.
- * Règle H : purement présentationnel.
- */
-
-import { ChevronRight } from 'lucide-react'
-import { Link } from 'react-router-dom'
-import type { SimulationListItem as Item } from '@/entities/production/types'
-import { formatShortDate, formatTaskLabel, nclcChipVariant } from '../lib/historique'
-
-interface Props {
- item: Item
- isLast: boolean
-}
-
-const CHIP_BASE =
- 'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide'
-
-const CHIP_OK = 'bg-success-soft text-success border-success/30'
-const CHIP_WARN = 'bg-warning-soft text-warning border-warning/30'
-const CHIP_ERR = 'bg-danger-soft text-danger border-danger/30'
-const CHIP_NEUTRAL = 'bg-surface text-ink-secondary border-border'
-
-function NclcBadge({ nclc }: { nclc: number }) {
- const variant = nclcChipVariant(nclc)
- const cls = variant === 'ok' ? CHIP_OK : variant === 'warn' ? CHIP_WARN : CHIP_ERR
- return NCLC {nclc}
-}
-
-export function SimulationListItem({ item, isLast }: Props) {
- const hasScore = item.score !== null && item.nclc !== null
- const borderClass = isLast ? '' : 'border-b border-border'
-
- return (
-
-
- {formatShortDate(item.created_at)}
-
-
-
- {formatTaskLabel(item)}
-
-
- {hasScore && item.nclc !== null ? (
-
- ) : (
- En cours
- )}
-
-
- {hasScore ? `${item.score}/20` : '—/20'}
-
-
-
-
- )
-}
diff --git a/src/features/historique/components/SimulationsList.tsx b/src/features/historique/components/SimulationsList.tsx
deleted file mode 100644
index a706108..0000000
--- a/src/features/historique/components/SimulationsList.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * SimulationsList — refonte Sprint 4.7.
- *
- * - Reçoit directement `items: SimulationListItem[]` (filtrés en amont par la
- * page) au lieu d'une réponse paginée. La pagination Précédent/Suivant a
- * été supprimée au profit du filtrage local (cf. HistoriquePage).
- * - Conserve le gating Free (aperçu flouté + CTA upgrade — Règle D).
- * - Distingue état vide global (« aucune simulation ») vs filtré
- * (« aucun résultat pour ces filtres »).
- *
- * Règle L : tokens DA Charcoal exclusivement.
- */
-
-import { Lock } from 'lucide-react'
-import { Link } from 'react-router-dom'
-import { Card } from '@/shared/ui/Card'
-import { Button } from '@/shared/ui/Button'
-import { hasAccess, type Plan } from '@/entities/user/lib'
-import type { SimulationListItem as Item } from '@/entities/production/types'
-import { SimulationListItem } from './SimulationListItem'
-
-interface Props {
- plan: Plan
- items: Item[]
- isLoading: boolean
- isError: boolean
- /** True si au moins un filtre non-`all` est actif — distingue empty filtré vs global. */
- isFiltered: boolean
- onUpgrade: () => void
-}
-
-const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
-
-function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
- return (
-
-
- {PLACEHOLDER_WIDTHS.map((w, i) => (
-
- ))}
-
-
-
-
Historique disponible en Standard
-
- Voir les plans
-
-
-
- )
-}
-
-function ListSkeleton() {
- return (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- )
-}
-
-function EmptyState() {
- return (
-
- Aucune simulation pour le moment.
-
- Lancez votre première simulation pour commencer à construire votre historique.
-
-
-
-
- Démarrer une simulation
-
-
-
-
- )
-}
-
-function EmptyFilteredState() {
- return (
-
- Aucune simulation ne correspond à ces filtres.
-
- Essayez d'élargir la période ou de changer la tâche sélectionnée.
-
-
- )
-}
-
-function ErrorState() {
- return (
-
-
- Impossible de charger l'historique. Réessayez dans quelques instants.
-
-
- )
-}
-
-export function SimulationsList({ plan, items, isLoading, isError, isFiltered, onUpgrade }: Props) {
- if (!hasAccess(plan, 'dashboard')) {
- return
- }
-
- if (isError) return
- if (isLoading) return
-
- if (items.length === 0) {
- return isFiltered ? :
- }
-
- return (
-
-
- {items.map((it, i) => (
-
-
-
- ))}
-
-
- )
-}
diff --git a/src/features/historique/components/__tests__/SimulationsList.test.tsx b/src/features/historique/components/__tests__/SimulationsList.test.tsx
deleted file mode 100644
index 31a199d..0000000
--- a/src/features/historique/components/__tests__/SimulationsList.test.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-/**
- * Tests SimulationsList — refonte Sprint 4.7.
- *
- * Couvre : gating Free, état vide global, état vide filtré, items rendus,
- * isError, isLoading. La pagination a été retirée au Sprint 4.7.
- */
-
-import { describe, it, expect, vi, afterEach } from 'vitest'
-import { render, screen, cleanup } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { MemoryRouter } from 'react-router-dom'
-import { SimulationsList } from '../SimulationsList'
-import type { SimulationListItem } from '@/entities/production/types'
-
-afterEach(cleanup)
-
-function renderWithRouter(ui: React.ReactNode) {
- return render({ui} )
-}
-
-const ITEMS: SimulationListItem[] = [
- {
- id: 'p1',
- tache: 'EE_T1',
- mode: 'entrainement',
- score: 14,
- nclc: 9,
- nclc_cible: 9,
- created_at: '2026-04-22T10:00:00Z',
- },
- {
- id: 'p2',
- tache: 'EE_T2',
- mode: 'examen',
- score: 16,
- nclc: 10,
- nclc_cible: 10,
- created_at: '2026-04-22T09:00:00Z',
- },
- {
- id: 'p3',
- tache: 'EE_T3',
- mode: 'entrainement',
- score: null,
- nclc: null,
- nclc_cible: null,
- created_at: '2026-04-22T08:00:00Z',
- },
-]
-
-const NOOP = () => {}
-
-describe('SimulationsList — gating Free', () => {
- it("affiche l'aperçu flouté pour le plan Free", () => {
- renderWithRouter(
- ,
- )
- expect(screen.getByText(/historique disponible en standard/i)).toBeInTheDocument()
- expect(screen.queryByText(/EE · Tâche 1/i)).not.toBeInTheDocument()
- })
-
- it('clic sur "Voir les plans" appelle onUpgrade', async () => {
- const user = userEvent.setup()
- const onUpgrade = vi.fn()
- renderWithRouter(
- ,
- )
- await user.click(screen.getByRole('button', { name: /voir les plans/i }))
- expect(onUpgrade).toHaveBeenCalledTimes(1)
- })
-})
-
-describe('SimulationsList — plan Standard', () => {
- it('affiche l\'état vide global avec CTA "Démarrer une simulation"', () => {
- renderWithRouter(
- ,
- )
- expect(screen.getByText(/aucune simulation pour le moment/i)).toBeInTheDocument()
- expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
- 'href',
- '/simulation/ee',
- )
- })
-
- it('affiche un état vide spécifique quand des filtres sont actifs', () => {
- renderWithRouter(
- ,
- )
- expect(screen.getByText(/aucune simulation ne correspond à ces filtres/i)).toBeInTheDocument()
- expect(screen.queryByRole('link', { name: /démarrer une simulation/i })).not.toBeInTheDocument()
- })
-
- it('rend les items avec libellé, score et badges', () => {
- renderWithRouter(
- ,
- )
- const links = screen.getAllByRole('link')
- expect(links).toHaveLength(3)
- expect(screen.getByText('EE · Tâche 1')).toBeInTheDocument()
- expect(screen.getByText('Examen blanc EE')).toBeInTheDocument()
- expect(screen.getByText('14/20')).toBeInTheDocument()
- expect(screen.getByText('NCLC 9')).toBeInTheDocument()
- expect(screen.getByText(/En cours/i)).toBeInTheDocument()
- expect(screen.getByText('—/20')).toBeInTheDocument()
- })
-
- it('chaque item pointe vers /rapport/:id', () => {
- renderWithRouter(
- ,
- )
- const link = screen.getAllByRole('link')[0]
- expect(link).toHaveAttribute('href', '/rapport/p1')
- })
-})
-
-describe('SimulationsList — états transverses', () => {
- it("isError → callout d'erreur", () => {
- renderWithRouter(
- ,
- )
- expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i)
- })
-
- it('isLoading → squelettes', () => {
- renderWithRouter(
- ,
- )
- expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument()
- })
-})
diff --git a/src/features/historique/hooks/useSimulationsList.ts b/src/features/historique/hooks/useSimulationsList.ts
deleted file mode 100644
index dab8aad..0000000
--- a/src/features/historique/hooks/useSimulationsList.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * Hook TanStack Query — liste paginée des simulations.
- *
- * Clé de cache : `['simulations', 'list', page, limit]`. `staleTime: 30 s` —
- * l'historique change peu entre deux requêtes utilisateur, 30 s évite les
- * rafraîchissements inutiles tout en gardant les données fraîches.
- *
- * `placeholderData: keepPreviousData` (TanStack v5) permet un changement de
- * page sans flash de squelette — les items précédents restent affichés
- * pendant le fetch.
- *
- * Règle H : aucune logique métier ici — le hook ne fait qu'envelopper l'API.
- */
-
-import { keepPreviousData, useQuery } from '@tanstack/react-query'
-import { listSimulations } from '@/entities/production/api'
-
-const DEFAULT_LIMIT = 20
-
-export function useSimulationsList(page: number, limit: number = DEFAULT_LIMIT) {
- return useQuery({
- queryKey: ['simulations', 'list', page, limit] as const,
- queryFn: () => listSimulations(page, limit),
- staleTime: 30 * 1000,
- placeholderData: keepPreviousData,
- })
-}
diff --git a/src/features/historique/lib/historique.ts b/src/features/historique/lib/historique.ts
deleted file mode 100644
index ed5d5f8..0000000
--- a/src/features/historique/lib/historique.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * Logique pure de la page /historique — Sprint 4.7.
- *
- * Toutes les fonctions ici sont déterministes et acceptent `now` injecté
- * pour permettre des tests reproductibles. Aucune dépendance React, aucune
- * I/O — Règle H respectée (ces helpers pourraient vivre en `entities/`,
- * mais ils sont 100 % spécifiques à la page historique → restent ici).
- *
- * Filtrage côté frontend uniquement : les 50 simulations les plus récentes
- * sont chargées en une fois (cf. HistoriquePage), puis filtrées localement.
- */
-
-import type { SimulationListItem, Tache } from '@/entities/production/types'
-
-export type TaskFilter = 'all' | Tache
-export type PeriodFilter = 'all' | 'this-month' | '3-months'
-
-export interface FiltersState {
- task: TaskFilter
- period: PeriodFilter
-}
-
-const DAY_MS = 24 * 60 * 60 * 1000
-
-function isInPeriod(iso: string, period: PeriodFilter, now: Date): boolean {
- if (period === 'all') return true
- const d = new Date(iso)
- if (!Number.isFinite(d.getTime())) return false
-
- if (period === 'this-month') {
- return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
- }
- // 3-months : 90 jours glissants
- return now.getTime() - d.getTime() <= 90 * DAY_MS
-}
-
-export function applyFilters(
- items: SimulationListItem[],
- { task, period }: FiltersState,
- now: Date,
-): SimulationListItem[] {
- return items.filter((it) => {
- if (task !== 'all' && it.tache !== task) return false
- if (!isInPeriod(it.created_at, period, now)) return false
- return true
- })
-}
-
-export interface HistoriqueStats {
- total: number
- thisMonth: number
- average: number | null
- best: { score: number; tache: Tache; created_at: string } | null
-}
-
-export function computeStats(items: SimulationListItem[], now: Date): HistoriqueStats {
- const total = items.length
- const thisMonth = items.filter((it) => isInPeriod(it.created_at, 'this-month', now)).length
-
- const scored = items.filter(
- (it): it is SimulationListItem & { score: number } => typeof it.score === 'number',
- )
-
- if (scored.length === 0) {
- return { total, thisMonth, average: null, best: null }
- }
-
- const sum = scored.reduce((acc, it) => acc + it.score, 0)
- const average = sum / scored.length
-
- const bestItem = scored.reduce((acc, it) => (it.score > acc.score ? it : acc), scored[0])
-
- return {
- total,
- thisMonth,
- average,
- best: { score: bestItem.score, tache: bestItem.tache, created_at: bestItem.created_at },
- }
-}
-
-export interface Trend {
- direction: 'up' | 'down'
- delta: number
-}
-
-/**
- * Tendance 30 j : moyenne des 30 derniers jours vs 30 j précédents (j-60 → j-30).
- * Ignore les items sans score. Retourne `null` si l'une des deux fenêtres est vide.
- */
-export function computeTrend(items: SimulationListItem[], now: Date): Trend | null {
- const t = now.getTime()
- const within = (iso: string, fromDays: number, toDays: number) => {
- const ts = new Date(iso).getTime()
- if (!Number.isFinite(ts)) return false
- const ageMs = t - ts
- return ageMs >= fromDays * DAY_MS && ageMs < toDays * DAY_MS
- }
-
- const recent = items.filter(
- (it): it is SimulationListItem & { score: number } =>
- typeof it.score === 'number' && within(it.created_at, 0, 30),
- )
- const previous = items.filter(
- (it): it is SimulationListItem & { score: number } =>
- typeof it.score === 'number' && within(it.created_at, 30, 60),
- )
-
- if (recent.length === 0 || previous.length === 0) return null
-
- const avg = (xs: { score: number }[]) => xs.reduce((s, it) => s + it.score, 0) / xs.length
- const delta = avg(recent) - avg(previous)
- if (delta === 0) return { direction: 'up', delta: 0 }
- return { direction: delta > 0 ? 'up' : 'down', delta: Math.abs(delta) }
-}
-
-const SHORT_DATE = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' })
-
-/** Date courte type "22 avr." (locale fr-FR). */
-export function formatShortDate(iso: string): string {
- const d = new Date(iso)
- if (!Number.isFinite(d.getTime())) return ''
- return SHORT_DATE.format(d)
-}
-
-const TACHE_NUMBER: Record = {
- EE_T1: 'EE · Tâche 1',
- 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',
-}
-
-/**
- * Libellé court d'un item d'historique selon la maquette Sprint 4.7.
- *
- * Mode entraînement → "EE · Tâche 3"
- * Mode examen → "Examen blanc EE" / "Examen blanc EO"
- */
-export function formatTaskLabel(item: Pick): string {
- if (item.mode === 'examen') {
- return item.tache.startsWith('EE_') ? 'Examen blanc EE' : 'Examen blanc EO'
- }
- return TACHE_NUMBER[item.tache]
-}
-
-export type NclcChip = 'ok' | 'warn' | 'err'
-
-/**
- * Variante visuelle du badge NCLC selon le seuil :
- * - ≥ 9 → ok (success)
- * - 7-8 → warn (warning/gold)
- * - ≤ 6 → err (danger)
- */
-export function nclcChipVariant(nclc: number): NclcChip {
- if (nclc >= 9) return 'ok'
- if (nclc >= 7) return 'warn'
- return 'err'
-}
diff --git a/src/features/historique/pages/HistoriquePage.tsx b/src/features/historique/pages/HistoriquePage.tsx
deleted file mode 100644
index 028d9e0..0000000
--- a/src/features/historique/pages/HistoriquePage.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * Page /historique — refonte Sprint 4.7.
- *
- * Charge en une fois les 50 simulations les plus récentes via
- * `useSimulationsList(1, 50)` puis applique les filtres (tâche + période)
- * côté frontend. Cette limite est volontaire : un MVP avec un volume modeste
- * d'utilisateurs ne nécessite pas de filtrage backend. Au-delà de 50, les
- * simulations plus anciennes ne sont pas accessibles tant que les filtres
- * ne sont pas reportés côté backend (`GET /simulations?tache=&since=`).
- *
- * Règle D : gating via `hasAccess(plan, 'dashboard')` dans `SimulationsList`.
- * Règle L : tokens DA Charcoal exclusivement.
- */
-
-import { useMemo, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { usePlan } from '@/features/dashboard/hooks/usePlan'
-import { useSimulationsList } from '../hooks/useSimulationsList'
-import { SimulationsList } from '../components/SimulationsList'
-import { HistoriqueStatsCards } from '../components/HistoriqueStats'
-import { HistoriqueFilters } from '../components/HistoriqueFilters'
-import {
- applyFilters,
- computeStats,
- computeTrend,
- type PeriodFilter,
- type TaskFilter,
-} from '../lib/historique'
-
-const LIMIT = 50
-
-export function HistoriquePage() {
- const navigate = useNavigate()
- const [task, setTask] = useState('all')
- const [period, setPeriod] = useState('this-month')
-
- const { data: planData, isLoading: isPlanLoading } = usePlan()
- const { data, isLoading, isError } = useSimulationsList(1, LIMIT)
-
- const now = useMemo(() => new Date(), [])
- const allItems = data?.data ?? []
- const filtered = useMemo(
- () => applyFilters(allItems, { task, period }, now),
- [allItems, task, period, now],
- )
- const stats = useMemo(() => computeStats(filtered, now), [filtered, now])
- const trend = useMemo(() => computeTrend(filtered, now), [filtered, now])
-
- const isFiltered = task !== 'all' || period !== 'all'
- const showStats = !isPlanLoading && planData && !isError
- const canSeeContent = planData && planData.plan !== 'free'
-
- // AppLayout fournit déjà mx-auto max-w-[1100px] + lg:px-9 lg:py-9 (cf.
- // AppLayout.tsx) — on limite ici à 860 px sans réintroduire de padding
- // pour éviter le double margin.
- return (
-
-
-
- {showStats && canSeeContent && (
-
-
-
- )}
-
- {isPlanLoading || !planData ? (
-
- ) : (
- navigate('/plan')}
- />
- )}
-
- )
-}
diff --git a/src/features/progression/components/BlurredProgression.tsx b/src/features/progression/components/BlurredProgression.tsx
deleted file mode 100644
index 5162d78..0000000
--- a/src/features/progression/components/BlurredProgression.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * BlurredProgression — Sprint 3.6c.
- *
- * Aperçu flouté de la page /progression pour Free/Standard + CTA upgrade
- * vers Premium. Ce composant n'est JAMAIS rendu pour Premium (cf.
- * ProgressionPage) — le gating est fait en amont via hasAccess.
- *
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { Lock } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-
-interface Props {
- onUpgrade: () => void
-}
-
-const PLACEHOLDER_HEIGHTS = ['h-24', 'h-16', 'h-16', 'h-20'] as const
-
-export function BlurredProgression({ onUpgrade }: Props) {
- return (
-
-
- {PLACEHOLDER_HEIGHTS.map((h, i) => (
-
- ))}
-
-
-
-
-
- Profil de préparation — Exclusivité Premium
-
-
- Analysez vos erreurs récurrentes, recevez des exercices ciblés long terme, et suivez
- votre indice de préparation au TCF Canada.
-
-
-
- Passer en Premium
-
-
-
- )
-}
diff --git a/src/features/progression/components/NotReadyState.tsx b/src/features/progression/components/NotReadyState.tsx
deleted file mode 100644
index 27f3136..0000000
--- a/src/features/progression/components/NotReadyState.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * NotReadyState — Sprint 3.6c.
- *
- * Affiché quand l'utilisateur Premium a moins de 5 productions corrigées.
- * Barre de progression N/5 + CTA pour démarrer une simulation.
- *
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { Link } from 'react-router-dom'
-import { Card } from '@/shared/ui/Card'
-import { Button } from '@/shared/ui/Button'
-
-interface Props {
- current: number
- minimum: number
-}
-
-export function NotReadyState({ current, minimum }: Props) {
- const remaining = Math.max(0, minimum - current)
- const pct = Math.max(0, Math.min(100, (current / minimum) * 100))
-
- return (
-
-
-
Profil de préparation
-
- Vous avez réalisé{' '}
-
- {current}/{minimum}
- {' '}
- simulations corrigées.{' '}
- {remaining > 0
- ? `Encore ${remaining} pour débloquer votre profil.`
- : 'Votre profil va être généré à la prochaine correction.'}
-
-
-
-
-
-
-
-
- Démarrer une simulation
-
-
-
-
- )
-}
diff --git a/src/features/progression/components/PatternExerciceCard.tsx b/src/features/progression/components/PatternExerciceCard.tsx
deleted file mode 100644
index 4337cd0..0000000
--- a/src/features/progression/components/PatternExerciceCard.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * PatternExerciceCard — Sprint 3.6c.
- *
- * Carte d'exercice long terme : UX **leçon** (pas interactive, contrairement à
- * `ExerciceInteractive` du rapport individuel). Le candidat a déjà répété
- * cette erreur 3+ fois — l'intention est de montrer directement le bon usage
- * + l'astuce mnémotechnique pour réflexe de relecture.
- *
- * Structure :
- * - En-tête : critère + badge taxonomie + diagnostic
- * - Bloc consigne (fond neutre)
- * - Exemple incorrect (barré rouge) → Correction (fond vert)
- * - Encart astuce avec icône ampoule + fond chaud
- *
- * Règle L : tokens Direction H exclusivement.
- * Règle H : présentation pure — contenu fourni par DeepSeek via backend.
- */
-
-import { Lightbulb } from 'lucide-react'
-import ReactMarkdown from 'react-markdown'
-import { Card } from '@/shared/ui/Card'
-import { Badge } from '@/shared/ui/Badge'
-import { CRITERE_LABELS } from '@/entities/report/lib'
-import type { PatternExercice } from '@/entities/patterns/types'
-
-interface Props {
- exercice: PatternExercice
-}
-
-export function PatternExerciceCard({ exercice }: Props) {
- const critereLabel = CRITERE_LABELS[exercice.critere]
-
- return (
-
-
-
- {critereLabel}
-
- {exercice.code.replace(/_/g, ' ')}
-
-
- {exercice.diagnostic && (
-
- {children} }}
- >
- {exercice.diagnostic}
-
-
- )}
-
-
- {exercice.exercice.consigne && (
-
-
- Consigne
-
-
{exercice.exercice.consigne}
-
- )}
-
-
-
-
- Incorrect
-
-
- {exercice.exercice.exemple}
-
-
-
-
- Correct
-
-
{exercice.exercice.correction}
-
-
-
-
-
-
-
- Astuce de relecture
-
-
{exercice.exercice.astuce}
-
-
-
- )
-}
diff --git a/src/features/progression/components/PatternsList.tsx b/src/features/progression/components/PatternsList.tsx
deleted file mode 100644
index 78c3ded..0000000
--- a/src/features/progression/components/PatternsList.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * PatternsList — Sprint 3.6c.
- *
- * Liste les erreurs récurrentes détectées, groupées par critère et triées par
- * fréquence DESC (déjà fait côté backend).
- *
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { Card } from '@/shared/ui/Card'
-import { Badge } from '@/shared/ui/Badge'
-import { CRITERE_LABELS } from '@/entities/report/lib'
-import type { Pattern } from '@/entities/patterns/types'
-
-interface Props {
- patterns: Pattern[]
-}
-
-function humanizeCode(code: string): string {
- return code.replace(/_/g, ' ')
-}
-
-export function PatternsList({ patterns }: Props) {
- if (patterns.length === 0) {
- return (
-
-
- Aucune erreur récurrente détectée sur vos 5 dernières productions. Continuez ainsi !
-
-
- )
- }
-
- return (
-
- {patterns.map((p) => (
-
-
-
-
- {p.description ?? humanizeCode(p.code)}
-
-
{CRITERE_LABELS[p.critere]}
-
-
- {p.frequency}/5
-
-
-
- ))}
-
- )
-}
diff --git a/src/features/progression/components/PreparationIndexHero.tsx b/src/features/progression/components/PreparationIndexHero.tsx
deleted file mode 100644
index ef112f7..0000000
--- a/src/features/progression/components/PreparationIndexHero.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * PreparationIndexHero — Sprint 3.6c.
- *
- * Affiche l'indice de préparation (0-100) en gros, jauge horizontale et
- * message interprétatif (<40 / 40-70 / >70).
- *
- * Règle L : tokens Direction H exclusivement.
- * Règle H : présentation pure — le message vient du backend.
- */
-
-import { Card } from '@/shared/ui/Card'
-import type { PreparationIndex } from '@/entities/patterns/types'
-
-interface Props {
- index: PreparationIndex
-}
-
-function gaugeColor(score: number): string {
- if (score < 40) return 'bg-danger'
- if (score <= 70) return 'bg-warning'
- return 'bg-success'
-}
-
-export function PreparationIndexHero({ index }: Props) {
- const pct = Math.max(0, Math.min(100, index.score))
- const color = gaugeColor(pct)
-
- return (
-
-
-
-
- Indice de préparation
-
-
- {index.score}
- /100
-
-
-
{index.message}
-
-
-
-
- 0
- 40
- 70
- 100
-
-
- )
-}
diff --git a/src/features/progression/components/ProgressionPremium.tsx b/src/features/progression/components/ProgressionPremium.tsx
deleted file mode 100644
index a8c816f..0000000
--- a/src/features/progression/components/ProgressionPremium.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * ProgressionPremium — Sprint 3.6c.
- *
- * Orchestre le contenu de /progression pour un utilisateur Premium :
- * - not-ready → NotReadyState
- * - ready → Hero (indice) + PatternsList + PatternExerciceCard[] + footer
- *
- * Règle L : tokens Direction H exclusivement.
- * Règle H : purement présentationnel — data vient du parent via props.
- */
-
-import { Card } from '@/shared/ui/Card'
-import { formatRelativeDate } from '@/shared/lib/date'
-import type { PatternsResponse } from '@/entities/patterns/types'
-import { PreparationIndexHero } from './PreparationIndexHero'
-import { PatternsList } from './PatternsList'
-import { PatternExerciceCard } from './PatternExerciceCard'
-import { NotReadyState } from './NotReadyState'
-
-interface Props {
- data: PatternsResponse
-}
-
-export function ProgressionPremium({ data }: Props) {
- if (!data.ready) {
- return
- }
-
- return (
-
-
-
-
- Erreurs récurrentes
-
-
-
- {data.exercises.length > 0 && (
-
- Exercices long terme
-
- {data.exercises.map((ex, i) => (
-
- ))}
-
-
- )}
-
-
-
- Analyse basée sur vos {data.analyzed_productions} dernières productions —{' '}
- {formatRelativeDate(data.last_analysis)}
-
-
-
- )
-}
diff --git a/src/features/progression/components/__tests__/ProgressionPremium.test.tsx b/src/features/progression/components/__tests__/ProgressionPremium.test.tsx
deleted file mode 100644
index d22cc43..0000000
--- a/src/features/progression/components/__tests__/ProgressionPremium.test.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * Tests — ProgressionPremium (Sprint 3.6c).
- *
- * Couvre les 3 états principaux du composant (le gating plan lui-même est
- * géré en amont par ProgressionPage via hasAccess).
- */
-
-import { describe, it, expect, afterEach } from 'vitest'
-import { render, screen, cleanup } from '@testing-library/react'
-import { MemoryRouter } from 'react-router-dom'
-import { ProgressionPremium } from '../ProgressionPremium'
-import type { PatternsReady, PatternsNotReady, PatternExercice } from '@/entities/patterns/types'
-
-afterEach(cleanup)
-
-function renderWithRouter(ui: React.ReactNode) {
- return render({ui} )
-}
-
-const EXERCICE: PatternExercice = {
- code: 'accord_sujet_verbe',
- critere: 'competence_grammaticale',
- diagnostic: 'Accords fragiles sur vos 5 dernières productions.',
- exercice: {
- consigne: 'Corrigez la phrase suivante.',
- exemple: 'les enfants joue dans le parc',
- correction: 'les enfants jouent dans le parc',
- astuce: 'Pointez du doigt le sujet avant de lire le verbe.',
- },
-}
-
-const READY_DATA: PatternsReady = {
- ready: true,
- patterns: [
- {
- code: 'accord_sujet_verbe',
- critere: 'competence_grammaticale',
- frequency: 4,
- description: null,
- },
- { code: 'connecteurs_repetes', critere: 'coherence_cohesion', frequency: 3, description: null },
- ],
- exercises: [EXERCICE],
- preparation_index: { score: 72, message: 'Vous êtes en bonne voie pour NCLC 9+' },
- analyzed_productions: 5,
- last_analysis: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
-}
-
-const NOT_READY: PatternsNotReady = {
- ready: false,
- minimum: 5,
- current: 3,
-}
-
-describe('ProgressionPremium — état not-ready', () => {
- it('affiche le compteur N/5 et le CTA "Démarrer une simulation"', () => {
- renderWithRouter( )
-
- expect(screen.getByText(/3\/5/)).toBeInTheDocument()
- expect(screen.getByText(/encore 2 pour débloquer votre profil/i)).toBeInTheDocument()
- expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
- 'href',
- '/simulation/ee',
- )
- })
-})
-
-describe('ProgressionPremium — état ready', () => {
- it("affiche l'indice de préparation (score + message)", () => {
- renderWithRouter( )
-
- expect(screen.getByText('72')).toBeInTheDocument()
- expect(screen.getByText(/NCLC 9/)).toBeInTheDocument()
- })
-
- it('affiche les 2 patterns avec leur fréquence', () => {
- renderWithRouter( )
-
- expect(screen.getByText('4/5')).toBeInTheDocument()
- expect(screen.getByText('3/5')).toBeInTheDocument()
- // Libellés critères — chacun apparaît au moins une fois (pattern + exercice
- // réutilisent le même label, donc getAllByText)
- expect(screen.getAllByText(/Compétence grammaticale/i).length).toBeGreaterThan(0)
- expect(screen.getByText(/Cohérence et cohésion/i)).toBeInTheDocument()
- })
-
- it("rend l'exercice avec consigne, exemple incorrect, correction et astuce", () => {
- renderWithRouter( )
-
- expect(screen.getByText(EXERCICE.exercice.consigne)).toBeInTheDocument()
- expect(screen.getByText(EXERCICE.exercice.exemple)).toBeInTheDocument()
- expect(screen.getByText(EXERCICE.exercice.correction)).toBeInTheDocument()
- expect(screen.getByText(EXERCICE.exercice.astuce)).toBeInTheDocument()
- expect(screen.getByText(/astuce de relecture/i)).toBeInTheDocument()
- })
-
- it('affiche le footer "Analyse basée sur vos 5 dernières productions"', () => {
- renderWithRouter( )
-
- expect(screen.getByText(/analyse basée sur vos 5 dernières productions/i)).toBeInTheDocument()
- })
-
- it('ready sans pattern : affiche le message "Aucune erreur récurrente"', () => {
- const noPatterns: PatternsReady = {
- ...READY_DATA,
- patterns: [],
- exercises: [],
- }
- renderWithRouter( )
-
- expect(screen.getByText(/aucune erreur récurrente détectée/i)).toBeInTheDocument()
- // Pas de section "Exercices long terme" si exercises=[]
- expect(screen.queryByText(/exercices long terme/i)).not.toBeInTheDocument()
- })
-})
diff --git a/src/features/progression/hooks/usePatterns.ts b/src/features/progression/hooks/usePatterns.ts
deleted file mode 100644
index 348cbc8..0000000
--- a/src/features/progression/hooks/usePatterns.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * Hook TanStack Query — analyse des patterns (Premium).
- *
- * Clé `['users', 'patterns']` partagée entre `/progression` et la section
- * dashboard Premium — un seul appel backend pour les deux affichages.
- *
- * `staleTime: 60 s` — l'analyse ne change que quand une nouvelle production est
- * corrigée ; 60 s évite les rafraîchissements inutiles.
- *
- * `enabled` : ne lance la requête QUE si l'utilisateur a la feature. Évite un
- * 403 parasite pour Free/Standard (la route backend refuse avec
- * PLAN_INSUFFICIENT — on court-circuite côté client).
- *
- * Règle H : aucune logique métier ici — wrap pur autour de `getPatterns`.
- * Règle D : le check feature utilise `hasAccess`, jamais `plan === 'premium'`.
- */
-
-import { useQuery } from '@tanstack/react-query'
-import { getPatterns } from '@/entities/patterns/api'
-import { hasAccess, type Plan } from '@/entities/user/lib'
-
-export function usePatterns(plan: Plan | undefined) {
- const enabled = plan !== undefined && hasAccess(plan, 'pattern_analysis')
-
- return useQuery({
- queryKey: ['users', 'patterns'] as const,
- queryFn: getPatterns,
- staleTime: 60 * 1000,
- enabled,
- })
-}
diff --git a/src/features/progression/pages/ProgressionPage.tsx b/src/features/progression/pages/ProgressionPage.tsx
deleted file mode 100644
index 38793cf..0000000
--- a/src/features/progression/pages/ProgressionPage.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * Page /progression — Sprint 3.6c.
- *
- * Gating plan via `hasAccess(plan, 'pattern_analysis')` :
- * - Free + Standard → `BlurredProgression` (aperçu flouté + CTA upgrade)
- * - Premium → `ProgressionPremium` (NotReady ou contenu complet)
- *
- * Règle D : aucun `plan === 'xxx'` — tout passe par hasAccess().
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { useNavigate } from 'react-router-dom'
-import { Card } from '@/shared/ui/Card'
-import { Button } from '@/shared/ui/Button'
-import { hasAccess } from '@/entities/user/lib'
-import { usePlan } from '@/features/dashboard/hooks/usePlan'
-import { usePatterns } from '../hooks/usePatterns'
-import { BlurredProgression } from '../components/BlurredProgression'
-import { ProgressionPremium } from '../components/ProgressionPremium'
-
-function Skeleton() {
- return (
-
- )
-}
-
-export function ProgressionPage() {
- const navigate = useNavigate()
- const { data: planData, isLoading: isPlanLoading } = usePlan()
- const { data: patternsData, isLoading: isPatternsLoading, isError } = usePatterns(planData?.plan)
-
- const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false
-
- return (
-
-
-
-
- {isPlanLoading && }
-
- {!isPlanLoading && planData && !isPremium && (
- navigate('/plan')} />
- )}
-
- {!isPlanLoading && planData && isPremium && (
- <>
- {isPatternsLoading && }
- {isError && (
-
-
- Impossible de charger votre profil de préparation. Réessayez dans quelques
- instants.
-
-
- navigate(0)}>
- Rafraîchir
-
-
-
- )}
- {patternsData && }
- >
- )}
-
-
- )
-}
diff --git a/src/features/simulations/components/AudioRecorder.tsx b/src/features/simulations/components/AudioRecorder.tsx
deleted file mode 100644
index b8bc611..0000000
--- a/src/features/simulations/components/AudioRecorder.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-/**
- * Composant d'enregistrement audio pour les productions orales —
- * Sprint 4c-1, simplifié au Sprint 4c-3.
- *
- * Encapsule `useAudioRecorder` côté UI : timer montant MM:SS, indicateur
- * visuel d'enregistrement, garde-fou minimum 30 s, bouton de téléchargement
- * local de l'audio (le backend ne stocke aucun audio).
- *
- * Le streaming chunk-par-chunk a été retiré au Sprint 4c-3 : l'audio est
- * envoyé entier au backend après stop, le backend appelle Gemini batch pour
- * transcrire. `useAudioRecorder.subscribeChunks` reste exposé côté hook
- * pour un usage futur (ex. réactivation Deepgram live).
- */
-
-import { useEffect } from 'react'
-import { Download, Mic, MicOff, Square } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { formatTimer } from '../lib/simulationConfig'
-import { useAudioRecorder } from '../hooks/useAudioRecorder'
-import { RecordingTimeline } from './RecordingTimeline'
-import { RecordingWaveform } from './RecordingWaveform'
-
-interface Props {
- /** Durée minimale (s) avant que la soumission soit autorisée. */
- minSeconds: number
- /**
- * Sprint 4b.3 — durée maximale recommandée (s). À l'atteinte, le hook
- * arrête automatiquement l'enregistrement et l'`onSubmit` est déclenché
- * via le chemin existant (status='stopped' → useEffect onSubmit).
- */
- maxSeconds?: number
- /** Notification optionnelle quand `maxSeconds` est atteint. */
- onMaxReached?: () => void
- /** Nom de fichier proposé au téléchargement local (sans extension). */
- downloadFilename: string
- /** Appelé au clic « Arrêter et soumettre » avec le blob final + son MIME. */
- onSubmit: (audioBlob: Blob, audioMimeType: string | null) => void
- onCancel: () => void
- /** Initialisé à true → l'utilisateur démarre l'enregistrement automatiquement
- * au mount. Sinon, un bouton « Démarrer » est affiché. */
- autoStart?: boolean
- /**
- * Sprint 4c-3 — désactive les contrôles tant qu'une soumission est en
- * cours côté parent (transcription + correction backend ~30-60 s).
- */
- disabled?: boolean
-}
-
-export function AudioRecorder({
- minSeconds,
- maxSeconds,
- onMaxReached,
- downloadFilename,
- onSubmit,
- onCancel,
- autoStart = true,
- disabled = false,
-}: Props) {
- const recorder = useAudioRecorder({ maxSeconds, onMaxReached })
-
- // Auto-start au mount si demandé. Pas de dépendance sur `recorder.start`
- // pour éviter les re-runs au changement d'identité de la fonction.
- useEffect(() => {
- if (!autoStart) return
- void recorder.start()
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [autoStart])
-
- const isRecording = recorder.status === 'recording'
- const isStopped = recorder.status === 'stopped'
- const remaining = Math.max(0, minSeconds - recorder.elapsedSeconds)
- const submitEnabled = isRecording && remaining === 0
-
- function handleSubmitClick() {
- recorder.stop()
- }
-
- // Quand le recorder passe en 'stopped', on remonte le blob au parent.
- useEffect(() => {
- if (recorder.status === 'stopped' && recorder.audioBlob) {
- onSubmit(recorder.audioBlob, recorder.audioMimeType)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [recorder.status, recorder.audioBlob])
-
- if (recorder.status === 'error') {
- return (
-
-
-
-
-
{recorder.error ?? 'Erreur audio.'}
- {recorder.permissionDenied && (
-
- Vérifiez que le site a l'autorisation d'utiliser le micro dans les réglages du
- navigateur, puis réessayez.
-
- )}
-
-
-
- void recorder.start()}>
- Réessayer
-
-
- Annuler
-
-
-
- )
- }
-
- return (
-
-
-
-
- {recorder.status === 'requesting' && 'Autorisation du micro…'}
- {isRecording && 'Enregistrement actif'}
- {isStopped && 'Enregistrement terminé'}
- {recorder.status === 'idle' && 'Prêt'}
-
-
- {formatTimer(recorder.elapsedSeconds)}
-
-
-
- {isRecording && (
-
-
-
- )}
-
- {maxSeconds && (isRecording || isStopped) && (
-
-
-
- )}
-
- {isRecording && remaining > 0 && (
-
- Minimum 30 secondes requis ({remaining} s restantes).
-
- )}
-
-
- {isRecording && (
- <>
- }
- onClick={handleSubmitClick}
- disabled={!submitEnabled || disabled}
- >
- {submitEnabled ? 'Arrêter et soumettre' : `Arrêter et soumettre (${remaining}s)`}
-
-
- Annuler
-
- >
- )}
-
- {recorder.status === 'idle' && (
- }
- onClick={() => void recorder.start()}
- >
- Démarrer l'enregistrement
-
- )}
-
- {isStopped && (
- }
- onClick={() => recorder.downloadAudio(`${downloadFilename}.webm`)}
- >
- Télécharger l'audio
-
- )}
-
-
- )
-}
diff --git a/src/features/simulations/components/IdeesSuggestions.tsx b/src/features/simulations/components/IdeesSuggestions.tsx
deleted file mode 100644
index 64ec9d4..0000000
--- a/src/features/simulations/components/IdeesSuggestions.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Modal — suggestions d'idées DeepSeek (tâche G5).
- *
- * Présentationnel pur. La fermeture déclenche `onClose` qui doit appeler
- * `reset()` du hook useIdees côté parent pour vider le cache de mutation.
- *
- * Règle H : aucune logique métier. Règle L : tokens Direction H uniquement.
- */
-
-import { Loader2, Lightbulb } from 'lucide-react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/shared/components/ui/dialog'
-import type { ApiError } from '@/shared/types/api'
-
-function mapIdeesError(err: ApiError | null): string | null {
- if (!err) return null
- switch (err.code) {
- case 'AUTH_REQUIRED':
- return 'Votre session a expiré. Reconnectez-vous.'
- case 'VALIDATION_ERROR':
- case 'INVALID_BODY':
- return 'Écrivez au moins 30 mots avant de demander des suggestions.'
- default:
- return 'Suggestions indisponibles. Réessayez dans quelques instants.'
- }
-}
-
-interface Props {
- idees: string[] | null
- isLoading: boolean
- error: ApiError | null
- isOpen: boolean
- onClose: () => void
-}
-
-export function IdeesSuggestions({ idees, isLoading, error, isOpen, onClose }: Props) {
- const message = mapIdeesError(error)
-
- return (
- {
- if (!open) onClose()
- }}
- >
-
-
-
-
- Suggestions d'idées
-
-
- Pour prolonger votre rédaction, inspirez-vous de ces pistes.
-
-
-
- {isLoading && (
-
-
- Génération des idées…
-
- )}
-
- {!isLoading && message && (
-
- {message}
-
- )}
-
- {!isLoading && !message && idees && idees.length > 0 && (
-
- {idees.map((idee, i) => (
-
-
- {idee}
-
- ))}
-
- )}
-
-
- )
-}
diff --git a/src/features/simulations/components/NclcCibleSelector.tsx b/src/features/simulations/components/NclcCibleSelector.tsx
deleted file mode 100644
index ce9fd48..0000000
--- a/src/features/simulations/components/NclcCibleSelector.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * Sélecteur de niveau NCLC cible — Sprint 3.6b.
- *
- * Segmented control à 2 valeurs : NCLC 9 ou NCLC 10.
- * La valeur est envoyée dans le payload `POST /corrections/ee` (champ `nclc_cible`).
- * Backend par défaut : 9 (cf. expria-backend `corrections.ts`).
- *
- * Règle L : tokens Direction H exclusivement.
- * Règle H : purement présentationnel — l'état vit chez le parent (SimulationForm).
- */
-
-import { cn } from '@/shared/lib/utils'
-import type { NclcCible } from '@/entities/report/types'
-
-interface Props {
- value: NclcCible
- onChange: (next: NclcCible) => void
- disabled?: boolean
-}
-
-const OPTIONS: { value: NclcCible; label: string; hint: string }[] = [
- { value: 9, label: 'NCLC 9', hint: 'Visa — 14/20 minimum' },
- { value: 10, label: 'NCLC 10', hint: 'Excellence — 16/20 minimum' },
-]
-
-export function NclcCibleSelector({ value, onChange, disabled = false }: Props) {
- return (
-
- Objectif de correction
-
- {OPTIONS.map((opt) => {
- const active = opt.value === value
- return (
- onChange(opt.value)}
- className={cn(
- 'px-4 py-2 text-sm font-medium transition-colors duration-150',
- 'focus-visible:outline-none focus-visible:shadow-focus',
- 'disabled:cursor-not-allowed disabled:opacity-50',
- active
- ? 'bg-brand text-white'
- : 'bg-surface text-ink-primary hover:bg-surface-hover hover:text-ink-primary',
- )}
- title={opt.hint}
- >
- {opt.label}
-
- )
- })}
-
- {OPTIONS.find((o) => o.value === value)?.hint}
-
- )
-}
diff --git a/src/features/simulations/components/RecordingTimeline.tsx b/src/features/simulations/components/RecordingTimeline.tsx
deleted file mode 100644
index 234c065..0000000
--- a/src/features/simulations/components/RecordingTimeline.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * Barre de progression colorée pour l'enregistrement EO — Sprint 4.6.
- *
- * Affiche le temps écoulé vs `maxSeconds` avec une couleur qui passe de
- * verte à orange à rouge selon des seuils fixes :
- * - Vert : elapsed < maxSeconds - 30
- * - Orange : maxSeconds - 30 ≤ elapsed < maxSeconds - 15
- * - Rouge : elapsed ≥ maxSeconds - 15 (les 15 dernières secondes)
- *
- * Tokens Direction Charcoal exclusivement (Règle L).
- */
-
-interface Props {
- elapsedSeconds: number
- maxSeconds: number
-}
-
-type TimelineColor = 'success' | 'warning' | 'danger'
-
-export function getTimelineColor(elapsedSeconds: number, maxSeconds: number): TimelineColor {
- if (elapsedSeconds >= maxSeconds - 15) return 'danger'
- if (elapsedSeconds >= maxSeconds - 30) return 'warning'
- return 'success'
-}
-
-const COLOR_VAR: Record = {
- success: 'var(--color-success)',
- warning: 'var(--color-warning)',
- danger: 'var(--color-danger)',
-}
-
-export function RecordingTimeline({ elapsedSeconds, maxSeconds }: Props) {
- const clamped = Math.min(Math.max(elapsedSeconds, 0), maxSeconds)
- const ratio = maxSeconds > 0 ? clamped / maxSeconds : 0
- const color = getTimelineColor(clamped, maxSeconds)
-
- return (
-
- )
-}
diff --git a/src/features/simulations/components/RecordingWaveform.tsx b/src/features/simulations/components/RecordingWaveform.tsx
deleted file mode 100644
index 405680e..0000000
--- a/src/features/simulations/components/RecordingWaveform.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * Visualisation en barres audio animées pendant l'enregistrement — Sprint 4.6.
- *
- * Branche un AnalyserNode sur le `MediaStream` exposé par `useAudioRecorder`,
- * agrège la FFT en `barCount` barres et anime via requestAnimationFrame.
- *
- * Quand `stream === null`, le composant affiche des barres au repos (au cas
- * où il serait monté hors enregistrement) et ne crée aucun AudioContext.
- *
- * Respecte `prefers-reduced-motion` : pas de rAF, rendu statique.
- *
- * Tokens Direction Charcoal exclusivement (Règle L).
- */
-
-import { useEffect, useRef, useState } from 'react'
-
-interface Props {
- stream: MediaStream | null
- barCount?: number
-}
-
-const DEFAULT_BAR_COUNT = 32
-const FFT_SIZE = 256
-
-export function RecordingWaveform({ stream, barCount = DEFAULT_BAR_COUNT }: Props) {
- const [levels, setLevels] = useState(() => new Array(barCount).fill(0))
- const rafRef = useRef(null)
- const audioCtxRef = useRef(null)
-
- useEffect(() => {
- if (!stream) {
- setLevels(new Array(barCount).fill(0))
- return
- }
-
- const reduceMotion =
- typeof window !== 'undefined' &&
- window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
-
- const Ctor =
- window.AudioContext ??
- (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
- if (!Ctor) return
-
- const audioCtx = new Ctor()
- audioCtxRef.current = audioCtx
- const source = audioCtx.createMediaStreamSource(stream)
- const analyser = audioCtx.createAnalyser()
- analyser.fftSize = FFT_SIZE
- analyser.smoothingTimeConstant = 0.7
- source.connect(analyser)
-
- const bins = analyser.frequencyBinCount
- const data = new Uint8Array(bins)
- const binsPerBar = Math.max(1, Math.floor(bins / barCount))
-
- function tick() {
- analyser.getByteFrequencyData(data)
- const next: number[] = []
- for (let b = 0; b < barCount; b++) {
- let sum = 0
- const start = b * binsPerBar
- for (let i = 0; i < binsPerBar; i++) sum += data[start + i] ?? 0
- next.push(sum / binsPerBar / 255)
- }
- setLevels(next)
- rafRef.current = requestAnimationFrame(tick)
- }
-
- if (reduceMotion) {
- // Snapshot unique
- analyser.getByteFrequencyData(data)
- const next: number[] = []
- for (let b = 0; b < barCount; b++) {
- let sum = 0
- const start = b * binsPerBar
- for (let i = 0; i < binsPerBar; i++) sum += data[start + i] ?? 0
- next.push(sum / binsPerBar / 255)
- }
- setLevels(next)
- } else {
- rafRef.current = requestAnimationFrame(tick)
- }
-
- return () => {
- if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
- rafRef.current = null
- try {
- source.disconnect()
- analyser.disconnect()
- } catch {
- /* noop */
- }
- void audioCtx.close().catch(() => {
- /* noop */
- })
- audioCtxRef.current = null
- }
- }, [stream, barCount])
-
- return (
-
- {levels.map((level, i) => {
- const height = Math.max(0.08, level)
- return (
-
- )
- })}
-
- )
-}
diff --git a/src/features/simulations/components/SimulationForm.tsx b/src/features/simulations/components/SimulationForm.tsx
deleted file mode 100644
index 14f90af..0000000
--- a/src/features/simulations/components/SimulationForm.tsx
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * Formulaire de saisie pour une simulation Expression Écrite.
- *
- * SEC-04 : validation Zod avant envoi (texte non vide, max 5 000 caractères).
- * SEC-05 : aucun dangerouslySetInnerHTML — le texte utilisateur est rendu comme texte.
- * Règle H : aucune logique métier — délègue à simulationConfig + useTimer.
- *
- * Minuteur et cibles de mots : cf. getSimulationConfig(tache).
- * À l'expiration du timer, soumission automatique si mots ≥ motsMin,
- * sinon message explicite demandant d'atteindre le seuil.
- */
-
-import { useEffect, useRef, useState, type FormEvent } from 'react'
-import { Clock, Lightbulb, Loader2, Shuffle } from 'lucide-react'
-import { z } from 'zod'
-import { Button } from '@/shared/ui/Button'
-import { formatTache } from '@/entities/production/lib'
-import { hasAccess, type Plan } from '@/entities/user/lib'
-import type { SujetData, Tache } from '@/entities/production/types'
-import type { NclcCible } from '@/entities/report/types'
-import type { ApiError } from '@/shared/types/api'
-import { countWords, getSimulationConfig } from '../lib/simulationConfig'
-import { useTimer } from '../hooks/useTimer'
-import { useIdees } from '../hooks/useIdees'
-import { useAutosave } from '../hooks/useAutosave'
-import type { SimulationStep } from '../state/simulationFlow'
-import { SujetDisplay } from './SujetDisplay'
-import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
-import { TimerDisplay } from './TimerDisplay'
-import { WordCountBar } from './WordCountBar'
-import { IdeesSuggestions } from './IdeesSuggestions'
-import { NclcCibleSelector } from './NclcCibleSelector'
-
-const MIN_WORDS_IDEES = 30
-const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
-
-const secondaryActionBtn =
- 'inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-ink-primary transition-colors hover:border-brand hover:text-brand-text focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50'
-
-const textSchema = z.object({
- texte: z
- .string()
- .min(1, 'Le texte ne peut pas être vide.')
- .max(5000, 'Le texte ne doit pas dépasser 5 000 caractères.'),
-})
-
-function mapCorrectError(err: ApiError | null): string | null {
- if (!err) return null
- switch (err.code) {
- case 'SIMULATION_NOT_FOUND':
- return 'Simulation introuvable. Revenez en arrière et recommencez.'
- case 'AUTH_REQUIRED':
- return 'Votre session a expiré. Reconnectez-vous.'
- case 'VALIDATION_ERROR':
- case 'INVALID_BODY':
- return 'Le texte soumis est invalide. Vérifiez votre saisie.'
- default:
- return 'Correction impossible. Réessayez dans quelques instants.'
- }
-}
-
-interface Props {
- tache: Tache
- sujet: SujetData | null
- plan: Plan
- simulationId: string
- initialContenu?: string
- step: SimulationStep
- isSubmitting: boolean
- error: ApiError | null
- onSubmit: (texte: string, nclcCible: NclcCible) => void
- onBack: () => void
- onChangeSujet: () => void
-}
-
-export function SimulationForm({
- tache,
- sujet,
- plan,
- simulationId,
- initialContenu,
- step,
- isSubmitting,
- error,
- onSubmit,
- onBack,
- onChangeSujet,
-}: Props) {
- const textareaRef = useRef(null)
- const hasAutoSubmittedRef = useRef(false)
- const [texte, setTexte] = useState(() => initialContenu ?? '')
- const [fieldError, setFieldError] = useState(null)
- const [isIdeesOpen, setIsIdeesOpen] = useState(false)
- const [nclcCible, setNclcCible] = useState(9)
-
- const config = getSimulationConfig(tache)
- const wordCount = countWords(texte)
- const canSubmit = wordCount >= config.motsMin
-
- const timer = useTimer(config.dureeMinutes, !isSubmitting)
- const idees = useIdees()
- const autosaveEnabled = !isSubmitting && step !== 'done' && step !== 'correcting'
- const autosave = useAutosave(simulationId, texte, autosaveEnabled)
-
- // FTD-21 — marquer la simulation en cours pour le resume au refresh.
- useEffect(() => {
- if (simulationId) {
- localStorage.setItem(LS_SIMULATION_ID_KEY, simulationId)
- }
- }, [simulationId])
-
- // FTD-21 — nettoyer dès que la correction est terminée.
- useEffect(() => {
- if (step === 'done') {
- localStorage.removeItem(LS_SIMULATION_ID_KEY)
- }
- }, [step])
-
- const tipsAllowed = hasAccess(plan, 'tips')
- const ideesDisabled =
- isSubmitting || idees.isLoading || !sujet || !tipsAllowed || wordCount < MIN_WORDS_IDEES
- const ideesTitle = !tipsAllowed
- ? 'Disponible en Standard'
- : wordCount < MIN_WORDS_IDEES
- ? `Écrivez au moins ${MIN_WORDS_IDEES} mots`
- : undefined
-
- function handleIdeesClick() {
- if (!sujet) return
- setIsIdeesOpen(true)
- idees.fetchIdees({ consigne: sujet.consigne, contenu: texte })
- }
-
- function handleIdeesClose() {
- setIsIdeesOpen(false)
- idees.reset()
- }
-
- useEffect(() => {
- const el = textareaRef.current
- if (!el) return
- el.style.height = 'auto'
- el.style.height = `${el.scrollHeight}px`
- }, [texte])
-
- useEffect(() => {
- if (!timer.isExpired) return
- if (hasAutoSubmittedRef.current) return
- if (isSubmitting) return
- if (wordCount < config.motsMin) return
-
- hasAutoSubmittedRef.current = true
- onSubmit(texte, nclcCible)
- }, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, nclcCible, onSubmit])
-
- function handleInsert(char: string) {
- const el = textareaRef.current
- if (!el) {
- setTexte((prev) => prev + char)
- return
- }
- const start = el.selectionStart ?? texte.length
- const end = el.selectionEnd ?? texte.length
- const next = texte.slice(0, start) + char + texte.slice(end)
- setTexte(next)
- const caret = start + char.length
- requestAnimationFrame(() => {
- el.focus()
- el.setSelectionRange(caret, caret)
- })
- }
-
- function handleSubmit(e: FormEvent) {
- e.preventDefault()
- setFieldError(null)
-
- const parsed = textSchema.safeParse({ texte })
- if (!parsed.success) {
- setFieldError(parsed.error.flatten().fieldErrors.texte?.[0] ?? null)
- return
- }
-
- if (wordCount < config.motsMin) {
- setFieldError(`Écrivez au moins ${config.motsMin} mots pour soumettre.`)
- return
- }
-
- onSubmit(parsed.data.texte, nclcCible)
- }
-
- const apiError = mapCorrectError(error)
- const expiredBelowMin = timer.isExpired && wordCount < config.motsMin
- const submitDisabled = isSubmitting || !canSubmit
-
- return (
-
-
-
- ← Retour
-
-
{formatTache(tache)}
-
-
-
-
- {sujet && (
-
-
-
- Suggestions d'idées
-
-
-
- Changer de sujet
-
-
- )}
-
-
-
- {apiError && (
-
- {apiError}
-
- )}
-
- {expiredBelowMin && (
-
- Temps écoulé. Écrivez au moins {config.motsMin} mots pour soumettre.
-
- )}
-
-
-
- )
-}
diff --git a/src/features/simulations/components/SpecialCharsKeyboard.tsx b/src/features/simulations/components/SpecialCharsKeyboard.tsx
deleted file mode 100644
index 79b812a..0000000
--- a/src/features/simulations/components/SpecialCharsKeyboard.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * Clavier de caractères spéciaux français pour la zone de saisie.
- *
- * Affiche une rangée horizontale scrollable de 30 caractères accentués
- * (15 minuscules + 15 majuscules). Un clic insère le caractère à la
- * position du curseur via le callback `onInsert`.
- *
- * Règle H : purement présentationnel, la logique d'insertion est dans le parent.
- * Règle L : tokens Direction H exclusivement.
- *
- * `onMouseDown preventDefault` empêche le textarea de perdre le focus au clic,
- * ce qui permet de conserver la position du curseur lors de l'insertion.
- */
-
-const SPECIAL_CHARS = [
- 'à',
- 'â',
- 'é',
- 'è',
- 'ê',
- 'ë',
- 'î',
- 'ï',
- 'ô',
- 'ù',
- 'û',
- 'ü',
- 'ç',
- 'œ',
- 'æ',
- 'À',
- 'Â',
- 'É',
- 'È',
- 'Ê',
- 'Ë',
- 'Î',
- 'Ï',
- 'Ô',
- 'Ù',
- 'Û',
- 'Ü',
- 'Ç',
- 'Œ',
- 'Æ',
-] as const
-
-interface Props {
- onInsert: (char: string) => void
- disabled?: boolean
-}
-
-export function SpecialCharsKeyboard({ onInsert, disabled = false }: Props) {
- return (
-
- {SPECIAL_CHARS.map((char) => (
- e.preventDefault()}
- onClick={() => onInsert(char)}
- aria-label={`Insérer le caractère ${char}`}
- className="size-8 shrink-0 rounded-md border border-border bg-surface text-sm font-medium text-ink-primary transition-colors hover:border-brand hover:bg-brand-soft hover:text-brand-text focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50"
- >
- {char}
-
- ))}
-
- )
-}
diff --git a/src/features/simulations/components/SujetCard.tsx b/src/features/simulations/components/SujetCard.tsx
deleted file mode 100644
index 53fcbdc..0000000
--- a/src/features/simulations/components/SujetCard.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * Carte cliquable pour un sujet dans la grille /sujets.
- *
- * Rendu : consigne tronquée sur 3 lignes (line-clamp-3) + badge rôle si présent.
- * Règle H : purement présentationnel — l'action vient du parent.
- * Règle L : tokens Direction H via la primitive Card (variant interactive).
- */
-
-import { Badge } from '@/shared/ui/Badge'
-import { Card } from '@/shared/ui/Card'
-import type { SujetData } from '@/entities/production/types'
-
-interface Props {
- sujet: SujetData
- onSelect: (sujet: SujetData) => void
-}
-
-export function SujetCard({ sujet, onSelect }: Props) {
- return (
- onSelect(sujet)}>
-
- {sujet.role && (
-
- {sujet.role}
-
- )}
-
{sujet.consigne}
-
-
- )
-}
diff --git a/src/features/simulations/components/SujetDisplay.tsx b/src/features/simulations/components/SujetDisplay.tsx
deleted file mode 100644
index d338861..0000000
--- a/src/features/simulations/components/SujetDisplay.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * Affichage du sujet d'examen (consigne + documents) — purement présentationnel.
- *
- * Depuis la refonte /sujets (2026-04-21), le choix du sujet se fait sur une
- * page dédiée (SujetsPage). Ce composant n'affiche que le sujet sélectionné.
- *
- * Le contenu est admin-curé (pas du texte IA) → plain-text avec whitespace-pre-wrap,
- * pas de react-markdown.
- *
- * Règle H : purement présentationnel — le sujet vient du parent.
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { Badge } from '@/shared/ui/Badge'
-import { Card } from '@/shared/ui/Card'
-import type { SujetData } from '@/entities/production/types'
-
-interface Props {
- sujet: SujetData | null
-}
-
-function DocumentBlock({ titre, texte }: { titre: string | null; texte: string | null }) {
- if (!titre && !texte) return null
- return (
-
- {titre && {titre} }
- {texte && (
- {texte}
- )}
-
- )
-}
-
-export function SujetDisplay({ sujet }: Props) {
- if (!sujet) return null
-
- return (
-
-
- {sujet.role && (
-
- Rôle
- {sujet.role}
-
- )}
-
- {sujet.contexte && (
-
- {sujet.contexte}
-
- )}
-
-
-
- Consigne
-
-
- {sujet.consigne}
-
-
-
-
-
-
-
- )
-}
diff --git a/src/features/simulations/components/TaskSelector.tsx b/src/features/simulations/components/TaskSelector.tsx
deleted file mode 100644
index 2b8d58e..0000000
--- a/src/features/simulations/components/TaskSelector.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-/**
- * Sélecteur de tâche pour lancer une simulation.
- *
- * Filtre les cartes selon `type` :
- * - 'EE' → 3 tâches EE (T1/T2/T3) sélectionnables si quota OK
- * - 'EO' → EO_T1 (Entretien) + EO_T3 (Point de vue) + EO_T2 Live verrouillé (Premium)
- *
- * Règle D : le quota est vérifié via canSimulate(), jamais if (plan === 'free').
- * Règle H : aucune logique métier — uniquement appel de canSimulate() et affichage.
- */
-
-import { Lock, Loader2 } from 'lucide-react'
-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'
-import type { Plan } from '@/entities/user/lib'
-import type { CreateSimulationPayload, Tache } from '@/entities/production/types'
-
-export type TaskKind = 'EE' | 'EO'
-
-interface Props {
- type: TaskKind
- plan: Plan
- 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
- /**
- * Sprint 7b — handler dédié pour la carte EO_T1_LIVE. Même gating Premium
- * que T2 Live (`oral_t2_live`, TD-24 — pas de permission distincte). La
- * production EO_T1 est créée en backend en fin de session, pas au clic.
- */
- onT1LiveSelect?: () => void
-}
-
-interface TaskCard {
- key: string
- tache: Tache | null // null = carte non sélectionnable directement (Live → handler dédié)
- label: string
- sublabel: string
- lockLabel?: string
- /** Sprint 7b — carte « Live » (T1 ou T2) : gating Premium via hasAccess. */
- live?: 'T1' | 'T2'
-}
-
-const EE_CARDS: readonly TaskCard[] = [
- { key: 'EE_T1', tache: 'EE_T1', label: 'Expression Écrite', sublabel: 'Tâche 1' },
- { key: 'EE_T2', tache: 'EE_T2', label: 'Expression Écrite', sublabel: 'Tâche 2' },
- { key: 'EE_T3', tache: 'EE_T3', label: 'Expression Écrite', sublabel: 'Tâche 3' },
-]
-
-const EO_CARDS: readonly TaskCard[] = [
- { key: 'EO_T1', tache: 'EO_T1', label: 'Expression Orale', sublabel: 'Entretien' },
- { key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
- {
- key: 'EO_T1_LIVE',
- tache: null,
- label: 'Expression Orale',
- sublabel: 'Tâche 1 — Live',
- lockLabel: 'Exclusivité Premium',
- live: 'T1',
- },
- {
- key: 'EO_T2_LIVE',
- tache: 'EO_T2_LIVE',
- label: 'Expression Orale',
- sublabel: 'Tâche 2 — Live',
- lockLabel: 'Exclusivité Premium',
- live: 'T2',
- },
-]
-
-export function TaskSelector({
- type,
- plan,
- simulationsUsed,
- isLoading,
- onSelect,
- onT2LiveSelect,
- onT1LiveSelect,
-}: Props) {
- const simulationCheck = canSimulate(plan, simulationsUsed)
- const quotaBlocked = !simulationCheck.allowed
- const cards = type === 'EE' ? EE_CARDS : EO_CARDS
- // TD-24 : T1 Live ET T2 Live partagent la même permission `oral_t2_live`.
- const t1LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT1LiveSelect)
- const t2LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT2LiveSelect)
-
- return (
-
-
-
Choisir une tâche
-
- Sélectionnez la tâche que vous souhaitez simuler.
-
-
-
- {quotaBlocked && (
-
- Vous avez utilisé vos 5 simulations gratuites.{' '}
-
- Voir les plans
- {' '}
- pour continuer votre préparation.
-
- )}
-
-
- {cards.map((card) => {
- const isT1Live = card.live === 'T1'
- const isT2Live = card.live === 'T2'
- // Live ne consomme pas le quota Free (plan Premium uniquement, illimité).
- // Verrouillage : Live → hasAccess(plan, 'oral_t2_live') ; autres → quota.
- const locked = isT1Live
- ? !t1LiveUnlocked
- : isT2Live
- ? !t2LiveUnlocked
- : card.tache === null || quotaBlocked
- const abbrev = card.tache ? card.tache.split('_')[0] : 'EO'
-
- if (locked) {
- return (
-
- {(card.tache === null || isT1Live || isT2Live) && (
-
- )}
-
- {card.label}
-
- {card.sublabel}
- {card.lockLabel && (
- {card.lockLabel}
- )}
-
- )
- }
-
- return (
-
{
- if (isLoading) return
- if (isT1Live && onT1LiveSelect) {
- onT1LiveSelect()
- return
- }
- if (isT2Live && onT2LiveSelect) {
- onT2LiveSelect()
- return
- }
- if (card.tache && !card.live) {
- onSelect({ tache: card.tache, mode: 'entrainement' })
- }
- }}
- >
-
- {abbrev}
- {isLoading && (
-
- )}
-
-
- {card.label}
-
- {card.sublabel}
-
- )
- })}
-
-
- )
-}
diff --git a/src/features/simulations/components/TimerDisplay.tsx b/src/features/simulations/components/TimerDisplay.tsx
deleted file mode 100644
index fa522ea..0000000
--- a/src/features/simulations/components/TimerDisplay.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * Affichage du minuteur de simulation au format MM:SS.
- *
- * Règle H : purement présentationnel, reçoit secondesRestantes et isExpired par props.
- * Règle L : tokens Direction H uniquement (ink-*, danger).
- *
- * États visuels :
- * - Normal : ink-2
- * - Critique (< 120s) : danger + pulse (motion-safe uniquement — prefers-reduced-motion respecté)
- * - Expiré : danger + bold
- *
- * A11y : role="timer" + aria-live="polite" pour annoncer les changements critiques.
- */
-
-import { formatTimer } from '../lib/simulationConfig'
-
-interface Props {
- secondesRestantes: number
- isExpired: boolean
-}
-
-const SEUIL_CRITIQUE_SECONDES = 120
-
-export function TimerDisplay({ secondesRestantes, isExpired }: Props) {
- const isCritique = !isExpired && secondesRestantes < SEUIL_CRITIQUE_SECONDES
-
- const base = 'tabular-nums text-sm font-medium'
- const tone = isExpired
- ? 'text-danger font-bold'
- : isCritique
- ? 'text-danger motion-safe:animate-pulse'
- : 'text-ink-primary'
-
- return (
-
- {formatTimer(secondesRestantes)}
-
- )
-}
diff --git a/src/features/simulations/components/TranscriptionDisplay.tsx b/src/features/simulations/components/TranscriptionDisplay.tsx
deleted file mode 100644
index 0d65ef9..0000000
--- a/src/features/simulations/components/TranscriptionDisplay.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * Affichage du transcript live Deepgram — Sprint 4c-1.
- *
- * Présente le transcript final accumulé + l'interim en cours (en italique).
- * Compteur de mots informatif. Empty state explicite tant qu'aucun mot n'a
- * été retourné.
- */
-
-import { Loader2 } from 'lucide-react'
-import { countWords } from '../lib/simulationConfig'
-
-interface Props {
- /** Transcript final accumulé (segments is_final=true). */
- transcript: string
- /** Buffer interim (segment is_final=false en cours). */
- interim?: string
- /** True quand la WS Deepgram est ouverte. */
- isConnected: boolean
-}
-
-export function TranscriptionDisplay({ transcript, interim = '', isConnected }: Props) {
- const total = transcript + (interim ? ` ${interim}` : '')
- const wordCount = countWords(transcript)
- const isEmpty = total.trim().length === 0
-
- return (
-
-
-
- {isConnected ? (
- <>
-
- Transcription en cours…
- >
- ) : (
- Transcription en attente
- )}
-
-
- {wordCount} mot{wordCount > 1 ? 's' : ''}
-
-
-
-
- {isEmpty ? (
- En attente du premier mot…
- ) : (
- <>
- {transcript}
- {interim && {interim} }
- >
- )}
-
-
- )
-}
diff --git a/src/features/simulations/components/WordCountBar.tsx b/src/features/simulations/components/WordCountBar.tsx
deleted file mode 100644
index fe8ada7..0000000
--- a/src/features/simulations/components/WordCountBar.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * Barre de progression du nombre de mots avec compteur contextuel.
- *
- * Règle H : purement présentationnel, reçoit count et config par props.
- * Règle L : tokens Direction H uniquement (success, warning, danger, ink-*, line).
- *
- * Couleurs selon le nombre de mots :
- * - < motsCibleMin : warning (orange) — en dessous de la cible
- * - motsCibleMin ≤ count ≤ motsCibleMax : success (vert) — dans la cible
- * - > motsCibleMax : danger (rouge) — au-dessus de la cible
- *
- * La barre est graduée par rapport à motsCibleMax (100% = borne haute cible).
- * Au-dessus on reste à 100% de remplissage en rouge.
- */
-
-import type { SimulationConfig } from '../lib/simulationConfig'
-
-interface Props {
- count: number
- config: SimulationConfig
-}
-
-type Tone = 'warning' | 'success' | 'danger'
-
-function computeTone(count: number, config: SimulationConfig): Tone {
- if (count > config.motsCibleMax) return 'danger'
- if (count >= config.motsCibleMin) return 'success'
- return 'warning'
-}
-
-const TONE_CLASSES: Record = {
- warning: { bar: 'bg-warning', text: 'text-warning' },
- success: { bar: 'bg-success', text: 'text-success' },
- danger: { bar: 'bg-danger', text: 'text-danger' },
-}
-
-export function WordCountBar({ count, config }: Props) {
- const tone = computeTone(count, config)
- const classes = TONE_CLASSES[tone]
- const pct = Math.min(100, Math.round((count / config.motsCibleMax) * 100))
-
- return (
-
-
-
-
- {count.toLocaleString('fr-FR')} mot{count > 1 ? 's' : ''}
-
-
- cible {config.motsCibleMin}–{config.motsCibleMax} mots
-
-
-
- )
-}
diff --git a/src/features/simulations/components/__tests__/RecordingTimeline.test.tsx b/src/features/simulations/components/__tests__/RecordingTimeline.test.tsx
deleted file mode 100644
index 916e962..0000000
--- a/src/features/simulations/components/__tests__/RecordingTimeline.test.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { afterEach, describe, it, expect } from 'vitest'
-import { cleanup, render, screen } from '@testing-library/react'
-import { RecordingTimeline, getTimelineColor } from '../RecordingTimeline'
-
-describe('getTimelineColor — seuils fixes (15s / 30s avant la fin)', () => {
- it('retourne success quand il reste plus de 30s', () => {
- expect(getTimelineColor(0, 90)).toBe('success')
- expect(getTimelineColor(59, 90)).toBe('success')
- expect(getTimelineColor(89, 120)).toBe('success')
- })
-
- it('retourne warning entre 30s et 15s avant la fin', () => {
- expect(getTimelineColor(60, 90)).toBe('warning')
- expect(getTimelineColor(74, 90)).toBe('warning')
- expect(getTimelineColor(90, 120)).toBe('warning')
- })
-
- it('retourne danger dans les 15 dernières secondes', () => {
- expect(getTimelineColor(75, 90)).toBe('danger')
- expect(getTimelineColor(90, 90)).toBe('danger')
- expect(getTimelineColor(105, 120)).toBe('danger')
- expect(getTimelineColor(120, 120)).toBe('danger')
- })
-
- it('gère les durées courtes (max ≤ 30s) en restant cohérent', () => {
- // À max=20s : maxSeconds-30 = -10 → toujours ≥ warning ; maxSeconds-15 = 5 → danger après 5s
- expect(getTimelineColor(0, 20)).toBe('warning')
- expect(getTimelineColor(5, 20)).toBe('danger')
- })
-})
-
-describe('RecordingTimeline — rendu', () => {
- afterEach(() => cleanup())
-
- it('expose un progressbar avec aria-valuenow/max', () => {
- render( )
- const bar = screen.getByRole('progressbar')
- expect(bar).toHaveAttribute('aria-valuenow', '45')
- expect(bar).toHaveAttribute('aria-valuemax', '90')
- })
-
- it('clamp aria-valuenow à 0 si elapsed négatif', () => {
- render( )
- expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0')
- })
-
- it('clamp aria-valuenow à maxSeconds si dépassement', () => {
- render( )
- expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '90')
- })
-})
diff --git a/src/features/simulations/components/__tests__/TranscriptionDisplay.test.tsx b/src/features/simulations/components/__tests__/TranscriptionDisplay.test.tsx
deleted file mode 100644
index 0045ea9..0000000
--- a/src/features/simulations/components/__tests__/TranscriptionDisplay.test.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { TranscriptionDisplay } from '../TranscriptionDisplay'
-
-describe('TranscriptionDisplay', () => {
- it('affiche un état "Transcription en attente" quand non connecté et vide', () => {
- render( )
- expect(screen.getByText(/Transcription en attente/i)).toBeInTheDocument()
- expect(screen.getByText(/En attente du premier mot/i)).toBeInTheDocument()
- expect(screen.getByText(/^0 mot$/)).toBeInTheDocument()
- })
-
- it('affiche le label "Transcription en cours…" quand connecté', () => {
- render( )
- expect(screen.getByText(/Transcription en cours/i)).toBeInTheDocument()
- })
-
- it("compte les mots du transcript final (ignore l'interim)", () => {
- render(
- ,
- )
- expect(screen.getByText(/^5 mots$/)).toBeInTheDocument()
- })
-
- it('rend transcript final + interim concaténés', () => {
- const { container } = render(
- ,
- )
- expect(container.textContent).toContain('Bonjour')
- expect(container.textContent).toContain('je continue')
- })
-})
diff --git a/src/features/simulations/components/rapport/ConseilNclcCallout.tsx b/src/features/simulations/components/rapport/ConseilNclcCallout.tsx
deleted file mode 100644
index 17a2c5e..0000000
--- a/src/features/simulations/components/rapport/ConseilNclcCallout.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * ConseilNclcCallout — Sprint 3.6b.
- *
- * Section "Plan d'action NCLC" : écart au NCLC cible + action prioritaire.
- * Visible pour tous les plans.
- *
- * Règle L : tokens Direction H exclusivement.
- */
-
-import ReactMarkdown from 'react-markdown'
-import { Card } from '@/shared/ui/Card'
-import type { ConseilNclc, NclcCible } from '@/entities/report/types'
-
-interface Props {
- conseil: ConseilNclc
- nclc: number
- nclcCible: NclcCible
-}
-
-export function ConseilNclcCallout({ conseil, nclc, nclcCible }: Props) {
- // PATCH TEMPORAIRE — à retirer quand FTD-40 (fix prompt backend) est résolu.
- // Le prompt maître DeepSeek génère un message d'encouragement vers `nclcCible`
- // même quand `nclc > nclcCible` ; on substitue alors un texte cohérent.
- const depasse = nclc > nclcCible
-
- return (
-
- Plan d'action NCLC
-
-
-
- Objectif
-
-
{conseil.nclc_cible}
-
- Écart
-
-
{conseil.ecart}
-
-
-
- Action prioritaire
-
-
- {depasse ? (
-
- Excellent travail — vous avez dépassé votre objectif. Continuez sur cette lancée
- pour viser NCLC {nclc + 1} !
-
- ) : (
-
- {conseil.action_prioritaire}
-
- )}
-
-
-
-
- )
-}
diff --git a/src/features/simulations/components/rapport/CritereCard.tsx b/src/features/simulations/components/rapport/CritereCard.tsx
deleted file mode 100644
index 7ad461f..0000000
--- a/src/features/simulations/components/rapport/CritereCard.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * CritereCard — Sprint 3.6b.
- *
- * Carte critère enrichie : nom, score /5, commentaire, exemple, suggestion,
- * astuce + badges des codes d'erreurs taxonomie correspondants.
- *
- * Visible pour Standard et Premium (gate `detailed_report`). Le floutage est
- * géré par le parent via BlurredSection — CritereCard ne connaît pas le plan.
- *
- * Règle L : tokens Direction H exclusivement.
- * Règle H : purement présentationnel — aucune logique plan ici.
- */
-
-import ReactMarkdown from 'react-markdown'
-import { Card } from '@/shared/ui/Card'
-import { Badge } from '@/shared/ui/Badge'
-import type { Critere, ErreurCode } from '@/entities/report/types'
-
-interface Props {
- critere: Critere
- erreursCodes: ErreurCode[]
- /** Sprint 4.8 — échelle par critère : 4 (EO 5 critères) ou 5 (EE / EO legacy 4 critères). */
- maxScore?: 4 | 5
-}
-
-export function CritereCard({ critere, erreursCodes, maxScore = 5 }: Props) {
- return (
-
-
-
{critere.nom}
-
- {critere.score}/{maxScore}
-
-
-
- {critere.commentaire && (
-
-
- {critere.commentaire}
-
-
- )}
-
- {critere.exemple && (
-
-
- Exemple tiré de votre texte
-
-
« {critere.exemple} »
-
- )}
-
- {critere.suggestion && (
-
-
- Reformulation suggérée
-
-
{critere.suggestion}
-
- )}
-
- {critere.astuce && (
-
-
- 💡
-
- {critere.astuce}
-
- )}
-
- {erreursCodes.length > 0 && (
-
- {erreursCodes.map((e) => (
-
- {e.description ?? e.code.replace(/_/g, ' ')}
-
- ))}
-
- )}
-
- )
-}
diff --git a/src/features/simulations/components/rapport/DiagnosticCallout.tsx b/src/features/simulations/components/rapport/DiagnosticCallout.tsx
deleted file mode 100644
index 8cfc534..0000000
--- a/src/features/simulations/components/rapport/DiagnosticCallout.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * DiagnosticCallout — Sprint 3.6b.
- *
- * Section "Ce qui freine votre progression" — phrase courte identifiant
- * le frein principal. Visible pour tous les plans.
- *
- * Règle L : tokens Direction H exclusivement.
- */
-
-import ReactMarkdown from 'react-markdown'
-import { Card } from '@/shared/ui/Card'
-
-interface Props {
- diagnostic: string
-}
-
-export function DiagnosticCallout({ diagnostic }: Props) {
- return (
-
-
- Ce qui freine votre progression
-
-
-
- {diagnostic}
-
-
-
- )
-}
diff --git a/src/features/simulations/components/rapport/ExerciceInteractive.tsx b/src/features/simulations/components/rapport/ExerciceInteractive.tsx
deleted file mode 100644
index dc5d817..0000000
--- a/src/features/simulations/components/rapport/ExerciceInteractive.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * ExerciceInteractive — Sprint 3.6b.
- *
- * Carte d'exercice avec interactions :
- * - Badge de difficulté + thème + diagnostic
- * - Consigne + extrait candidat
- * - Zone de texte libre (tentative du candidat)
- * - Bouton "Indice" → révèle une piste (fond jaune), une seule fois
- * - Bouton "Voir la correction" → activé dès qu'une saisie est présente →
- * révèle correction (fond vert) + explication
- * - Message "Comparez avec votre réponse" une fois la correction révélée
- *
- * Règle H : aucune logique métier — la correction ne calcule rien, elle
- * révèle seulement ce que DeepSeek a produit.
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { useState } from 'react'
-import ReactMarkdown from 'react-markdown'
-import { Card } from '@/shared/ui/Card'
-import { Badge } from '@/shared/ui/Badge'
-import { Button } from '@/shared/ui/Button'
-import { DIFFICULTE_LABEL, type Exercice } from '@/entities/report/types'
-
-interface Props {
- exercice: Exercice
-}
-
-export function ExerciceInteractive({ exercice }: Props) {
- const [tentative, setTentative] = useState('')
- const [indiceRevealed, setIndiceRevealed] = useState(false)
- const [correctionRevealed, setCorrectionRevealed] = useState(false)
-
- const canRevealCorrection = tentative.trim().length > 0
-
- return (
-
-
-
- {DIFFICULTE_LABEL[exercice.difficulte]}
- {exercice.theme && (
-
- {exercice.theme.replace(/_/g, ' ')}
-
- )}
-
-
-
- {exercice.diagnostic && (
- {exercice.diagnostic}
- )}
-
- {exercice.consigne && (
-
-
- Consigne
-
-
{exercice.consigne}
-
- )}
-
- {exercice.extrait && (
-
-
- Extrait à retravailler
-
-
« {exercice.extrait} »
-
- )}
-
-
- Votre réponse
- setTentative(e.target.value)}
- placeholder="Écrivez votre tentative ici…"
- className="w-full resize-none rounded-md border border-border bg-surface p-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:border-brand focus:outline-none focus:shadow-focus"
- />
-
-
-
- setIndiceRevealed(true)}
- >
- {indiceRevealed ? 'Indice révélé' : 'Indice'}
-
- setCorrectionRevealed(true)}
- title={!canRevealCorrection ? "Écrivez d'abord votre tentative" : undefined}
- >
- Voir la correction
-
-
-
- {indiceRevealed && exercice.indice && (
-
-
Indice
-
{exercice.indice}
-
- )}
-
- {correctionRevealed && (
-
-
-
- Correction attendue
-
-
{exercice.correction}
-
- {exercice.explication && (
-
-
- Explication
-
-
-
- {exercice.explication}
-
-
-
- )}
-
- Comparez avec votre réponse ci-dessus pour repérer les différences.
-
-
- )}
-
- )
-}
diff --git a/src/features/simulations/components/rapport/JobStatusFallback.tsx b/src/features/simulations/components/rapport/JobStatusFallback.tsx
deleted file mode 100644
index ace3891..0000000
--- a/src/features/simulations/components/rapport/JobStatusFallback.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * JobStatusFallback — Sprint 3.6b + FTD-24.
- *
- * Affiche un fallback visuel pour les sections générées en asynchrone par le
- * backend (exercices, production modèle) :
- * - 'pending' → "Génération en cours…" avec spinner. Polling automatique
- * géré par useRapport (cf. FTD-24).
- * - 'pending' + hasTimedOut → message "La génération prend plus de temps
- * que prévu" + bouton Réessayer.
- * - 'error' → "Indisponible pour le moment".
- *
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { Loader2 } from 'lucide-react'
-import { Card } from '@/shared/ui/Card'
-import { Button } from '@/shared/ui/Button'
-import type { JobStatus } from '@/entities/report/types'
-
-interface Props {
- status: JobStatus
- pendingLabel?: string
- errorLabel?: string
- hasTimedOut?: boolean
- onRetry?: () => void
-}
-
-export function JobStatusFallback({
- status,
- pendingLabel = 'Génération en cours…',
- errorLabel = 'Indisponible pour le moment.',
- hasTimedOut = false,
- onRetry,
-}: Props) {
- if (status === 'pending') {
- if (hasTimedOut) {
- return (
-
-
- La génération prend plus de temps que prévu.
-
- {onRetry && (
-
- Réessayer
-
- )}
-
- )
- }
-
- return (
-
-
-
- {pendingLabel}
-
-
- )
- }
-
- return (
-
-
- {errorLabel}
-
-
- )
-}
diff --git a/src/features/simulations/components/rapport/ProductionModeleSection.tsx b/src/features/simulations/components/rapport/ProductionModeleSection.tsx
deleted file mode 100644
index 15e3e96..0000000
--- a/src/features/simulations/components/rapport/ProductionModeleSection.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * ProductionModeleSection — Sprint 3.6b.
- *
- * Affiche la production modèle NCLC 9 générée par DeepSeek :
- * - Texte final prêt pour l'examen
- * - 3 passages commentés (notes_pedagogiques)
- * - Transformations : original → amélioré → explication
- * - Bandeau message encourageant
- *
- * Gate `tips` (Standard+). Le floutage est géré par le parent via BlurredSection.
- *
- * Règle L : tokens Direction H exclusivement.
- * Règle H : purement présentationnel.
- */
-
-import ReactMarkdown from 'react-markdown'
-import { Card } from '@/shared/ui/Card'
-import { Badge } from '@/shared/ui/Badge'
-import type { ProductionModele } from '@/entities/report/types'
-
-interface Props {
- modele: ProductionModele
-}
-
-export function ProductionModeleSection({ modele }: Props) {
- return (
-
-
-
-
- Version restructurée NCLC 9+
-
-
{modele.tcf_word_count ?? ''} mots
-
-
- {modele.production_modele_propre}
-
- {modele.tcf_truncated && (
-
- Texte tronqué au maximum autorisé pour la tâche ({modele.tcf_word_max} mots).
-
- )}
-
-
- {modele.notes_pedagogiques.length > 0 && (
-
-
- Passages clés
-
-
- {modele.notes_pedagogiques.map((n, i) => (
-
- « {n.passage} »
- {n.explication}
-
- ))}
-
-
- )}
-
- {modele.transformations.length > 0 && (
-
-
- Transformations appliquées
-
-
- {modele.transformations.map((t, i) => (
-
-
-
- Original
-
-
- {t.original}
-
-
-
-
- Amélioré
-
-
{t.ameliore}
-
- {t.explication}
-
- ))}
-
-
- )}
-
- {modele.message && (
-
-
-
- {modele.message}
-
-
-
- )}
-
- )
-}
diff --git a/src/features/simulations/components/rapport/RevelationCards.tsx b/src/features/simulations/components/rapport/RevelationCards.tsx
deleted file mode 100644
index 7863fa2..0000000
--- a/src/features/simulations/components/rapport/RevelationCards.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * RevelationCards — Sprint 3.6b.
- *
- * Section "Lecture du correcteur" — 3 colonnes : ce que le candidat croit faire,
- * ce que le correcteur observe, et l'impact sur la note.
- *
- * Visible pour tous les plans (cf. PLANS_TARIFAIRES.md).
- * Règle L : tokens Direction H exclusivement.
- */
-
-import ReactMarkdown from 'react-markdown'
-import { Card } from '@/shared/ui/Card'
-import type { Revelation } from '@/entities/report/types'
-
-interface Props {
- revelation: Revelation
-}
-
-const SECTIONS: { key: keyof Revelation; titre: string; ton: 'ink' | 'warning' | 'danger' }[] = [
- { key: 'croyance', titre: 'Ce que vous croyez', ton: 'ink' },
- { key: 'realite', titre: "Ce qu'observe le correcteur", ton: 'warning' },
- { key: 'consequence', titre: 'Conséquence sur la note', ton: 'danger' },
-]
-
-const TON_CLASS: Record<'ink' | 'warning' | 'danger', string> = {
- ink: 'text-ink-primary',
- warning: 'text-warning',
- danger: 'text-danger',
-}
-
-export function RevelationCards({ revelation }: Props) {
- return (
-
- Lecture du correcteur
-
- {SECTIONS.map(({ key, titre, ton }) => (
-
-
- {titre}
-
-
-
- {revelation[key]}
-
-
-
- ))}
-
-
- )
-}
diff --git a/src/features/simulations/components/rapport/ScoreHero.tsx b/src/features/simulations/components/rapport/ScoreHero.tsx
deleted file mode 100644
index f214d2f..0000000
--- a/src/features/simulations/components/rapport/ScoreHero.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * ScoreHero — Sprint 3.6b.
- *
- * Affiche score /20, jauge avec seuil NCLC cible marqué, badge NCLC atteint,
- * et un encart d'écart "X points avant NCLC 9+" si objectif non atteint.
- *
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { Card } from '@/shared/ui/Card'
-import { Badge } from '@/shared/ui/Badge'
-import { ecartVsCible } from '@/entities/report/lib'
-import type { NclcCible } from '@/entities/report/types'
-
-interface Props {
- score: number // /20
- nclc: number // NCLC atteint
- nclcCible: NclcCible
-}
-
-const NCLC_MIN_SCORE: Record = { 9: 14, 10: 16 }
-
-export function ScoreHero({ score, nclc, nclcCible }: Props) {
- const { points, atteint } = ecartVsCible(score, nclcCible)
- const depasse = atteint && nclc > nclcCible
- const seuilCible = NCLC_MIN_SCORE[nclcCible] ?? 14
- const percent = Math.max(0, Math.min(100, (score / 20) * 100))
- const seuilPercent = (seuilCible / 20) * 100
-
- return (
-
-
-
-
- Score
-
-
- {score}
- /20
-
-
-
-
- Niveau atteint
-
-
- NCLC {nclc}
-
-
-
-
- Objectif
-
-
- NCLC {nclcCible}
-
-
-
-
- {/* Jauge avec marqueur NCLC cible */}
-
-
-
- {/* Marqueur du seuil NCLC cible */}
-
-
-
- 0
-
- Seuil NCLC {nclcCible} : {seuilCible}/20
-
- 20
-
-
-
- {/* Encart d'écart */}
- {depasse ? (
-
- Objectif dépassé — continuez vers NCLC {nclc + 1}.
-
- ) : atteint ? (
-
- Objectif NCLC {nclcCible} atteint.
-
- ) : (
-
- {points === 1 ? '1 point avant NCLC ' : `${points} points avant NCLC `}
- {nclcCible}+
-
- )}
-
- )
-}
diff --git a/src/features/simulations/components/rapport/__tests__/ConseilNclcCallout.test.tsx b/src/features/simulations/components/rapport/__tests__/ConseilNclcCallout.test.tsx
deleted file mode 100644
index 50edb21..0000000
--- a/src/features/simulations/components/rapport/__tests__/ConseilNclcCallout.test.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Tests — ConseilNclcCallout (Sprint 4.5).
- *
- * Couvre le patch temporaire FTD-40 :
- * - nclc < cible : affiche action_prioritaire backend
- * - nclc = cible : affiche action_prioritaire backend
- * - nclc > cible : substitue le texte par « Excellent travail — … NCLC {nclc+1} »
- */
-
-import { describe, it, expect, afterEach } from 'vitest'
-import { render, screen, cleanup } from '@testing-library/react'
-import { ConseilNclcCallout } from '../ConseilNclcCallout'
-import type { ConseilNclc } from '@/entities/report/types'
-
-afterEach(cleanup)
-
-const conseil: ConseilNclc = {
- nclc_cible: 'NCLC 9',
- ecart: '2 points',
- action_prioritaire: 'Concentre-toi sur les connecteurs logiques pour atteindre NCLC 9.',
-}
-
-describe('ConseilNclcCallout — patch FTD-40', () => {
- it('nclc < cible : rend action_prioritaire backend', () => {
- render( )
- expect(screen.getByText(/connecteurs logiques/i)).toBeInTheDocument()
- })
-
- it('nclc = cible : rend action_prioritaire backend', () => {
- render( )
- expect(screen.getByText(/connecteurs logiques/i)).toBeInTheDocument()
- })
-
- it('nclc > cible : substitue par texte fixe avec NCLC {nclc+1}', () => {
- render( )
- expect(
- screen.getByText(
- /Excellent travail — vous avez dépassé votre objectif\. Continuez sur cette lancée pour viser NCLC 11 !/,
- ),
- ).toBeInTheDocument()
- expect(screen.queryByText(/connecteurs logiques/i)).not.toBeInTheDocument()
- })
-})
diff --git a/src/features/simulations/components/rapport/__tests__/ExerciceInteractive.test.tsx b/src/features/simulations/components/rapport/__tests__/ExerciceInteractive.test.tsx
deleted file mode 100644
index 0a3d51c..0000000
--- a/src/features/simulations/components/rapport/__tests__/ExerciceInteractive.test.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * Tests — ExerciceInteractive (Sprint 3.6b).
- *
- * Couvre l'état interne : indice révélé une seule fois, bouton correction
- * désactivé tant qu'aucune saisie, activé dès qu'une tentative existe.
- */
-
-import { describe, it, expect, afterEach } from 'vitest'
-import { render, screen, cleanup } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-
-afterEach(cleanup)
-import { ExerciceInteractive } from '../ExerciceInteractive'
-import type { Exercice } from '@/entities/report/types'
-
-const EXERCICE: Exercice = {
- difficulte: 'facile',
- theme: 'accord_sujet_verbe',
- diagnostic: 'Les accords sujet-verbe sont fragiles.',
- consigne: 'Corrigez les accords.',
- extrait: 'les enfants joue',
- indice: 'Pluriel du sujet ?',
- correction: 'les enfants jouent',
- explication: "Le verbe s'accorde en nombre avec le sujet.",
-}
-
-describe('ExerciceInteractive', () => {
- it('affiche le badge de difficulté avec le libellé mappé', () => {
- render( )
- expect(screen.getByText('Facile')).toBeInTheDocument()
- })
-
- it('bouton "Voir la correction" désactivé tant que la zone de saisie est vide', () => {
- render( )
- const btn = screen.getByRole('button', { name: /voir la correction/i })
- expect(btn).toBeDisabled()
- })
-
- it('bouton "Voir la correction" activé dès qu\'une saisie est présente', async () => {
- const user = userEvent.setup()
- render( )
- await user.type(screen.getByPlaceholderText(/écrivez votre tentative/i), 'ma réponse')
-
- expect(screen.getByRole('button', { name: /voir la correction/i })).toBeEnabled()
- })
-
- it('clic sur "Indice" révèle la piste une seule fois (bouton se désactive)', async () => {
- const user = userEvent.setup()
- render( )
-
- const btn = screen.getByRole('button', { name: /^indice$/i })
- expect(btn).toBeEnabled()
- expect(screen.queryByText(EXERCICE.indice)).not.toBeInTheDocument()
-
- await user.click(btn)
-
- expect(screen.getByText(EXERCICE.indice)).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /indice révélé/i })).toBeDisabled()
- })
-
- it('clic sur "Voir la correction" révèle correction + explication + message final', async () => {
- const user = userEvent.setup()
- render( )
-
- await user.type(screen.getByPlaceholderText(/écrivez votre tentative/i), 'les enfants joue')
- await user.click(screen.getByRole('button', { name: /voir la correction/i }))
-
- expect(screen.getByText(EXERCICE.correction)).toBeInTheDocument()
- expect(screen.getByText(EXERCICE.explication)).toBeInTheDocument()
- expect(screen.getByText(/comparez avec votre réponse/i)).toBeInTheDocument()
- })
-
- it('le bouton "Indice" reste disponible si aucun indice fourni par le backend', () => {
- const sansIndice: Exercice = { ...EXERCICE, indice: '' }
- render( )
- // Pas d'indice → bouton désactivé d'office (evite de révéler un vide)
- expect(screen.getByRole('button', { name: /^indice$/i })).toBeDisabled()
- })
-})
diff --git a/src/features/simulations/components/rapport/__tests__/ScoreHero.test.tsx b/src/features/simulations/components/rapport/__tests__/ScoreHero.test.tsx
deleted file mode 100644
index 9e98bb4..0000000
--- a/src/features/simulations/components/rapport/__tests__/ScoreHero.test.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * Tests — ScoreHero (Sprint 4.5).
- *
- * Couvre les 3 états de l'encart de conclusion :
- * - NCLC atteint < NCLC cible : message « X points avant NCLC … »
- * - NCLC atteint = NCLC cible : message « Objectif NCLC … atteint. »
- * - NCLC atteint > NCLC cible : message « Objectif dépassé — continuez vers NCLC {nclc+1}. »
- */
-
-import { describe, it, expect } from 'vitest'
-import { render, screen, cleanup } from '@testing-library/react'
-import { afterEach } from 'vitest'
-import { ScoreHero } from '../ScoreHero'
-
-afterEach(cleanup)
-
-describe('ScoreHero — encart de conclusion', () => {
- it('NCLC atteint < cible : affiche « X points avant NCLC {cible}+ »', () => {
- render( )
- expect(screen.getByText(/2 points avant NCLC/i)).toBeInTheDocument()
- expect(screen.getByText(/9\+/)).toBeInTheDocument()
- })
-
- it('NCLC atteint = cible : affiche « Objectif NCLC {cible} atteint. »', () => {
- render( )
- expect(screen.getByText('Objectif NCLC 9 atteint.')).toBeInTheDocument()
- })
-
- it('NCLC atteint > cible : affiche « Objectif dépassé — continuez vers NCLC {nclc+1}. »', () => {
- render( )
- expect(screen.getByText('Objectif dépassé — continuez vers NCLC 11.')).toBeInTheDocument()
- })
-})
diff --git a/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts b/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts
deleted file mode 100644
index 2c19961..0000000
--- a/src/features/simulations/hooks/__tests__/useAudioRecorder.test.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-/**
- * Tests du hook useAudioRecorder — Sprint 4c-1.
- *
- * jsdom ne fournit ni MediaRecorder ni navigator.mediaDevices : on les mocke.
- * On valide :
- * - permission denied → status 'error' + permissionDenied=true
- * - start → status 'recording', timer incrémente
- * - stop → status 'stopped' + audioBlob produit
- * - subscribeChunks reçoit les chunks pendant l'enregistrement
- */
-
-import { act, renderHook } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { useAudioRecorder } from '../useAudioRecorder'
-
-// ── Mocks MediaRecorder + getUserMedia ──────────────────────────────────
-
-class FakeMediaStream {
- getTracks() {
- return [{ stop: vi.fn() }]
- }
-}
-
-interface FakeRecorderInstance {
- state: 'inactive' | 'recording'
- start: (timeslice?: number) => void
- stop: () => void
- ondataavailable: ((e: { data: Blob }) => void) | null
- onstop: (() => void) | null
- onerror: ((e: unknown) => void) | null
- emitChunk: (chunk: Blob) => void
-}
-
-const recorderInstances: FakeRecorderInstance[] = []
-
-class FakeMediaRecorder {
- state: 'inactive' | 'recording' = 'inactive'
- ondataavailable: ((e: { data: Blob }) => void) | null = null
- onstop: (() => void) | null = null
- onerror: ((e: unknown) => void) | null = null
-
- constructor() {
- const inst: FakeRecorderInstance = {
- get state() {
- return self.state
- },
- set state(v: 'inactive' | 'recording') {
- self.state = v
- },
- start: (timeslice?: number) => self.start(timeslice),
- stop: () => self.stop(),
- get ondataavailable() {
- return self.ondataavailable
- },
- set ondataavailable(v) {
- self.ondataavailable = v
- },
- get onstop() {
- return self.onstop
- },
- set onstop(v) {
- self.onstop = v
- },
- get onerror() {
- return self.onerror
- },
- set onerror(v) {
- self.onerror = v
- },
- emitChunk: (chunk: Blob) => self.ondataavailable?.({ data: chunk }),
- }
- // eslint-disable-next-line @typescript-eslint/no-this-alias
- const self = this
- recorderInstances.push(inst)
- }
-
- static isTypeSupported(_t: string): boolean {
- void _t
- return true
- }
-
- start(_timeslice?: number) {
- void _timeslice
- this.state = 'recording'
- }
-
- stop() {
- this.state = 'inactive'
- this.onstop?.()
- }
-}
-
-function setupMediaMocks(opts: { allow: boolean } = { allow: true }) {
- ;(globalThis as unknown as { MediaRecorder: typeof FakeMediaRecorder }).MediaRecorder =
- FakeMediaRecorder
- Object.defineProperty(globalThis, 'navigator', {
- value: {
- mediaDevices: {
- getUserMedia: vi.fn().mockImplementation(() => {
- if (!opts.allow) {
- const err = new Error('denied')
- err.name = 'NotAllowedError'
- return Promise.reject(err)
- }
- return Promise.resolve(new FakeMediaStream())
- }),
- },
- },
- writable: true,
- configurable: true,
- })
-}
-
-// ── Tests ───────────────────────────────────────────────────────────────
-
-describe('useAudioRecorder', () => {
- beforeEach(() => {
- recorderInstances.length = 0
- vi.useFakeTimers()
- })
-
- afterEach(() => {
- vi.useRealTimers()
- })
-
- it("permission denied → status 'error' et permissionDenied=true", async () => {
- setupMediaMocks({ allow: false })
- const { result } = renderHook(() => useAudioRecorder())
-
- await act(async () => {
- await result.current.start()
- })
-
- expect(result.current.status).toBe('error')
- expect(result.current.permissionDenied).toBe(true)
- })
-
- it("start passe en 'recording' et le timer incrémente", async () => {
- setupMediaMocks({ allow: true })
- const { result } = renderHook(() => useAudioRecorder())
-
- await act(async () => {
- await result.current.start()
- })
-
- expect(result.current.status).toBe('recording')
- expect(result.current.elapsedSeconds).toBe(0)
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(3_000)
- })
-
- expect(result.current.elapsedSeconds).toBe(3)
- })
-
- it("stop produit un audioBlob et passe en 'stopped'", async () => {
- setupMediaMocks({ allow: true })
- const { result } = renderHook(() => useAudioRecorder())
-
- await act(async () => {
- await result.current.start()
- })
-
- const inst = recorderInstances[0]!
- act(() => {
- inst.emitChunk(new Blob(['chunk1'], { type: 'audio/webm' }))
- })
-
- act(() => {
- result.current.stop()
- })
-
- expect(result.current.status).toBe('stopped')
- expect(result.current.audioBlob).toBeInstanceOf(Blob)
- })
-
- it('subscribeChunks reçoit les chunks émis pendant l’enregistrement', async () => {
- setupMediaMocks({ allow: true })
- const { result } = renderHook(() => useAudioRecorder())
-
- const received: Blob[] = []
- const unsub = result.current.subscribeChunks((c) => received.push(c))
-
- await act(async () => {
- await result.current.start()
- })
-
- const inst = recorderInstances[0]!
- act(() => {
- inst.emitChunk(new Blob(['a'], { type: 'audio/webm' }))
- inst.emitChunk(new Blob(['b'], { type: 'audio/webm' }))
- })
-
- expect(received).toHaveLength(2)
- unsub()
- })
-})
diff --git a/src/features/simulations/hooks/__tests__/useAutosave.test.ts b/src/features/simulations/hooks/__tests__/useAutosave.test.ts
deleted file mode 100644
index d7dd16f..0000000
--- a/src/features/simulations/hooks/__tests__/useAutosave.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * Tests du hook useAutosave — FTD-21.
- *
- * Valide :
- * - debounce 30 s (pas de save avant, save après)
- * - flush immédiat sur `beforeunload`
- * - cleanup du listener au unmount
- * - enabled=false → aucun appel
- */
-
-import { act, renderHook } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-vi.mock('@/entities/production/api', () => ({
- autosaveContenu: vi.fn().mockResolvedValue(undefined),
-}))
-
-import { autosaveContenu } from '@/entities/production/api'
-import { useAutosave } from '../useAutosave'
-
-const mocked = vi.mocked(autosaveContenu)
-
-describe('useAutosave', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- mocked.mockClear()
- mocked.mockResolvedValue(undefined)
- })
-
- afterEach(() => {
- vi.useRealTimers()
- })
-
- it("debounce 30 s : pas d'appel avant, appel après", async () => {
- const { rerender } = renderHook(
- ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
- { initialProps: { contenu: '' } },
- )
-
- rerender({ contenu: 'hello world' })
-
- // Avant 30 s : aucun appel
- await act(async () => {
- await vi.advanceTimersByTimeAsync(29_000)
- })
- expect(mocked).not.toHaveBeenCalled()
-
- // Après 30 s : save
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2_000)
- })
- expect(mocked).toHaveBeenCalledTimes(1)
- expect(mocked).toHaveBeenCalledWith('sim-1', 'hello world')
- })
-
- it('flush immédiat sur beforeunload', async () => {
- const { rerender } = renderHook(
- ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
- { initialProps: { contenu: '' } },
- )
-
- rerender({ contenu: 'texte à sauvegarder' })
-
- await act(async () => {
- window.dispatchEvent(new Event('beforeunload'))
- await Promise.resolve()
- })
-
- expect(mocked).toHaveBeenCalledTimes(1)
- expect(mocked).toHaveBeenCalledWith('sim-1', 'texte à sauvegarder')
- })
-
- it('cleanup : unmount retire le listener beforeunload', async () => {
- const { unmount, rerender } = renderHook(
- ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
- { initialProps: { contenu: '' } },
- )
- rerender({ contenu: 'texte' })
-
- unmount()
-
- await act(async () => {
- window.dispatchEvent(new Event('beforeunload'))
- await Promise.resolve()
- })
-
- expect(mocked).not.toHaveBeenCalled()
- })
-
- it('enabled=false : aucun appel, même après 30 s', async () => {
- const { rerender } = renderHook(
- ({ contenu, enabled }: { contenu: string; enabled: boolean }) =>
- useAutosave('sim-1', contenu, enabled),
- { initialProps: { contenu: 'texte', enabled: false } },
- )
- rerender({ contenu: 'texte modifié', enabled: false })
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(31_000)
- })
-
- expect(mocked).not.toHaveBeenCalled()
- })
-
- it('simulationId null : aucun appel', async () => {
- renderHook(() => useAutosave(null, 'texte', true))
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(31_000)
- })
-
- expect(mocked).not.toHaveBeenCalled()
- })
-
- it('FTD-23 : enabled true→false annule le debounce en cours', async () => {
- const { rerender } = renderHook(
- ({ contenu, enabled }: { contenu: string; enabled: boolean }) =>
- useAutosave('sim-1', contenu, enabled),
- { initialProps: { contenu: '', enabled: true } },
- )
-
- rerender({ contenu: 'hello world', enabled: true })
-
- // Avance partiellement le debounce.
- await act(async () => {
- await vi.advanceTimersByTimeAsync(15_000)
- })
- expect(mocked).not.toHaveBeenCalled()
-
- // Passage à enabled=false (simule step='done' après correction).
- rerender({ contenu: 'hello world', enabled: false })
-
- // Fin du debounce — ne doit PAS déclencher d'appel.
- await act(async () => {
- await vi.advanceTimersByTimeAsync(30_000)
- })
- expect(mocked).not.toHaveBeenCalled()
- })
-
- it('FTD-23 : enabled=false + beforeunload = aucun appel', async () => {
- const { rerender } = renderHook(
- ({ contenu, enabled }: { contenu: string; enabled: boolean }) =>
- useAutosave('sim-1', contenu, enabled),
- { initialProps: { contenu: 'texte', enabled: true } },
- )
-
- rerender({ contenu: 'texte', enabled: false })
-
- await act(async () => {
- window.dispatchEvent(new Event('beforeunload'))
- await Promise.resolve()
- })
-
- expect(mocked).not.toHaveBeenCalled()
- })
-
- it("dédoublonnage : pas de second appel si le contenu n'a pas changé", async () => {
- const { rerender } = renderHook(
- ({ contenu }: { contenu: string }) => useAutosave('sim-1', contenu, true),
- { initialProps: { contenu: '' } },
- )
-
- rerender({ contenu: 'identique' })
- await act(async () => {
- await vi.advanceTimersByTimeAsync(31_000)
- })
- expect(mocked).toHaveBeenCalledTimes(1)
-
- // Rerender avec même contenu → debounce ne repart pas (le contenu ref ne change pas côté React)
- // Simule une édition puis retour au texte initial (déjà sauvegardé)
- rerender({ contenu: 'identique' })
- await act(async () => {
- await vi.advanceTimersByTimeAsync(31_000)
- })
- expect(mocked).toHaveBeenCalledTimes(1)
- })
-})
diff --git a/src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts b/src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts
deleted file mode 100644
index ab54fd7..0000000
--- a/src/features/simulations/hooks/__tests__/useDeepgramLive.test.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-/**
- * Tests du hook useDeepgramLive — Sprint 4c-1.
- *
- * jsdom ne fournit pas de WebSocket utilisable : on installe un fake
- * minimaliste pilotable depuis les tests. On valide :
- * - connect → demande un token + ouvre une WS sur le bon endpoint
- * - is_final → append au transcript ; sinon → interim
- * - sendChunk envoie via la WS ouverte ; bufferise sinon
- * - close envoie CloseStream et passe en status='closed'
- * - rotation : un nouveau token est demandé avant expiration
- */
-
-import { act, renderHook } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-vi.mock('@/entities/transcription/api', () => ({
- requestDeepgramToken: vi.fn(),
-}))
-
-import { requestDeepgramToken } from '@/entities/transcription/api'
-import { useDeepgramLive } from '../useDeepgramLive'
-
-const mockedToken = vi.mocked(requestDeepgramToken)
-
-// ── Fake WebSocket ──────────────────────────────────────────────────────
-
-type WSListener = (e: { data: string }) => void
-
-interface FakeWS {
- url: string
- readyState: number
- send: ReturnType
- close: ReturnType
- onopen: (() => void) | null
- onmessage: WSListener | null
- onerror: (() => void) | null
- onclose: (() => void) | null
- addEventListener: (e: string, cb: () => void) => void
- removeEventListener: (e: string, cb: () => void) => void
- emitOpen: () => void
- emitMessage: (payload: unknown) => void
- protocols: string | string[] | undefined
-}
-
-const wsInstances: FakeWS[] = []
-
-class FakeWebSocket implements Partial {
- static OPEN = 1
- static CLOSED = 3
- url: string
- protocols: string | string[] | undefined
- readyState = 0
- send = vi.fn()
- close = vi.fn(() => {
- this.readyState = FakeWebSocket.CLOSED
- })
- onopen: (() => void) | null = null
- onmessage: WSListener | null = null
- onerror: (() => void) | null = null
- onclose: (() => void) | null = null
- private listeners: Map void>> = new Map()
-
- constructor(url: string, protocols?: string | string[]) {
- this.url = url
- this.protocols = protocols
- wsInstances.push(this as unknown as FakeWS)
- }
-
- addEventListener(event: string, cb: () => void) {
- const arr = this.listeners.get(event) ?? []
- arr.push(cb)
- this.listeners.set(event, arr)
- }
- removeEventListener(event: string, cb: () => void) {
- const arr = this.listeners.get(event) ?? []
- this.listeners.set(
- event,
- arr.filter((c) => c !== cb),
- )
- }
- emitOpen() {
- this.readyState = FakeWebSocket.OPEN
- this.onopen?.()
- ;(this.listeners.get('open') ?? []).forEach((cb) => cb())
- }
- emitMessage(payload: unknown) {
- this.onmessage?.({ data: JSON.stringify(payload) })
- }
-}
-
-beforeEach(() => {
- wsInstances.length = 0
- ;(globalThis as unknown as { WebSocket: typeof FakeWebSocket }).WebSocket = FakeWebSocket
- mockedToken.mockReset()
- vi.useFakeTimers()
-})
-
-afterEach(() => {
- vi.useRealTimers()
-})
-
-// ── Tests ───────────────────────────────────────────────────────────────
-
-describe('useDeepgramLive', () => {
- it('connect demande un token et ouvre une WS sur Deepgram', async () => {
- mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
- const { result } = renderHook(() => useDeepgramLive())
-
- await act(async () => {
- await result.current.connect()
- })
-
- expect(mockedToken).toHaveBeenCalledTimes(1)
- expect(wsInstances).toHaveLength(1)
- expect(wsInstances[0]!.url).toContain('wss://api.deepgram.com/v1/listen')
- expect(wsInstances[0]!.url).toContain('language=fr')
- expect(wsInstances[0]!.url).toContain('model=nova-2')
- // Le token n'est PAS dans l'URL — il est passé via Sec-WebSocket-Protocol.
- expect(wsInstances[0]!.url).not.toContain('token=')
- expect(wsInstances[0]!.protocols).toEqual(['token', 'tok-1'])
- })
-
- it('is_final accumule le transcript ; interim non accumulé', async () => {
- mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
- const { result } = renderHook(() => useDeepgramLive())
-
- await act(async () => {
- await result.current.connect()
- })
-
- const ws = wsInstances[0]!
- act(() => ws.emitOpen())
-
- act(() => {
- ws.emitMessage({
- channel: { alternatives: [{ transcript: 'Bonjour' }] },
- is_final: false,
- })
- })
- expect(result.current.interim).toBe('Bonjour')
- expect(result.current.transcript).toBe('')
-
- act(() => {
- ws.emitMessage({
- channel: { alternatives: [{ transcript: 'Bonjour je m appelle Pierre' }] },
- is_final: true,
- })
- })
- expect(result.current.transcript).toBe('Bonjour je m appelle Pierre')
- expect(result.current.interim).toBe('')
- })
-
- it('sendChunk envoie sur la WS ouverte', async () => {
- mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
- const { result } = renderHook(() => useDeepgramLive())
-
- await act(async () => {
- await result.current.connect()
- })
-
- const ws = wsInstances[0]!
- act(() => ws.emitOpen())
-
- const blob = new Blob(['chunk'], { type: 'audio/webm' })
- act(() => result.current.sendChunk(blob))
-
- expect(ws.send).toHaveBeenCalledWith(blob)
- })
-
- it("close envoie CloseStream et passe en status='closed'", async () => {
- mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
- const { result } = renderHook(() => useDeepgramLive())
-
- await act(async () => {
- await result.current.connect()
- })
-
- const ws = wsInstances[0]!
- act(() => ws.emitOpen())
-
- act(() => result.current.close())
-
- expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'CloseStream' }))
- expect(ws.close).toHaveBeenCalled()
- expect(result.current.status).toBe('closed')
- })
-
- it('rotation : un nouveau token est demandé à T-60 s avant expiration', async () => {
- mockedToken
- .mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 })
- .mockResolvedValueOnce({ token: 'tok-2', expires_in: 600 })
-
- const { result } = renderHook(() => useDeepgramLive())
-
- await act(async () => {
- await result.current.connect()
- })
- act(() => wsInstances[0]!.emitOpen())
-
- expect(mockedToken).toHaveBeenCalledTimes(1)
-
- // Avancer juste avant l'échéance (rotation à T-60 s = 540 s).
- await act(async () => {
- await vi.advanceTimersByTimeAsync(539_000)
- })
- expect(mockedToken).toHaveBeenCalledTimes(1)
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2_000)
- })
- expect(mockedToken).toHaveBeenCalledTimes(2)
- expect(wsInstances).toHaveLength(2)
- expect(wsInstances[1]!.url).not.toContain('token=')
- expect(wsInstances[1]!.protocols).toEqual(['token', 'tok-2'])
- })
-})
diff --git a/src/features/simulations/hooks/__tests__/useRapport.test.tsx b/src/features/simulations/hooks/__tests__/useRapport.test.tsx
deleted file mode 100644
index 90ee64f..0000000
--- a/src/features/simulations/hooks/__tests__/useRapport.test.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-/**
- * Tests du hook useRapport — FTD-24.
- *
- * Valide :
- * - Démarrage polling quand exercices_status ou modele_status === 'pending'
- * - Arrêt polling quand les deux statuts sortent de 'pending' (ready)
- * - Arrêt polling quand les deux statuts sont 'error'
- * - hasTimedOut=true après 2 min de polling continu
- * - refetch() remet hasTimedOut=false et relance le polling
- *
- * Note : fake timers + waitFor ne font pas bon ménage. On avance les timers
- * manuellement via `vi.advanceTimersByTimeAsync` sous `act()`, ce qui déclenche
- * les refetchs TanStack Query et les re-renders synchronement dans le test.
- */
-
-import React from 'react'
-import { act, renderHook } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-vi.mock('@/entities/report/api', () => ({
- getReport: vi.fn(),
-}))
-
-import { getReport } from '@/entities/report/api'
-import type { Report } from '@/entities/report/types'
-import { useRapport } from '../useRapport'
-
-const mockedGetReport = vi.mocked(getReport)
-
-function makeReport(overrides: Partial = {}): Report {
- return {
- simulation_id: 'sim-1',
- score: 14,
- nclc: 8,
- nclc_cible: 9,
- revelation: { croyance: '', realite: '', consequence: '' },
- diagnostic: '',
- criteres: [],
- conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
- erreurs_codes: [],
- exercices: null,
- exercices_status: 'pending',
- modele: null,
- modele_status: 'pending',
- ...overrides,
- }
-}
-
-function renderUseRapport() {
- const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false } },
- })
- const wrapper = ({ children }: { children: React.ReactNode }) =>
- React.createElement(QueryClientProvider, { client: queryClient }, children)
- return renderHook(() => useRapport('sim-1'), { wrapper })
-}
-
-/** Flush microtasks + une tick timer pour laisser TanStack Query / React se stabiliser. */
-async function flush() {
- await act(async () => {
- await vi.advanceTimersByTimeAsync(1)
- })
-}
-
-describe('useRapport — FTD-24 polling', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- mockedGetReport.mockReset()
- })
-
- afterEach(() => {
- vi.useRealTimers()
- })
-
- it("démarre le polling quand exercices_status='pending'", async () => {
- mockedGetReport.mockResolvedValue(
- makeReport({ exercices_status: 'pending', modele_status: 'ready' }),
- )
-
- const { result } = renderUseRapport()
-
- await flush()
- expect(result.current.rapport).toBeDefined()
- expect(mockedGetReport).toHaveBeenCalledTimes(1)
- expect(result.current.isPolling).toBe(true)
-
- // Après 3 s : 2e appel (polling).
- await act(async () => {
- await vi.advanceTimersByTimeAsync(3_000)
- })
- expect(mockedGetReport).toHaveBeenCalledTimes(2)
-
- // Après 3 s de plus : 3e appel.
- await act(async () => {
- await vi.advanceTimersByTimeAsync(3_000)
- })
- expect(mockedGetReport).toHaveBeenCalledTimes(3)
- })
-
- it('arrête le polling dès que les deux statuts sortent de pending (ready)', async () => {
- mockedGetReport
- .mockResolvedValueOnce(makeReport({ exercices_status: 'pending', modele_status: 'pending' }))
- .mockResolvedValue(makeReport({ exercices_status: 'ready', modele_status: 'ready' }))
-
- const { result } = renderUseRapport()
-
- await flush()
- expect(result.current.isPolling).toBe(true)
-
- // Tick polling : 2e appel renvoie ready/ready.
- await act(async () => {
- await vi.advanceTimersByTimeAsync(3_000)
- })
- await flush()
- expect(result.current.isPolling).toBe(false)
-
- // 5 s de plus : pas de nouvel appel (polling stoppé).
- const callsAfterReady = mockedGetReport.mock.calls.length
- await act(async () => {
- await vi.advanceTimersByTimeAsync(5_000)
- })
- expect(mockedGetReport).toHaveBeenCalledTimes(callsAfterReady)
- })
-
- it("n'active pas le polling quand les deux statuts sont 'error'", async () => {
- mockedGetReport.mockResolvedValue(
- makeReport({ exercices_status: 'error', modele_status: 'error' }),
- )
-
- const { result } = renderUseRapport()
-
- await flush()
- expect(result.current.rapport).toBeDefined()
- expect(result.current.isPolling).toBe(false)
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(10_000)
- })
- expect(mockedGetReport).toHaveBeenCalledTimes(1)
- })
-
- it('hasTimedOut=true après 2 min de polling continu, puis arrêt', async () => {
- mockedGetReport.mockResolvedValue(
- makeReport({ exercices_status: 'pending', modele_status: 'pending' }),
- )
-
- const { result } = renderUseRapport()
-
- await flush()
- expect(result.current.isPolling).toBe(true)
-
- // 120 s de polling continu → timeout.
- await act(async () => {
- await vi.advanceTimersByTimeAsync(120_000)
- })
- await flush()
-
- expect(result.current.hasTimedOut).toBe(true)
- expect(result.current.isPolling).toBe(false)
-
- const callsAtTimeout = mockedGetReport.mock.calls.length
-
- // Après timeout, pas de nouvel appel déclenché par refetchInterval.
- await act(async () => {
- await vi.advanceTimersByTimeAsync(10_000)
- })
- expect(mockedGetReport).toHaveBeenCalledTimes(callsAtTimeout)
- })
-
- it('refetch() remet hasTimedOut=false et relance le polling', async () => {
- mockedGetReport.mockResolvedValue(
- makeReport({ exercices_status: 'pending', modele_status: 'pending' }),
- )
-
- const { result } = renderUseRapport()
-
- await flush()
- expect(result.current.isPolling).toBe(true)
-
- // Déclenche le timeout.
- await act(async () => {
- await vi.advanceTimersByTimeAsync(120_000)
- })
- await flush()
- expect(result.current.hasTimedOut).toBe(true)
-
- const callsBeforeRetry = mockedGetReport.mock.calls.length
-
- // refetch() réinitialise le flag et refait un appel.
- await act(async () => {
- await result.current.refetch()
- })
-
- expect(result.current.hasTimedOut).toBe(false)
- expect(mockedGetReport.mock.calls.length).toBe(callsBeforeRetry + 1)
-
- // Polling actif à nouveau : tick → nouvel appel.
- expect(result.current.isPolling).toBe(true)
- await act(async () => {
- await vi.advanceTimersByTimeAsync(3_000)
- })
- expect(mockedGetReport.mock.calls.length).toBeGreaterThan(callsBeforeRetry + 1)
- })
-})
diff --git a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx b/src/features/simulations/hooks/__tests__/useSimulation.test.tsx
deleted file mode 100644
index 0f8394b..0000000
--- a/src/features/simulations/hooks/__tests__/useSimulation.test.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-/**
- * Tests de la state machine useSimulation.
- *
- * Transitions couvertes :
- * idle → choosing-subject (selectTask success, tâche avec catalogue)
- * choosing-subject → task-selected (changeSubject + setStep depuis /sujets)
- * task-selected → correcting (submitText déclenché)
- * correcting → done (correctEe success)
- * correcting → task-selected (correctEe error)
- * * → idle (reset)
- * guard submitText sans production (aucune mutation)
- */
-
-import { renderHook, act, waitFor } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { MemoryRouter } from 'react-router-dom'
-import React from 'react'
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { useSimulation } from '../useSimulation'
-import { SimulationFlowProvider } from '../../state/SimulationFlowProvider'
-import { useSimulationFlow } from '../../state/simulationFlow'
-import { createSimulation, getSimulationState, updateSujet } from '@/entities/production/api'
-import { correctEe } from '@/entities/report/api'
-import type { Production } from '@/entities/production/types'
-import type { Report } from '@/entities/report/types'
-
-vi.mock('@/entities/production/api')
-vi.mock('@/entities/report/api')
-
-const mockCreateSimulation = vi.mocked(createSimulation)
-const mockCorrectEe = vi.mocked(correctEe)
-const mockGetSimulationState = vi.mocked(getSimulationState)
-const mockUpdateSujet = vi.mocked(updateSujet)
-
-const mockProduction: Production = {
- id: 'sim-1',
- tache: 'EE_T1',
- mode: 'entrainement',
- created_at: '2026-04-19T00:00:00Z',
- sujet: null,
-}
-
-const mockSujet = {
- id: 'sujet-1',
- consigne: 'Rédigez une lettre.',
- role: null,
- contexte: null,
- doc1_titre: null,
- doc1_texte: null,
- doc2_titre: null,
- doc2_texte: null,
-}
-
-const mockReport: Report = {
- simulation_id: 'sim-1',
- score: 14,
- nclc: 9,
- nclc_cible: 9,
- revelation: { croyance: '', realite: '', consequence: '' },
- diagnostic: 'Diagnostic test.',
- criteres: [],
- conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
- erreurs_codes: [],
- exercices: null,
- exercices_status: 'pending',
- modele: null,
- modele_status: 'pending',
-}
-
-function createWrapper() {
- const queryClient = new QueryClient({
- defaultOptions: { mutations: { retry: false } },
- })
- return function Wrapper({ children }: { children: React.ReactNode }) {
- return React.createElement(
- MemoryRouter,
- null,
- React.createElement(
- QueryClientProvider,
- { client: queryClient },
- React.createElement(SimulationFlowProvider, null, children),
- ),
- )
- }
-}
-
-beforeEach(() => {
- vi.clearAllMocks()
- localStorage.clear()
- // FTD-21 — par défaut, pas de resume : la plupart des tests partent de idle.
- mockUpdateSujet.mockResolvedValue(undefined)
-})
-
-describe('useSimulation — état initial', () => {
- it('step = idle, production null, report null', () => {
- const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
- expect(result.current.step).toBe('idle')
- expect(result.current.production).toBeNull()
- expect(result.current.report).toBeNull()
- })
-})
-
-describe('useSimulation — selectTask', () => {
- it('step passe à choosing-subject et production est hydratée pour une tâche avec catalogue', async () => {
- mockCreateSimulation.mockResolvedValue(mockProduction)
-
- const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
- })
-
- await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- expect(result.current.production).toEqual(mockProduction)
- })
-
- it('Sprint 4c-2 — selectTask EO_T1 crée la simulation et passe à task-selected (sans catalogue)', async () => {
- // L'interception de 4c-1 est levée : EO_T1 dispose désormais d'un flux
- // dédié (/simulation/eo/t1/mode). Sans catalogue → step=task-selected.
- const eoT1Production: Production = { ...mockProduction, tache: 'EO_T1' }
- mockCreateSimulation.mockResolvedValue(eoT1Production)
-
- const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.selectTask({ tache: 'EO_T1', mode: 'entrainement' })
- })
-
- await waitFor(() => expect(result.current.step).toBe('task-selected'))
- expect(result.current.production).toEqual(eoT1Production)
- expect(mockCreateSimulation).toHaveBeenCalledTimes(1)
- })
-
- it('isCreating = true pendant la mutation createSimulation', async () => {
- let resolveCreate!: (p: Production) => void
- mockCreateSimulation.mockImplementation(
- () =>
- new Promise((r) => {
- resolveCreate = r
- }),
- )
-
- const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.selectTask({ tache: 'EE_T2', mode: 'entrainement' })
- })
-
- await waitFor(() => expect(result.current.isCreating).toBe(true))
-
- act(() => resolveCreate(mockProduction))
- await waitFor(() => expect(result.current.isCreating).toBe(false))
- })
-})
-
-describe('useSimulation — submitText', () => {
- it('step correcting pendant la correction, puis done après succès', async () => {
- mockCreateSimulation.mockResolvedValue(mockProduction)
-
- let resolveCorrect!: (r: Report) => void
- mockCorrectEe.mockImplementation(
- () =>
- new Promise((r) => {
- resolveCorrect = r
- }),
- )
-
- const { result } = renderHook(
- () => {
- const sim = useSimulation()
- const { setStep } = useSimulationFlow()
- return { ...sim, setStep }
- },
- { wrapper: createWrapper() },
- )
-
- act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
- await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- act(() => {
- result.current.changeSubject(mockSujet)
- result.current.setStep('task-selected')
- })
- await waitFor(() => expect(result.current.step).toBe('task-selected'))
-
- act(() => result.current.submitText('Mon texte de production.'))
- await waitFor(() => expect(result.current.step).toBe('correcting'))
-
- act(() => resolveCorrect(mockReport))
- await waitFor(() => expect(result.current.step).toBe('done'))
- expect(result.current.report).toEqual(mockReport)
- })
-
- it('step revient à task-selected si correctEe échoue', async () => {
- mockCreateSimulation.mockResolvedValue(mockProduction)
- mockCorrectEe.mockRejectedValue({ code: 'SIMULATION_NOT_FOUND', message: 'Not found' })
-
- const { result } = renderHook(
- () => {
- const sim = useSimulation()
- const { setStep } = useSimulationFlow()
- return { ...sim, setStep }
- },
- { wrapper: createWrapper() },
- )
-
- act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
- await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- act(() => {
- result.current.changeSubject(mockSujet)
- result.current.setStep('task-selected')
- })
- await waitFor(() => expect(result.current.step).toBe('task-selected'))
-
- act(() => result.current.submitText('Mon texte.'))
- await waitFor(() => expect(result.current.step).toBe('task-selected'))
- expect(result.current.report).toBeNull()
- })
-
- it('submitText sans production ne déclenche aucune mutation', async () => {
- const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
-
- act(() => result.current.submitText('texte quelconque'))
-
- expect(mockCorrectEe).not.toHaveBeenCalled()
- expect(result.current.step).toBe('idle')
- })
-})
-
-describe('useSimulation — FTD-21 resume depuis localStorage', () => {
- it('restaure step=task-selected et production hydratée si rapport=null', async () => {
- localStorage.setItem('expria_simulation_id', 'sim-42')
- mockGetSimulationState.mockResolvedValue({
- simulation_id: 'sim-42',
- tache: 'EE_T1',
- mode: 'entrainement',
- created_at: '2026-04-21T00:00:00Z',
- contenu: 'Mon brouillon.',
- sujet: mockSujet,
- rapport: null,
- nclc_cible: null,
- exercices: null,
- exercices_status: 'pending',
- modele: null,
- modele_status: 'pending',
- })
-
- const { result } = renderHook(() => useSimulation(), { wrapper: createWrapper() })
-
- await waitFor(() => expect(result.current.step).toBe('task-selected'))
- expect(mockGetSimulationState).toHaveBeenCalledWith('sim-42')
- expect(result.current.production?.id).toBe('sim-42')
- expect(result.current.production?.contenu).toBe('Mon brouillon.')
- expect(result.current.sujet).toEqual(mockSujet)
- expect(localStorage.getItem('expria_simulation_id')).toBe('sim-42')
- })
-
- it('nettoie localStorage si rapport présent (simulation déjà corrigée)', async () => {
- localStorage.setItem('expria_simulation_id', 'sim-42')
- mockGetSimulationState.mockResolvedValue({
- simulation_id: 'sim-42',
- tache: 'EE_T1',
- mode: 'entrainement',
- created_at: '2026-04-21T00:00:00Z',
- contenu: 'texte',
- sujet: null,
- rapport: {
- score: 14,
- nclc: 8,
- nclc_cible: 9,
- revelation: { croyance: '', realite: '', consequence: '' },
- diagnostic: '',
- criteres: [],
- conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
- erreurs_codes: [],
- },
- nclc_cible: 9,
- exercices: null,
- exercices_status: 'ready',
- modele: null,
- modele_status: 'ready',
- })
-
- renderHook(() => useSimulation(), { wrapper: createWrapper() })
-
- await waitFor(() => {
- expect(localStorage.getItem('expria_simulation_id')).toBeNull()
- })
- })
-
- it('nettoie localStorage si getSimulationState échoue', async () => {
- localStorage.setItem('expria_simulation_id', 'sim-missing')
- mockGetSimulationState.mockRejectedValue({ code: 'SIMULATION_NOT_FOUND' })
-
- renderHook(() => useSimulation(), { wrapper: createWrapper() })
-
- await waitFor(() => {
- expect(localStorage.getItem('expria_simulation_id')).toBeNull()
- })
- })
-})
-
-describe('useSimulation — reset', () => {
- it('reset depuis task-selected remet step à idle et production à null', async () => {
- mockCreateSimulation.mockResolvedValue(mockProduction)
-
- const { result } = renderHook(
- () => {
- const sim = useSimulation()
- const { setStep } = useSimulationFlow()
- return { ...sim, setStep }
- },
- { wrapper: createWrapper() },
- )
-
- act(() => result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' }))
- await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- act(() => {
- result.current.changeSubject(mockSujet)
- result.current.setStep('task-selected')
- })
- await waitFor(() => expect(result.current.step).toBe('task-selected'))
-
- act(() => result.current.reset())
-
- expect(result.current.step).toBe('idle')
- expect(result.current.production).toBeNull()
- })
-})
diff --git a/src/features/simulations/hooks/__tests__/useTimer.test.ts b/src/features/simulations/hooks/__tests__/useTimer.test.ts
deleted file mode 100644
index 6bf97eb..0000000
--- a/src/features/simulations/hooks/__tests__/useTimer.test.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * Tests du hook useTimer — logique de décompte critique (auto-submit à l'expiration).
- */
-
-import { act, renderHook } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { useTimer } from '../useTimer'
-
-describe('useTimer', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- })
-
- afterEach(() => {
- vi.useRealTimers()
- })
-
- it('initialise secondesRestantes à dureeMinutes × 60', () => {
- const { result } = renderHook(() => useTimer(10, true))
- expect(result.current.secondesRestantes).toBe(600)
- expect(result.current.isExpired).toBe(false)
- })
-
- it('décrémente de 1 par seconde quand active=true', () => {
- const { result } = renderHook(() => useTimer(1, true))
- expect(result.current.secondesRestantes).toBe(60)
-
- act(() => {
- vi.advanceTimersByTime(1000)
- })
- expect(result.current.secondesRestantes).toBe(59)
-
- act(() => {
- vi.advanceTimersByTime(3000)
- })
- expect(result.current.secondesRestantes).toBe(56)
- })
-
- it('ne décrémente pas quand active=false', () => {
- const { result } = renderHook(() => useTimer(1, false))
- expect(result.current.secondesRestantes).toBe(60)
-
- act(() => {
- vi.advanceTimersByTime(5000)
- })
- expect(result.current.secondesRestantes).toBe(60)
- expect(result.current.isExpired).toBe(false)
- })
-
- it('passe isExpired=true quand secondesRestantes atteint 0', () => {
- const { result } = renderHook(() => useTimer(1 / 60, true)) // 1 seconde
- expect(result.current.secondesRestantes).toBe(1)
-
- act(() => {
- vi.advanceTimersByTime(1000)
- })
- expect(result.current.secondesRestantes).toBe(0)
- expect(result.current.isExpired).toBe(true)
- })
-
- it("n'évolue pas sous zéro", () => {
- const { result } = renderHook(() => useTimer(1 / 60, true)) // 1 seconde
- act(() => {
- vi.advanceTimersByTime(5000)
- })
- expect(result.current.secondesRestantes).toBe(0)
- expect(result.current.isExpired).toBe(true)
- })
-
- it('reset() restaure la valeur initiale', () => {
- const { result } = renderHook(() => useTimer(1, true))
- act(() => {
- vi.advanceTimersByTime(10000)
- })
- expect(result.current.secondesRestantes).toBe(50)
-
- act(() => {
- result.current.reset()
- })
- expect(result.current.secondesRestantes).toBe(60)
- expect(result.current.isExpired).toBe(false)
- })
-
- it('reprend le décompte quand active passe de false à true sans reset', () => {
- const { result, rerender } = renderHook(({ active }) => useTimer(1, active), {
- initialProps: { active: false },
- })
-
- act(() => {
- vi.advanceTimersByTime(3000)
- })
- expect(result.current.secondesRestantes).toBe(60)
-
- rerender({ active: true })
- act(() => {
- vi.advanceTimersByTime(2000)
- })
- expect(result.current.secondesRestantes).toBe(58)
- })
-})
diff --git a/src/features/simulations/hooks/useAudioRecorder.ts b/src/features/simulations/hooks/useAudioRecorder.ts
deleted file mode 100644
index d636a3b..0000000
--- a/src/features/simulations/hooks/useAudioRecorder.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-/**
- * Hook MediaRecorder pour les productions orales — Sprint 4c-1.
- *
- * Capture le micro via getUserMedia + MediaRecorder, expose un timer montant
- * et un Blob webm/opus à l'arrêt. Permet aussi de s'abonner aux chunks
- * (timeslice 250 ms) pour streamer en parallèle vers Deepgram.
- *
- * Compat : préfère `audio/webm;codecs=opus`, fallback `audio/webm`, puis
- * `audio/mp4` (Safari iOS — cf. FTD audio iOS).
- *
- * Le hook ne stocke pas l'audio côté serveur — la sauvegarde locale via
- * `downloadAudio` est une commodité utilisateur (cf. Sprint 4b backend).
- */
-
-import { useCallback, useEffect, useRef, useState } from 'react'
-
-export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'stopped' | 'error'
-
-export interface UseAudioRecorderOptions {
- /**
- * Sprint 4b.3 — durée maximale d'enregistrement en secondes. Quand
- * `elapsedSeconds` atteint cette valeur, le hook stoppe automatiquement
- * le MediaRecorder et appelle `onMaxReached` une fois.
- */
- maxSeconds?: number
- onMaxReached?: () => void
-}
-
-export interface UseAudioRecorderResult {
- status: RecorderStatus
- elapsedSeconds: number
- audioBlob: Blob | null
- audioMimeType: string | null
- /**
- * Sprint 4.6 — flux micro actif pendant l'enregistrement, exposé pour
- * permettre au visualizer (waveform) d'attacher un AnalyserNode. `null`
- * tant que le micro n'est pas démarré, et après cleanup.
- */
- mediaStream: MediaStream | null
- error: string | null
- permissionDenied: boolean
- start: () => Promise
- stop: () => void
- cancel: () => void
- downloadAudio: (filename: string) => void
- /** S'abonne aux chunks (timeslice). Retourne un unsubscribe. */
- subscribeChunks: (cb: (chunk: Blob) => void) => () => void
-}
-
-/** Choisit le mimeType supporté par le navigateur, par ordre de préférence. */
-function pickMimeType(): string | null {
- if (typeof MediaRecorder === 'undefined') return null
- const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']
- for (const m of candidates) {
- if (MediaRecorder.isTypeSupported(m)) return m
- }
- return null
-}
-
-const TIMESLICE_MS = 250
-
-export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudioRecorderResult {
- const [status, setStatus] = useState('idle')
- const [elapsedSeconds, setElapsedSeconds] = useState(0)
- const [audioBlob, setAudioBlob] = useState(null)
- const [audioMimeType, setAudioMimeType] = useState(null)
- const [error, setError] = useState(null)
- const [permissionDenied, setPermissionDenied] = useState(false)
- const [mediaStream, setMediaStream] = useState(null)
-
- const recorderRef = useRef(null)
- const streamRef = useRef(null)
- const chunksRef = useRef([])
- const timerRef = useRef | null>(null)
- const subscribersRef = useRef void>>(new Set())
-
- // Capture options dans une ref pour éviter de réabonner les effets sur
- // chaque render (les callers fournissent souvent des fonctions inline).
- const optionsRef = useRef(options)
- useEffect(() => {
- optionsRef.current = options
- })
- const maxReachedFiredRef = useRef(false)
-
- const cleanupTimer = useCallback(() => {
- if (timerRef.current !== null) {
- clearInterval(timerRef.current)
- timerRef.current = null
- }
- }, [])
-
- const cleanupStream = useCallback(() => {
- streamRef.current?.getTracks().forEach((t) => t.stop())
- streamRef.current = null
- setMediaStream(null)
- }, [])
-
- const start = useCallback(async () => {
- if (status === 'recording' || status === 'requesting') return
-
- setError(null)
- setPermissionDenied(false)
- setAudioBlob(null)
- setElapsedSeconds(0)
- chunksRef.current = []
- setStatus('requesting')
-
- if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
- setError('Votre navigateur ne supporte pas la capture audio.')
- setStatus('error')
- return
- }
-
- let stream: MediaStream
- try {
- stream = await navigator.mediaDevices.getUserMedia({ audio: true })
- } catch (err) {
- const name = err instanceof Error ? err.name : ''
- if (name === 'NotAllowedError' || name === 'SecurityError') {
- setPermissionDenied(true)
- setError("L'accès au micro est refusé. Autorisez-le dans les réglages du navigateur.")
- } else {
- setError("Impossible d'accéder au micro. Vérifiez vos périphériques.")
- }
- setStatus('error')
- return
- }
- streamRef.current = stream
- setMediaStream(stream)
-
- const mimeType = pickMimeType()
- if (!mimeType) {
- cleanupStream()
- setError('Aucun format audio supporté par votre navigateur.')
- setStatus('error')
- return
- }
- setAudioMimeType(mimeType)
-
- let recorder: MediaRecorder
- try {
- recorder = new MediaRecorder(stream, { mimeType })
- } catch {
- cleanupStream()
- setError("Impossible d'initialiser l'enregistreur audio.")
- setStatus('error')
- return
- }
- recorderRef.current = recorder
-
- recorder.ondataavailable = (event) => {
- if (event.data && event.data.size > 0) {
- chunksRef.current.push(event.data)
- subscribersRef.current.forEach((cb) => cb(event.data))
- }
- }
- recorder.onstop = () => {
- const blob = new Blob(chunksRef.current, { type: mimeType })
- setAudioBlob(blob)
- cleanupStream()
- cleanupTimer()
- setStatus('stopped')
- }
- recorder.onerror = () => {
- cleanupStream()
- cleanupTimer()
- setError("L'enregistrement a échoué.")
- setStatus('error')
- }
-
- recorder.start(TIMESLICE_MS)
- setStatus('recording')
- maxReachedFiredRef.current = false
-
- timerRef.current = setInterval(() => {
- setElapsedSeconds((s) => {
- const next = s + 1
- const max = optionsRef.current.maxSeconds
- // Cap visuel à `max` et arrête d'incrémenter au-delà. L'auto-stop
- // est déclenché par l'effet observant `elapsedSeconds`.
- return max && next >= max ? max : next
- })
- }, 1000)
- }, [status, cleanupStream, cleanupTimer])
-
- const stop = useCallback(() => {
- // Arrêter le timer SYNCHRONE — sinon il continue d'incrémenter pendant
- // les ~50-200 ms entre l'appel à `recorder.stop()` et la réception du
- // callback `onstop` (qui appelle aussi cleanupTimer en sécurité).
- cleanupTimer()
- const recorder = recorderRef.current
- if (recorder && recorder.state !== 'inactive') {
- recorder.stop()
- }
- }, [cleanupTimer])
-
- const cancel = useCallback(() => {
- const recorder = recorderRef.current
- if (recorder && recorder.state !== 'inactive') {
- // Vide les chunks AVANT le stop pour produire un blob nul.
- chunksRef.current = []
- recorder.stop()
- }
- cleanupStream()
- cleanupTimer()
- setStatus('idle')
- setElapsedSeconds(0)
- setAudioBlob(null)
- }, [cleanupStream, cleanupTimer])
-
- const downloadAudio = useCallback(
- (filename: string) => {
- if (!audioBlob) return
- const url = URL.createObjectURL(audioBlob)
- const a = document.createElement('a')
- a.href = url
- a.download = filename
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- },
- [audioBlob],
- )
-
- const subscribeChunks = useCallback((cb: (chunk: Blob) => void) => {
- subscribersRef.current.add(cb)
- return () => {
- subscribersRef.current.delete(cb)
- }
- }, [])
-
- // Sprint 4b.3 — auto-stop à expiration de la durée recommandée.
- // Quand le timer atteint `maxSeconds`, on stoppe le MediaRecorder (ce qui
- // déclenche `onstop` → audioBlob, status='stopped') et on notifie le caller
- // une seule fois via `onMaxReached`. Le composant parent peut câbler son
- // onSubmit sur le passage en status='stopped' (cf. AudioRecorder).
- useEffect(() => {
- if (status !== 'recording') return
- const max = optionsRef.current.maxSeconds
- if (!max || elapsedSeconds < max) return
- if (maxReachedFiredRef.current) return
- maxReachedFiredRef.current = true
- cleanupTimer()
- const recorder = recorderRef.current
- if (recorder && recorder.state !== 'inactive') {
- recorder.stop()
- }
- optionsRef.current.onMaxReached?.()
- }, [elapsedSeconds, status, cleanupTimer])
-
- // Cleanup global au démontage : libère le micro même si l'utilisateur
- // navigue ailleurs sans cliquer sur Stop ou Annuler.
- useEffect(() => {
- return () => {
- cleanupTimer()
- const recorder = recorderRef.current
- if (recorder && recorder.state !== 'inactive') {
- try {
- recorder.stop()
- } catch {
- /* noop */
- }
- }
- cleanupStream()
- }
- }, [cleanupStream, cleanupTimer])
-
- return {
- status,
- elapsedSeconds,
- audioBlob,
- audioMimeType,
- mediaStream,
- error,
- permissionDenied,
- start,
- stop,
- cancel,
- downloadAudio,
- subscribeChunks,
- }
-}
diff --git a/src/features/simulations/hooks/useAutosave.ts b/src/features/simulations/hooks/useAutosave.ts
deleted file mode 100644
index 4e3d9bd..0000000
--- a/src/features/simulations/hooks/useAutosave.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * FTD-21 — autosave du contenu d'une simulation en cours.
- *
- * Debounce 30 s sur chaque changement de `contenu`. Flush immédiat au
- * `beforeunload` (best-effort : la promesse peut ne pas aboutir si la page
- * se ferme avant la réponse — le texte reste en `localStorage` côté texte
- * utilisateur + state parent).
- *
- * Dédoublonnage : aucun appel réseau si le contenu n'a pas changé depuis
- * le dernier save réussi.
- *
- * Règle H : pas de logique métier — wrap autour de `entities/production/api.autosaveContenu`.
- */
-
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { autosaveContenu } from '@/entities/production/api'
-
-const DEBOUNCE_MS = 30_000
-
-export interface UseAutosaveResult {
- savedAt: Date | null
- isSaving: boolean
-}
-
-export function useAutosave(
- simulationId: string | null,
- contenu: string,
- enabled: boolean,
-): UseAutosaveResult {
- const [savedAt, setSavedAt] = useState(null)
- const [isSaving, setIsSaving] = useState(false)
- const lastSavedContenuRef = useRef(null)
-
- const latestRef = useRef({ simulationId, contenu, enabled })
- latestRef.current = { simulationId, contenu, enabled }
-
- const flush = useCallback(async () => {
- const { simulationId, contenu, enabled } = latestRef.current
- if (!enabled || !simulationId || !contenu) return
- if (lastSavedContenuRef.current === contenu) return
-
- setIsSaving(true)
- try {
- await autosaveContenu(simulationId, contenu)
- lastSavedContenuRef.current = contenu
- setSavedAt(new Date())
- } catch {
- // best-effort — pas d'UI d'erreur, le texte reste en mémoire côté client
- } finally {
- setIsSaving(false)
- }
- }, [])
-
- useEffect(() => {
- if (!enabled || !simulationId || !contenu) return
- if (lastSavedContenuRef.current === contenu) return
- const timer = setTimeout(() => {
- void flush()
- }, DEBOUNCE_MS)
- return () => clearTimeout(timer)
- }, [simulationId, contenu, enabled, flush])
-
- useEffect(() => {
- const handler = () => {
- void flush()
- }
- window.addEventListener('beforeunload', handler)
- return () => window.removeEventListener('beforeunload', handler)
- }, [flush])
-
- return { savedAt, isSaving }
-}
diff --git a/src/features/simulations/hooks/useDeepgramLive.ts b/src/features/simulations/hooks/useDeepgramLive.ts
deleted file mode 100644
index 6bcb75f..0000000
--- a/src/features/simulations/hooks/useDeepgramLive.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-/**
- * Hook de transcription live Deepgram — Sprint 4c-1.
- *
- * Demande un token éphémère au backend (`POST /transcriptions/token`),
- * ouvre une connexion WebSocket directe vers Deepgram, expose le
- * transcript final accumulé + l'interim en cours.
- *
- * Rotation de token : Deepgram fournit un token valide ~10 min. On
- * redemande un nouveau token à T-60 s avant expiration et on bascule
- * la WebSocket en hot-swap (open new → ferme l'ancienne). Pendant le
- * gap (typiquement < 200 ms), des chunks peuvent être perdus —
- * acceptable au MVP, durci en Sprint 4c-2 (FTD à tracer).
- *
- * Paramètres Deepgram (cf. consigne Hermann) :
- * language=fr, model=nova-2, smart_format=true,
- * interim_results=true, punctuate=true.
- */
-
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { requestDeepgramToken } from '@/entities/transcription/api'
-
-const DEEPGRAM_BASE = 'wss://api.deepgram.com/v1/listen'
-const DEEPGRAM_QUERY =
- 'language=fr&model=nova-2&smart_format=true&interim_results=true&punctuate=true'
-const ROTATION_LEAD_SECONDS = 60
-
-export type DeepgramStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
-
-export interface UseDeepgramLiveResult {
- status: DeepgramStatus
- /** Transcript final accumulé (chaque segment is_final=true ajouté). */
- transcript: string
- /** Buffer interim courant (segment is_final=false en attente). */
- interim: string
- isConnected: boolean
- error: string | null
- connect: () => Promise
- sendChunk: (chunk: Blob) => void
- close: () => void
-}
-
-interface DeepgramMessage {
- channel?: { alternatives?: { transcript?: string }[] }
- is_final?: boolean
- type?: string
-}
-
-export function useDeepgramLive(): UseDeepgramLiveResult {
- const [status, setStatus] = useState('idle')
- const [transcript, setTranscript] = useState('')
- const [interim, setInterim] = useState('')
- const [error, setError] = useState(null)
-
- const wsRef = useRef(null)
- const rotationTimerRef = useRef | null>(null)
- const pendingChunksRef = useRef([])
-
- const clearRotationTimer = useCallback(() => {
- if (rotationTimerRef.current !== null) {
- clearTimeout(rotationTimerRef.current)
- rotationTimerRef.current = null
- }
- }, [])
-
- const wireWs = useCallback((ws: WebSocket) => {
- ws.onopen = () => {
- setStatus('open')
- // Vider le buffer FIFO — chunks accumulés pendant `connecting`.
- const pending = pendingChunksRef.current
- pendingChunksRef.current = []
- for (const chunk of pending) {
- if (ws.readyState === WebSocket.OPEN) ws.send(chunk)
- }
- }
- ws.onmessage = (event) => {
- try {
- const data = JSON.parse(event.data as string) as DeepgramMessage
- const text = data.channel?.alternatives?.[0]?.transcript ?? ''
- if (!text) return
- if (data.is_final) {
- setTranscript((prev) => (prev ? `${prev} ${text}` : text))
- setInterim('')
- } else {
- setInterim(text)
- }
- } catch {
- /* messages non-JSON ignorés (keep-alive, metadata) */
- }
- }
- ws.onerror = () => {
- setError('Erreur de connexion à la transcription.')
- setStatus('error')
- }
- ws.onclose = () => {
- // Ne pas écraser un état 'error' déjà posé.
- setStatus((s) => (s === 'error' ? s : 'closed'))
- }
- }, [])
-
- const openConnection = useCallback(async (): Promise => {
- const { token, expires_in } = await requestDeepgramToken()
- // Le navigateur ne permet pas de header custom à l'init d'une WebSocket :
- // Deepgram accepte le JWT via Sec-WebSocket-Protocol en passant
- // ['token', ''] comme sous-protocoles. Ne PAS mettre le token dans
- // l'URL — l'auth via query string est rejetée pour les tokens éphémères
- // (cf. doc Deepgram « WebSocket authentication »).
- const url = `${DEEPGRAM_BASE}?${DEEPGRAM_QUERY}`
- const ws = new WebSocket(url, ['token', token])
- wireWs(ws)
-
- // Programmer la rotation de token avant expiration.
- const leadMs = Math.max((expires_in - ROTATION_LEAD_SECONDS) * 1000, 5_000)
- clearRotationTimer()
- rotationTimerRef.current = setTimeout(() => {
- void rotateToken()
- }, leadMs)
-
- return ws
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [wireWs, clearRotationTimer])
-
- // Hot-swap : ouvre une nouvelle WS avec un nouveau token, attend 'open',
- // puis ferme l'ancienne. Si l'ouverture échoue, on garde l'ancienne.
- const rotateToken = useCallback(async () => {
- const oldWs = wsRef.current
- try {
- const newWs = await openConnection()
- const swap = () => {
- wsRef.current = newWs
- if (oldWs && oldWs.readyState === WebSocket.OPEN) {
- try {
- oldWs.send(JSON.stringify({ type: 'CloseStream' }))
- } catch {
- /* noop */
- }
- oldWs.close()
- }
- }
- if (newWs.readyState === WebSocket.OPEN) {
- swap()
- } else {
- const onOpen = () => {
- newWs.removeEventListener('open', onOpen)
- swap()
- }
- newWs.addEventListener('open', onOpen)
- }
- } catch {
- // Conserver l'ancienne connexion ; nouvelle tentative à la prochaine échéance.
- // FTD-XX : durcir avec une retry policy plus fine en Sprint 4c-2.
- }
- }, [openConnection])
-
- const connect = useCallback(async () => {
- if (status === 'connecting' || status === 'open') return
- setError(null)
- setTranscript('')
- setInterim('')
- pendingChunksRef.current = []
- setStatus('connecting')
- try {
- const ws = await openConnection()
- wsRef.current = ws
- } catch (err) {
- const message = err instanceof Error ? err.message : "Erreur d'initialisation."
- setError(message)
- setStatus('error')
- }
- }, [status, openConnection])
-
- const sendChunk = useCallback((chunk: Blob) => {
- const ws = wsRef.current
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.send(chunk)
- return
- }
- // FIFO borné — évite la fuite mémoire si la WS reste closed.
- const buf = pendingChunksRef.current
- buf.push(chunk)
- if (buf.length > 5) buf.shift()
- }, [])
-
- const close = useCallback(() => {
- clearRotationTimer()
- const ws = wsRef.current
- if (ws) {
- try {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'CloseStream' }))
- }
- ws.close()
- } catch {
- /* noop */
- }
- wsRef.current = null
- }
- setStatus('closed')
- }, [clearRotationTimer])
-
- // Cleanup global — coupe la WS et annule la rotation au démontage.
- useEffect(() => {
- return () => {
- clearRotationTimer()
- const ws = wsRef.current
- if (ws) {
- try {
- ws.close()
- } catch {
- /* noop */
- }
- }
- }
- }, [clearRotationTimer])
-
- return {
- status,
- transcript,
- interim,
- isConnected: status === 'open',
- error,
- connect,
- sendChunk,
- close,
- }
-}
diff --git a/src/features/simulations/hooks/useIdees.ts b/src/features/simulations/hooks/useIdees.ts
deleted file mode 100644
index d0cd75a..0000000
--- a/src/features/simulations/hooks/useIdees.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * Hook — suggestions d'idées DeepSeek pour prolonger une rédaction en cours.
- *
- * Tâche G5 : mutation sur POST /sujets/idees. Retourne les idées, l'état
- * de chargement, l'erreur API et un `reset()` pour vider le cache de la
- * mutation (appelé à la fermeture du modal côté composant).
- *
- * Règle H : aucune logique métier — la garde `hasAccess(plan, 'tips')`
- * est appliquée dans SimulationForm (UX), jamais ici.
- */
-
-import { useMutation } from '@tanstack/react-query'
-import { getIdees } from '@/entities/report/api'
-import type { ApiError } from '@/shared/types/api'
-
-interface FetchIdeesVariables {
- consigne: string
- contenu: string
-}
-
-export function useIdees() {
- const mutation = useMutation({
- mutationFn: ({ consigne, contenu }) => getIdees(consigne, contenu),
- })
-
- return {
- idees: mutation.data ?? null,
- isLoading: mutation.isPending,
- error: mutation.error as ApiError | null,
- fetchIdees: (variables: FetchIdeesVariables) => mutation.mutate(variables),
- reset: mutation.reset,
- }
-}
diff --git a/src/features/simulations/hooks/useRapport.ts b/src/features/simulations/hooks/useRapport.ts
deleted file mode 100644
index 8a2c384..0000000
--- a/src/features/simulations/hooks/useRapport.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * Hook de récupération d'un rapport de correction.
- *
- * Appelle GET /simulations/:id (cache TanStack Query).
- * staleTime Infinity : un rapport figé ne doit pas être refetché au focus.
- *
- * FTD-24 — Polling automatique quand les jobs backend (exercices, modèle)
- * sont encore `pending` après la réponse de correction :
- * - refetchInterval : 3 s tant qu'un des deux statuts vaut 'pending'.
- * - Arrêt automatique dès que les deux statuts sortent de 'pending'
- * (ready ou error).
- * - Timeout global : 2 min de polling actif → `hasTimedOut = true`,
- * le polling s'arrête et l'UI propose un bouton Réessayer qui
- * réinitialise le flag et relance un refetch.
- *
- * Règle H : aucune logique métier — expose les données brutes + flags UI.
- */
-
-import { useCallback, useEffect, useState } from 'react'
-import { useQuery } from '@tanstack/react-query'
-import { getReport } from '@/entities/report/api'
-import type { Report } from '@/entities/report/types'
-
-const POLL_INTERVAL_MS = 3000
-const POLL_TIMEOUT_MS = 120_000
-
-function hasPendingJob(report: Report | undefined): boolean {
- if (!report) return false
- return report.exercices_status === 'pending' || report.modele_status === 'pending'
-}
-
-export function useRapport(id: string) {
- const [hasTimedOut, setHasTimedOut] = useState(false)
-
- const query = useQuery({
- queryKey: ['rapport', id],
- queryFn: () => getReport(id),
- enabled: Boolean(id),
- staleTime: Infinity,
- refetchInterval: (q) => {
- if (hasTimedOut) return false
- return hasPendingJob(q.state.data) ? POLL_INTERVAL_MS : false
- },
- })
-
- const isPolling = !hasTimedOut && hasPendingJob(query.data)
-
- // Timer 2 min armé au démarrage du polling, clear dès qu'il s'arrête.
- useEffect(() => {
- if (!isPolling) return
- const timer = setTimeout(() => setHasTimedOut(true), POLL_TIMEOUT_MS)
- return () => clearTimeout(timer)
- }, [isPolling])
-
- const refetch = useCallback(async () => {
- setHasTimedOut(false)
- await query.refetch()
- }, [query])
-
- return {
- rapport: query.data,
- isLoading: query.isLoading,
- isError: query.isError,
- error: query.error,
- refetch,
- isPolling,
- hasTimedOut,
- }
-}
diff --git a/src/features/simulations/hooks/useSimulation.ts b/src/features/simulations/hooks/useSimulation.ts
deleted file mode 100644
index 5310bee..0000000
--- a/src/features/simulations/hooks/useSimulation.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Hook d'orchestration du flux simulation EE — consommateur de SimulationFlowProvider.
- *
- * Depuis la refonte /sujets (Option A), l'état vit dans le Provider pour survivre
- * aux navigations entre /simulation/ee et /sujets. Ce hook ajoute la navigation
- * vers /sujets après création d'une simulation pour une tâche avec catalogue.
- *
- * Règle H : aucune logique métier — les gardes de quota et de plan sont dans
- * TaskSelector (UX) et dans le backend (autorité).
- */
-
-import { useNavigate } from 'react-router-dom'
-import { useSimulationFlow } from '../state/simulationFlow'
-
-export function useSimulation() {
- const navigate = useNavigate()
- const flow = useSimulationFlow()
-
- /** Retour à /sujets depuis SimulationForm (bouton "Changer de sujet"). */
- function goToSubjectPicker(): void {
- flow.setStep('choosing-subject')
- navigate('/sujets')
- }
-
- return {
- step: flow.step,
- production: flow.production,
- sujet: flow.sujet,
- report: flow.report,
- isCreating: flow.isCreating,
- isCorrecting: flow.isCorrecting,
- createError: flow.createError,
- correctError: flow.correctError,
- selectTask: flow.selectTask,
- submitText: flow.submitText,
- changeSubject: flow.changeSubject,
- goToSubjectPicker,
- reset: flow.reset,
- }
-}
diff --git a/src/features/simulations/hooks/useSujets.ts b/src/features/simulations/hooks/useSujets.ts
deleted file mode 100644
index aa13364..0000000
--- a/src/features/simulations/hooks/useSujets.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Hook de chargement des sujets disponibles pour une tâche.
- *
- * Règle H : aucune logique métier ici — mapping Tache → filtres DB dans
- * `entities/production/api.ts` (getSujets). Le hook ne fait qu'encapsuler
- * la requête TanStack Query avec un staleTime généreux (le catalogue de
- * sujets change rarement).
- */
-
-import { useQuery } from '@tanstack/react-query'
-import { getSujets } from '@/entities/production/api'
-import type { SujetData, Tache } from '@/entities/production/types'
-
-export function useSujets(tache: Tache, enabled: boolean = true) {
- return useQuery({
- queryKey: ['sujets', tache],
- queryFn: () => getSujets(tache),
- staleTime: 10 * 60 * 1000,
- enabled,
- })
-}
diff --git a/src/features/simulations/hooks/useTimer.ts b/src/features/simulations/hooks/useTimer.ts
deleted file mode 100644
index 1d5d7d8..0000000
--- a/src/features/simulations/hooks/useTimer.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * Hook de décompte en secondes piloté par un flag `active`.
- *
- * Règle H : logique isolée du composant, testable unitairement.
- *
- * Comportement :
- * - Initialise `secondesRestantes` à `dureeMinutes * 60`.
- * - Décrémente de 1 chaque seconde tant que `active === true` et non expiré.
- * - `isExpired` passe à true lorsque `secondesRestantes` atteint 0.
- * - `reset()` restaure la valeur initiale et relance le décompte si `active`.
- */
-
-import { useCallback, useEffect, useRef, useState } from 'react'
-
-export interface TimerState {
- secondesRestantes: number
- isExpired: boolean
- reset: () => void
-}
-
-export function useTimer(dureeMinutes: number, active: boolean): TimerState {
- const dureeSecondes = Math.max(0, Math.floor(dureeMinutes * 60))
- const [secondesRestantes, setSecondesRestantes] = useState(dureeSecondes)
- const dureeRef = useRef(dureeSecondes)
- useEffect(() => {
- dureeRef.current = dureeSecondes
- }, [dureeSecondes])
-
- useEffect(() => {
- if (!active) return
- if (secondesRestantes <= 0) return
-
- const id = setInterval(() => {
- setSecondesRestantes((prev) => (prev <= 0 ? 0 : prev - 1))
- }, 1000)
-
- return () => clearInterval(id)
- }, [active, secondesRestantes])
-
- const reset = useCallback(() => {
- setSecondesRestantes(dureeRef.current)
- }, [])
-
- return {
- secondesRestantes,
- isExpired: secondesRestantes <= 0,
- reset,
- }
-}
diff --git a/src/features/simulations/lib/__tests__/simulationConfig.test.ts b/src/features/simulations/lib/__tests__/simulationConfig.test.ts
deleted file mode 100644
index 241b40e..0000000
--- a/src/features/simulations/lib/__tests__/simulationConfig.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { countWords, formatTimer, getSimulationConfig, isEOTache } from '../simulationConfig'
-
-describe('simulationConfig — Sprint 4c-1', () => {
- it('EO_T1 : durée recommandée 120 s, min 30 s', () => {
- const c = getSimulationConfig('EO_T1')
- expect(c.dureeRecommandeeSecondes).toBe(120)
- expect(c.enregistrementMinSecondes).toBe(30)
- })
-
- it('EO_T3 : durée recommandée 270 s (4 min 30), min 30 s', () => {
- const c = getSimulationConfig('EO_T3')
- expect(c.dureeRecommandeeSecondes).toBe(270)
- expect(c.enregistrementMinSecondes).toBe(30)
- })
-
- it('EE_T1 : aucune durée recommandée (champ EO uniquement)', () => {
- const c = getSimulationConfig('EE_T1')
- expect(c.dureeRecommandeeSecondes).toBeUndefined()
- expect(c.enregistrementMinSecondes).toBeUndefined()
- })
-
- it('isEOTache distingue EO de EE', () => {
- expect(isEOTache('EO_T1')).toBe(true)
- expect(isEOTache('EO_T3')).toBe(true)
- expect(isEOTache('EE_T1')).toBe(false)
- expect(isEOTache('EE_T2')).toBe(false)
- expect(isEOTache('EE_T3')).toBe(false)
- })
-
- it('formatTimer pad correctement MM:SS', () => {
- expect(formatTimer(0)).toBe('00:00')
- expect(formatTimer(59)).toBe('00:59')
- expect(formatTimer(60)).toBe('01:00')
- expect(formatTimer(270)).toBe('04:30')
- })
-
- it('countWords sur transcript oral (espaces multiples ignorés)', () => {
- expect(countWords('')).toBe(0)
- expect(countWords(' ')).toBe(0)
- expect(countWords('un mot')).toBe(2)
- expect(countWords("Bonjour je m'appelle Pierre")).toBe(4)
- })
-})
diff --git a/src/features/simulations/lib/simulationConfig.ts b/src/features/simulations/lib/simulationConfig.ts
deleted file mode 100644
index 610560e..0000000
--- a/src/features/simulations/lib/simulationConfig.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Configuration par tâche de simulation : durée et cibles de mots.
- *
- * Règle H : logique métier regroupée ici, jamais dans les composants UI.
- *
- * Les valeurs EE viennent des consignes TCF Canada officielles.
- * Les valeurs EO sont symboliques (placeholders) — seront ajustées quand
- * les flux EO seront implémentés côté frontend.
- */
-
-import type { Tache } from '@/entities/production/types'
-
-export interface SimulationConfig {
- /** Durée du minuteur EE en minutes. Pour EO, durée informative non bloquante. */
- dureeMinutes: number
- /** Seuil minimum de mots EE. Non utilisé pour EO. */
- motsMin: number
- /** Borne basse de la cible TCF (mots). EE uniquement. */
- motsCibleMin: number
- /** Borne haute de la cible TCF (mots). EE uniquement. */
- motsCibleMax: number
- /**
- * EO uniquement — durée recommandée d'enregistrement, en secondes.
- * Affichée comme repère pédagogique, sans coupure automatique.
- */
- dureeRecommandeeSecondes?: number
- /**
- * EO uniquement — durée minimale d'enregistrement avant que la soumission
- * soit autorisée (sécurité contre les soumissions vides).
- */
- enregistrementMinSecondes?: number
-}
-
-const EO_MIN_RECORDING_SECONDS = 30
-
-const SIMULATION_CONFIG: Record = {
- EE_T1: { dureeMinutes: 10, motsMin: 30, motsCibleMin: 60, motsCibleMax: 120 },
- EE_T2: { dureeMinutes: 20, motsMin: 30, motsCibleMin: 120, motsCibleMax: 150 },
- EE_T3: { dureeMinutes: 30, motsMin: 30, motsCibleMin: 120, motsCibleMax: 180 },
- EO_T1: {
- dureeMinutes: 2,
- motsMin: 0,
- motsCibleMin: 0,
- motsCibleMax: 0,
- dureeRecommandeeSecondes: 120,
- enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
- },
- EO_T2_LIVE: {
- dureeMinutes: 6,
- motsMin: 0,
- motsCibleMin: 0,
- motsCibleMax: 0,
- // 2 min prépa + 3 min 30 dialogue (Sprint 6c).
- dureeRecommandeeSecondes: 330,
- enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
- },
- EO_T3: {
- dureeMinutes: 5,
- motsMin: 0,
- motsCibleMin: 0,
- motsCibleMax: 0,
- dureeRecommandeeSecondes: 270,
- enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
- },
-}
-
-/** True si la tâche est une production orale (EO_T1 ou EO_T3). */
-export function isEOTache(tache: Tache): boolean {
- return tache.startsWith('EO_')
-}
-
-export function getSimulationConfig(tache: Tache): SimulationConfig {
- return SIMULATION_CONFIG[tache]
-}
-
-/**
- * Compte les mots d'un texte en découpant sur les espaces / caractères blancs.
- * Un "mot" = toute séquence non-blanche. Retourne 0 si le texte est vide.
- */
-export function countWords(texte: string): number {
- const trimmed = texte.trim()
- if (trimmed.length === 0) return 0
- return trimmed.split(/\s+/).length
-}
-
-/** Formate un nombre de secondes en MM:SS (ex. 125 → "02:05"). */
-export function formatTimer(secondes: number): string {
- const safe = Math.max(0, Math.floor(secondes))
- const mm = Math.floor(safe / 60)
- .toString()
- .padStart(2, '0')
- const ss = (safe % 60).toString().padStart(2, '0')
- return `${mm}:${ss}`
-}
diff --git a/src/features/simulations/lib/t1Questionnaire.ts b/src/features/simulations/lib/t1Questionnaire.ts
deleted file mode 100644
index 616c7eb..0000000
--- a/src/features/simulations/lib/t1Questionnaire.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * t1Questionnaire — définitions partagées du questionnaire Tâche 1 EO.
- *
- * Source UNIQUE des 5 champs + schéma Zod, réutilisée par DEUX flux distincts :
- * - `QuestionnaireT1Page` (batch) : génère une présentation via DeepSeek ;
- * - `features/t1-live` (live, Sprint 7b) : envoie `{type:'context', reponses}`
- * au WebSocket T1 comme contexte de l'examinateur.
- *
- * PORTÉE STRICTE : ce module ne contient QUE la définition du formulaire (libellés,
- * placeholders, schéma). AUCUNE logique de génération ni de persistance — le flux
- * « présentation EO T1 » (localStorage `expria_eo_t1_presentation`, FTD-34/41)
- * reste entièrement dans `QuestionnaireT1Page` / le domaine `presentation`.
- *
- * ADR-004 : `PresentationReponses` n'est PAS redéfini ici — il est importé de
- * `@/entities/presentation/types`, déjà aligné au backend `validateReponses`
- * (mêmes champs). Le backend n'exige qu'un trim non vide ; le `max(FIELD_MAX)`
- * ci-dessous est une contrainte UX frontend (plus stricte, donc sûre).
- */
-
-import { z } from 'zod'
-import type { PresentationReponses } from '@/entities/presentation/types'
-
-export const FIELD_MAX = 500
-
-export const reponsesSchema = z.object({
- prenom_age_ville: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
- formation_metier: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
- situation_familiale: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
- loisirs: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
- motivation_canada: z.string().trim().min(1, 'Champ obligatoire.').max(FIELD_MAX),
-})
-
-export type FieldKey = keyof PresentationReponses
-
-export interface FieldDef {
- key: FieldKey
- label: string
- placeholder: string
- multiline?: boolean
-}
-
-export const T1_QUESTIONNAIRE_FIELDS: FieldDef[] = [
- {
- key: 'prenom_age_ville',
- label: "Ton prénom, âge, ville d'origine et ville où tu habites actuellement ?",
- placeholder: 'Ex : Marie, 32 ans, Douala — habite actuellement Moscou',
- },
- {
- key: 'formation_metier',
- label: 'Quelle est ta formation et ton métier actuel ou passé ?',
- placeholder: 'Ex : Master en gestion, comptable dans une PME',
- },
- {
- key: 'situation_familiale',
- label: 'Quelle est ta situation familiale ? (marié(e), enfants, etc.)',
- placeholder: 'Ex : Marié(e), 2 enfants de 5 et 8 ans',
- },
- {
- key: 'loisirs',
- label: 'Quels sont tes 2 ou 3 loisirs ou passions principales ?',
- placeholder: 'Ex : Lecture, cuisine, randonnée',
- multiline: true,
- },
- {
- key: 'motivation_canada',
- label: 'Pourquoi souhaites-tu immigrer au Canada et quand envisages-tu de partir ?',
- placeholder: 'Ex : Pour de meilleures opportunités professionnelles, départ prévu en 2025',
- multiline: true,
- },
-]
-
-export const EMPTY_REPONSES: PresentationReponses = {
- prenom_age_ville: '',
- formation_metier: '',
- situation_familiale: '',
- loisirs: '',
- motivation_canada: '',
-}
diff --git a/src/features/simulations/pages/EnregistrementEOPage.tsx b/src/features/simulations/pages/EnregistrementEOPage.tsx
deleted file mode 100644
index 41e6461..0000000
--- a/src/features/simulations/pages/EnregistrementEOPage.tsx
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * Page /simulation/eo/enregistrement — Sprint 4c-1, refondue Sprint 4c-3.
- *
- * Capture audio via `` (basé sur `useAudioRecorder`). À l'arrêt :
- * 1. Conversion du Blob en base64 via `blobToBase64`.
- * 2. Appel `submitEoAudio(base64, mimeType)` du provider.
- * 3. Le backend transcrit via Gemini batch puis corrige via DeepSeek.
- *
- * Aucun audio n'est stocké côté serveur — `` propose un
- * téléchargement local après stop pour que l'utilisateur conserve son
- * enregistrement s'il le souhaite.
- */
-
-import { useCallback, useEffect, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Loader2, Timer } from 'lucide-react'
-import { formatTache } from '@/entities/production/lib'
-import { Badge } from '@/shared/ui/Badge'
-import { blobToBase64 } from '@/shared/lib/audio'
-import { useSimulationFlow } from '../state/simulationFlow'
-import { AudioRecorder } from '../components/AudioRecorder'
-import { SujetDisplay } from '../components/SujetDisplay'
-import { getSimulationConfig, formatTimer } from '../lib/simulationConfig'
-
-export function EnregistrementEOPage() {
- const navigate = useNavigate()
- const {
- step,
- production,
- sujet,
- presentationT1,
- isCorrecting,
- correctError,
- submitEoAudio,
- reset,
- } = useSimulationFlow()
-
- // Sprint 4c-3 — `submitting` couvre la fenêtre entre le clic « Arrêter » et
- // le démarrage effectif de la mutation : conversion base64 du Blob (peut
- // prendre quelques centaines de ms sur gros enregistrements) + petit décalage
- // avant que `isCorrecting` ne passe à true.
- const [submitting, setSubmitting] = useState(false)
- const [encodingError, setEncodingError] = useState(null)
-
- // Garde-fous : refresh direct sans état → retour TaskSelector EO.
- // NOTE : on n'inclut PAS `step === 'done'` ici. Quand correctEoMutation.onSuccess
- // passe step à 'done' et navigate vers /rapport/:id, ce useEffect tirerait
- // une seconde navigation (replace) qui écraserait la première — résultat :
- // l'utilisateur reste sur /simulation/eo au lieu de voir son rapport.
- const shouldRedirect = !production || step === 'idle'
- useEffect(() => {
- if (shouldRedirect) navigate('/simulation/eo', { replace: true })
- }, [shouldRedirect, navigate])
-
- const handleSubmit = useCallback(
- async (audioBlob: Blob, audioMimeType: string | null) => {
- setEncodingError(null)
- setSubmitting(true)
- try {
- const base64 = await blobToBase64(audioBlob)
- // Normalisation du MIME : MediaRecorder produit souvent
- // `audio/webm;codecs=opus`. Le backend compare par égalité stricte
- // contre `audio/webm` / `audio/mp4` / `audio/wav` — on strip le
- // suffixe `;codecs=...` ici. Fallback `audio/webm` si vide.
- const rawMime = audioMimeType ?? 'audio/webm'
- const normalizedMime = rawMime.split(';')[0]!.trim() || 'audio/webm'
- submitEoAudio(base64, normalizedMime)
- } catch (err) {
- const message = err instanceof Error ? err.message : 'Encodage audio impossible.'
- setEncodingError(message)
- setSubmitting(false)
- }
- },
- [submitEoAudio],
- )
-
- const handleCancel = useCallback(() => {
- reset()
- navigate('/simulation/eo')
- }, [reset, navigate])
-
- if (shouldRedirect || !production) return null
-
- const config = getSimulationConfig(production.tache)
- const dureeRecommandee = config.dureeRecommandeeSecondes ?? 0
- const minSeconds = config.enregistrementMinSecondes ?? 30
-
- // Le composant AudioRecorder reste visible (pour le bouton « Télécharger
- // l'audio ») mais ses contrôles d'arrêt/annulation sont désactivés pendant
- // la soumission backend.
- const lockControls = submitting || isCorrecting
-
- return (
-
-
-
-
- {formatTache(production.tache)}
-
-
-
- Durée recommandée : {formatTimer(dureeRecommandee)}
-
-
-
- {/* T1 affiche la présentation générée comme texte de référence à lire.
- T3 affiche le sujet sélectionné. Les deux sont mutuellement exclusifs. */}
- {production.tache === 'EO_T1' && presentationT1 && (
-
-
- Ta présentation (référence)
-
-
- {presentationT1}
-
-
- )}
-
- {production.tache !== 'EO_T1' && sujet && (
-
-
-
- )}
-
-
-
- {lockControls && (
-
-
-
-
Transcription et correction en cours…
-
- Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
- automatiquement.
-
-
-
- )}
-
- {encodingError && (
-
- {encodingError}
-
- )}
-
- {correctError && !lockControls && (
-
- La correction a échoué. Réessayez dans quelques instants.
-
- )}
-
-
- )
-}
diff --git a/src/features/simulations/pages/ModeChoixT1Page.tsx b/src/features/simulations/pages/ModeChoixT1Page.tsx
deleted file mode 100644
index 3ebbdf8..0000000
--- a/src/features/simulations/pages/ModeChoixT1Page.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * Page /simulation/eo/t1/mode — Sprint 4c-2.
- *
- * Choix du mode d'entraînement pour la Tâche 1 EO :
- * a) Générer ma présentation → /simulation/eo/t1/questionnaire
- * b) Enregistrer directement → /simulation/eo/pre-enregistrement
- *
- * Garde-fou : si la simulation courante n'est pas EO_T1 → retour TaskSelector EO.
- * Aucune logique de plan ici (déjà vérifiée à la création de la simulation).
- */
-
-import { useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Mic, Sparkles } from 'lucide-react'
-import { Card } from '@/shared/ui/Card'
-import { useSimulationFlow } from '../state/simulationFlow'
-
-export function ModeChoixT1Page() {
- const navigate = useNavigate()
- const { production, step, reset } = useSimulationFlow()
-
- const shouldRedirect =
- !production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done'
-
- useEffect(() => {
- if (shouldRedirect) navigate('/simulation/eo', { replace: true })
- }, [shouldRedirect, navigate])
-
- if (shouldRedirect) return null
-
- return (
-
-
-
- {
- reset()
- navigate('/simulation/eo')
- }}
- className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
- >
- ← Retour
-
-
-
-
- Tâche 1 — Présentation personnelle
-
- Choisis ton mode d'entraînement.
-
-
-
navigate('/simulation/eo/t1/questionnaire')}
- >
-
- Générer ma présentation
-
- Réponds à 5 questions — Expria génère ton texte personnalisé que tu lis avant
- d'enregistrer.
-
-
-
-
navigate('/simulation/eo/pre-enregistrement')}
- >
-
- Enregistrer directement
-
- Tu as déjà préparé ta présentation — enregistre-toi directement sans passer par le
- formulaire.
-
-
-
-
-
- )
-}
diff --git a/src/features/simulations/pages/PreEnregistrementEOPage.tsx b/src/features/simulations/pages/PreEnregistrementEOPage.tsx
deleted file mode 100644
index c53ff0e..0000000
--- a/src/features/simulations/pages/PreEnregistrementEOPage.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * Page /simulation/eo/pre-enregistrement — Sprint 4c-1.
- *
- * Affiche le sujet sélectionné, la durée recommandée pour la tâche, des
- * instructions courtes, puis un bouton primaire qui démarre l'enregistrement.
- * Fait le lien entre `SujetsEOPage` (choix d'un sujet T3) et
- * `EnregistrementEOPage` (capture audio + transcription).
- *
- * Aucune logique de quota / plan ici : déjà vérifiée à la création.
- */
-
-import { useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Mic, Timer } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { Badge } from '@/shared/ui/Badge'
-import { formatTache } from '@/entities/production/lib'
-import { useSimulationFlow } from '../state/simulationFlow'
-import { SujetDisplay } from '../components/SujetDisplay'
-import { getSimulationConfig, formatTimer } from '../lib/simulationConfig'
-
-export function PreEnregistrementEOPage() {
- const navigate = useNavigate()
- const { step, production, sujet, setStep } = useSimulationFlow()
-
- // Garde-fous : refresh direct sans état → retour au TaskSelector EO.
- const shouldRedirect = !production || step === 'idle' || step === 'done'
- useEffect(() => {
- if (shouldRedirect) navigate('/simulation/eo', { replace: true })
- }, [shouldRedirect, navigate])
-
- if (shouldRedirect || !production) return null
-
- const config = getSimulationConfig(production.tache)
- const dureeRecommandee = config.dureeRecommandeeSecondes ?? 0
- const isT1 = production.tache === 'EO_T1'
- const isT3 = production.tache === 'EO_T3'
-
- // Sprint 4c-2 — T1 : pas de sujet pré-défini (présentation personnelle).
- // Le titre, l'encart d'instructions et l'absence du bouton « Changer de
- // sujet » diffèrent de T3.
- const heading = isT1 ? 'Tâche 1 — Présentation personnelle' : formatTache(production.tache)
-
- function handleStart() {
- setStep('recording')
- navigate('/simulation/eo/enregistrement')
- }
-
- function handleChangeSujet() {
- navigate('/simulation/eo/sujets')
- }
-
- return (
-
-
-
-
{heading}
-
-
- Durée recommandée : {formatTimer(dureeRecommandee)}
-
-
-
- {sujet && !isT1 && (
-
-
-
- )}
-
-
-
Avant de commencer
-
-
- Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.
-
- Autorisez l'accès au micro dans votre navigateur lorsque demandé.
- Parlez de manière naturelle. La durée est indicative, pas un cap.
- {isT1 && (
-
- Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
- projet d'immigration au Canada.
-
- )}
-
- Vous pourrez télécharger votre enregistrement à la fin — il n'est pas conservé sur nos
- serveurs.
-
-
-
-
-
- }
- onClick={handleStart}
- >
- Démarrer l'enregistrement
-
- {isT3 && (
-
- Changer de sujet
-
- )}
-
-
-
- )
-}
diff --git a/src/features/simulations/pages/PresentationGenereeT1Page.tsx b/src/features/simulations/pages/PresentationGenereeT1Page.tsx
deleted file mode 100644
index e664398..0000000
--- a/src/features/simulations/pages/PresentationGenereeT1Page.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-/**
- * Page /simulation/eo/t1/presentation — Sprint 4c-2.
- *
- * Affiche la présentation générée par DeepSeek et permet :
- * - Lecture (mode readonly par défaut)
- * - Édition manuelle (toggle « Modifier » / « Enregistrer les modifications »)
- * - Copier dans le presse-papier
- * - Télécharger en .txt
- * - Refaire (efface localStorage + retour questionnaire)
- *
- * Source du texte au mount, par ordre :
- * 1. `presentationT1` du provider (vient de finir le questionnaire)
- * 2. localStorage (refresh direct ou retour différé)
- * 3. Aucune → redirection /simulation/eo/t1/mode
- *
- * Les modifications manuelles sont persistées localStorage + provider.
- */
-
-import { useEffect, useMemo, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Check, Copy, Download, Mic, Pencil, RotateCcw, Save } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { useSimulationFlow } from '../state/simulationFlow'
-
-export function PresentationGenereeT1Page() {
- const navigate = useNavigate()
- const { production, step, presentationT1, setPresentationT1, setStep } = useSimulationFlow()
-
- // Garde-fou tâche EO_T1 + présence d'une présentation. Si absente, redirection
- // vers /t1/mode pour relancer un questionnaire.
- const shouldRedirect =
- !production ||
- production.tache !== 'EO_T1' ||
- step === 'idle' ||
- step === 'done' ||
- presentationT1 === null
-
- useEffect(() => {
- if (!production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done') {
- navigate('/simulation/eo', { replace: true })
- return
- }
- if (presentationT1 === null) {
- navigate('/simulation/eo/t1/mode', { replace: true })
- }
- }, [production, step, presentationT1, navigate])
-
- const [text, setText] = useState(presentationT1 ?? '')
- const [isEditing, setIsEditing] = useState(false)
- const [copied, setCopied] = useState(false)
-
- // Resync si le provider change (ex : retour depuis « Refaire » → null → re-générer).
- useEffect(() => {
- if (presentationT1 !== null && !isEditing) {
- setText(presentationT1)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [presentationT1])
-
- const downloadFilename = useMemo(
- () => `expria-presentation-t1-${(production?.id ?? 'session').slice(0, 8)}.txt`,
- [production?.id],
- )
-
- if (shouldRedirect) return null
-
- function handleCopy() {
- if (typeof navigator === 'undefined' || !navigator.clipboard) return
- void navigator.clipboard.writeText(text).then(() => {
- setCopied(true)
- setTimeout(() => setCopied(false), 1500)
- })
- }
-
- function handleDownload() {
- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = downloadFilename
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
-
- function handleToggleEdit() {
- if (isEditing) {
- // Sauvegarder les modifications.
- setPresentationT1(text)
- }
- setIsEditing((v) => !v)
- }
-
- function handleRefaire() {
- setPresentationT1(null)
- navigate('/simulation/eo/t1/questionnaire')
- }
-
- function handleStartRecording() {
- // S'assurer que le provider porte bien la dernière version du texte
- // (au cas où l'utilisateur a édité sans cliquer sur Enregistrer).
- if (text !== presentationT1) {
- setPresentationT1(text)
- }
- setStep('recording')
- navigate('/simulation/eo/enregistrement')
- }
-
- return (
-
-
-
-
-
Ta présentation générée
-
- Lis-la, modifie-la si nécessaire, puis enregistre-toi.
-
-
-
-
- ) : (
-
- )
- }
- onClick={handleCopy}
- >
- {copied ? 'Copié' : 'Copier'}
-
-
}
- onClick={handleDownload}
- >
- .txt
-
-
- ) : (
-
- )
- }
- onClick={handleToggleEdit}
- >
- {isEditing ? 'Enregistrer les modifications' : 'Modifier'}
-
-
-
-
- setText(e.target.value)}
- readOnly={!isEditing}
- rows={12}
- className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid"
- />
-
-
- Conseil : Lis ce texte à voix haute 2-3 fois
- avant d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
-
-
-
- Présentation sauvegardée — retrouvée automatiquement à ta prochaine visite.
-
-
- Refaire
-
-
-
- }
- onClick={handleStartRecording}
- className="mt-6 w-full"
- >
- Je suis prêt — Enregistrer
-
-
-
- )
-}
diff --git a/src/features/simulations/pages/QuestionnaireT1Page.tsx b/src/features/simulations/pages/QuestionnaireT1Page.tsx
deleted file mode 100644
index f823041..0000000
--- a/src/features/simulations/pages/QuestionnaireT1Page.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * Page /simulation/eo/t1/questionnaire — Sprint 4c-2.
- *
- * Formulaire des 5 réponses utilisées pour générer la présentation T1
- * via DeepSeek (POST /presentations/generate). State 100 % local (pas de
- * provider) : les réponses ne survivent pas au refresh, c'est volontaire
- * — c'est la *présentation générée* qui est persistée (cf. provider).
- *
- * SEC-04 : validation Zod côté client (chaque champ trim non vide, max 500 chars).
- * Garde-fou : tâche EO_T1 obligatoire, sinon retour TaskSelector EO.
- */
-
-import { useEffect, useState, type FormEvent } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { useMutation } from '@tanstack/react-query'
-import { Sparkles } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import type { ApiError } from '@/shared/types/api'
-import { generatePresentation } from '@/entities/presentation/api'
-import type { PresentationReponses } from '@/entities/presentation/types'
-import { useSimulationFlow } from '../state/simulationFlow'
-import {
- EMPTY_REPONSES,
- FIELD_MAX,
- T1_QUESTIONNAIRE_FIELDS,
- reponsesSchema,
- type FieldKey,
-} from '../lib/t1Questionnaire'
-
-const inputBase =
- 'w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:border-brand focus:outline-none focus:shadow-focus'
-
-function mapApiError(err: ApiError | null): string | null {
- if (!err) return null
- switch (err.code) {
- case 'AUTH_REQUIRED':
- return 'Votre session a expiré. Reconnectez-vous.'
- case 'VALIDATION_ERROR':
- case 'INVALID_BODY':
- return 'Les réponses saisies sont invalides. Vérifiez chaque champ.'
- default:
- return 'La génération a échoué. Réessayez dans quelques instants.'
- }
-}
-
-export function QuestionnaireT1Page() {
- const navigate = useNavigate()
- const { production, step, setPresentationT1 } = useSimulationFlow()
-
- const shouldRedirect =
- !production || production.tache !== 'EO_T1' || step === 'idle' || step === 'done'
-
- useEffect(() => {
- if (shouldRedirect) navigate('/simulation/eo', { replace: true })
- }, [shouldRedirect, navigate])
-
- const [reponses, setReponses] = useState(EMPTY_REPONSES)
- const [touched, setTouched] = useState>({
- prenom_age_ville: false,
- formation_metier: false,
- situation_familiale: false,
- loisirs: false,
- motivation_canada: false,
- })
-
- const mutation = useMutation({
- mutationFn: generatePresentation,
- onSuccess: (data) => {
- setPresentationT1(data.presentation)
- navigate('/simulation/eo/t1/presentation')
- },
- })
-
- const parsed = reponsesSchema.safeParse(reponses)
- const fieldErrors = !parsed.success
- ? parsed.error.issues.reduce>>((acc, issue) => {
- const key = issue.path[0] as FieldKey | undefined
- if (key && !acc[key]) acc[key] = issue.message
- return acc
- }, {})
- : {}
-
- const formValid = parsed.success
- const apiErrorMessage = mapApiError(mutation.error as ApiError | null)
-
- function handleChange(key: FieldKey, value: string) {
- setReponses((r) => ({ ...r, [key]: value.slice(0, FIELD_MAX) }))
- }
-
- function handleBlur(key: FieldKey) {
- setTouched((t) => ({ ...t, [key]: true }))
- }
-
- function handleSubmit(event: FormEvent) {
- event.preventDefault()
- setTouched({
- prenom_age_ville: true,
- formation_metier: true,
- situation_familiale: true,
- loisirs: true,
- motivation_canada: true,
- })
- if (!parsed.success) return
- mutation.mutate(parsed.data)
- }
-
- if (shouldRedirect) return null
-
- return (
-
-
-
- navigate('/simulation/eo/t1/mode')}
- className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
- disabled={mutation.isPending}
- >
- ← Retour
-
-
-
- Tâche 1 — Questionnaire
-
- Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
- automatiquement.
-
-
-
- {T1_QUESTIONNAIRE_FIELDS.map((field) => {
- const value = reponses[field.key]
- const showError = touched[field.key] && fieldErrors[field.key]
- const id = `q-${field.key}`
- return (
-
-
- {field.label}
-
- {field.multiline ? (
-
handleChange(field.key, e.target.value)}
- onBlur={() => handleBlur(field.key)}
- placeholder={field.placeholder}
- maxLength={FIELD_MAX}
- rows={2}
- className={inputBase}
- />
- ) : (
- handleChange(field.key, e.target.value)}
- onBlur={() => handleBlur(field.key)}
- placeholder={field.placeholder}
- maxLength={FIELD_MAX}
- className={inputBase}
- />
- )}
- {showError && (
-
- {fieldErrors[field.key]}
-
- )}
-
- )
- })}
-
- {apiErrorMessage && (
-
- {apiErrorMessage}
-
- )}
-
- }
- loading={mutation.isPending}
- disabled={!formValid || mutation.isPending}
- >
- Générer ma présentation
-
-
-
-
- )
-}
diff --git a/src/features/simulations/pages/RapportPage.tsx b/src/features/simulations/pages/RapportPage.tsx
deleted file mode 100644
index 30ee0b9..0000000
--- a/src/features/simulations/pages/RapportPage.tsx
+++ /dev/null
@@ -1,324 +0,0 @@
-/**
- * Page de rapport de correction — Sprint 3.6b.
- *
- * Sections toujours visibles : score + jauge, revelation, diagnostic, conseil_nclc.
- * Sections conditionnelles via isSectionVisible(plan, section) :
- * detailed_report → criteres (avec exemple/suggestion/astuce/codes)
- * tips → exercices, modele
- *
- * Les exercices et la production modèle peuvent être dans l'état `pending`
- * (jobs fire-and-forget côté backend — cf. correctionController 3.6a) ou
- * `error` : JobStatusFallback affiche le message approprié (refresh manuel
- * uniquement ; polling traqué en FTD-24).
- *
- * SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
- * Règle D : isSectionVisible() obligatoire — jamais if (plan === 'xxx').
- * Règle H : logique de floutage dans entities/report/lib.ts.
- */
-
-import { useEffect } from 'react'
-import { useNavigate, useParams } from 'react-router-dom'
-import { Lock } from 'lucide-react'
-import { usePlan } from '@/features/dashboard/hooks/usePlan'
-import {
- isSectionVisible,
- groupErreursByCritere,
- critereCodeFromNom,
- getMaxScorePerCritere,
-} from '@/entities/report/lib'
-import type { Report } from '@/entities/report/types'
-import { isOral } from '@/entities/production/lib'
-import { useRapport } from '../hooks/useRapport'
-import { useSimulation } from '../hooks/useSimulation'
-import { Card } from '@/shared/ui/Card'
-import { Button } from '@/shared/ui/Button'
-import { ScoreHero } from '../components/rapport/ScoreHero'
-import { RevelationCards } from '../components/rapport/RevelationCards'
-import { DiagnosticCallout } from '../components/rapport/DiagnosticCallout'
-import { CritereCard } from '../components/rapport/CritereCard'
-import { ConseilNclcCallout } from '../components/rapport/ConseilNclcCallout'
-import { ExerciceInteractive } from '../components/rapport/ExerciceInteractive'
-import { ProductionModeleSection } from '../components/rapport/ProductionModeleSection'
-import { JobStatusFallback } from '../components/rapport/JobStatusFallback'
-
-function isReportNotReady(err: unknown): boolean {
- return (
- typeof err === 'object' &&
- err !== null &&
- 'code' in err &&
- (err as { code: unknown }).code === 'REPORT_NOT_READY'
- )
-}
-
-// ── Floutage section ────────────────────────────────────────────────────
-
-const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
-
-function BlurredSection({
- visible,
- onUpgrade,
- children,
-}: {
- visible: boolean
- onUpgrade: () => void
- children: React.ReactNode
-}) {
- if (visible) return <>{children}>
- return (
-
-
- {PLACEHOLDER_WIDTHS.map((w, i) => (
-
- ))}
-
-
-
-
Disponible en Standard
-
- Voir les plans
-
-
-
- )
-}
-
-// ── Squelette ───────────────────────────────────────────────────────────
-
-function RapportSkeleton() {
- return (
-
- )
-}
-
-// ── Sections thématiques ─────────────────────────────────────────────────
-
-function CriteresSection({ rapport }: { rapport: Report }) {
- const grouped = groupErreursByCritere(rapport.erreurs_codes)
- const maxScore = getMaxScorePerCritere(rapport)
-
- return (
-
- Détail par critère
-
- {rapport.criteres.map((c) => {
- const code = critereCodeFromNom(c.nom)
- const codes = code ? grouped[code] : []
- return
- })}
-
-
- )
-}
-
-function ExercicesSection({
- rapport,
- hasTimedOut,
- onRetry,
-}: {
- rapport: Report
- hasTimedOut: boolean
- onRetry: () => void
-}) {
- if (rapport.exercices_status !== 'ready' || !rapport.exercices) {
- return (
-
-
- Mes exercices personnalisés
-
-
-
- )
- }
-
- return (
-
- Mes exercices personnalisés
-
- {rapport.exercices.map((ex, i) => (
-
- ))}
-
-
- )
-}
-
-function ModeleSection({
- rapport,
- hasTimedOut,
- onRetry,
-}: {
- rapport: Report
- hasTimedOut: boolean
- onRetry: () => void
-}) {
- if (rapport.modele_status !== 'ready' || !rapport.modele) {
- return (
-
-
- Version restructurée NCLC 9+
-
-
-
- )
- }
-
- return (
-
-
- Version restructurée NCLC 9+
-
-
-
- )
-}
-
-// ── Page principale ──────────────────────────────────────────────────────
-
-export function RapportPage() {
- const { id = '' } = useParams<{ id: string }>()
- const navigate = useNavigate()
-
- const { rapport, isLoading, isError, error, refetch, hasTimedOut } = useRapport(id)
-
- // Bug 6 — route le retour « Nouvelle simulation » selon la tâche d'origine
- // (propagée dans Report). EO → hub /simulation/eo ; EE (ou tâche inconnue
- // pendant le chargement) → /simulation/ee.
- const simulationsPath =
- rapport?.tache && isOral(rapport.tache) ? '/simulation/eo' : '/simulation/ee'
- const isInProgress = isError && isReportNotReady(error)
-
- const { reset } = useSimulation()
-
- const { data: planData, isLoading: isPlanLoading, isError: isPlanError } = usePlan()
-
- // FTD-21 — si la simulation n'est pas encore corrigée, rediriger vers /simulation/ee.
- useEffect(() => {
- if (isInProgress) {
- navigate('/simulation/ee', { replace: true })
- }
- }, [isInProgress, navigate])
-
- const onUpgrade = () => navigate('/plan')
-
- // Quitter le rapport proprement : reset du flow (step, production, mutations)
- // avant de naviguer — sinon step='done' resterait sticky et empêcherait le
- // retour au TaskSelector ou à /sujets.
- function goToSimulations() {
- reset()
- navigate(simulationsPath)
- }
-
- return (
-
-
- {/* Breadcrumb */}
-
-
- Simulations
-
- ›
-
- Rapport
-
-
-
- {(isLoading || isPlanLoading) && }
-
- {isInProgress && (
-
- Votre simulation est en cours.
-
- )}
-
- {(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
-
-
-
- Impossible de charger ce rapport. Réessayez dans quelques instants.
-
-
navigate('/simulation/ee')}>
- Retour aux simulations
-
-
-
- )}
-
- {rapport && planData && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
- void refetch()}
- />
-
-
-
- void refetch()}
- />
-
-
- {/* Action de sortie — reset + nouvelle simulation */}
-
-
- Nouvelle simulation
-
-
- >
- )}
-
-
- )
-}
diff --git a/src/features/simulations/pages/SimulationEOPage.tsx b/src/features/simulations/pages/SimulationEOPage.tsx
deleted file mode 100644
index 4f45a4f..0000000
--- a/src/features/simulations/pages/SimulationEOPage.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * Page de simulation Expression Orale — Sprint 4c-1.
- *
- * Affiche le TaskSelector type='EO' (T1 / T3 / T2 Live verrouillé) +
- * un bandeau d'info quand l'utilisateur clique sur une tâche temporairement
- * indisponible (EO_T1 livré en 4c-2).
- *
- * Règle D : quotas et permissions passent par canSimulate / hasAccess.
- * Règle H : aucune logique métier — délègue au provider.
- */
-
-import { useNavigate } from 'react-router-dom'
-import { usePlan } from '@/features/dashboard/hooks/usePlan'
-import { Button } from '@/shared/ui/Button'
-import { TaskSelector } from '../components/TaskSelector'
-import { useSimulationFlow } from '../state/simulationFlow'
-
-function SimulationEOSkeleton() {
- return (
-
-
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
-
- )
-}
-
-export function SimulationEOPage() {
- const {
- data: planData,
- isLoading: isPlanLoading,
- isError: isPlanError,
- refetch: refetchPlan,
- } = usePlan()
-
- const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
- const navigate = useNavigate()
-
- return (
-
-
- {isPlanLoading && }
-
- {isPlanError && (
-
-
- Impossible de charger vos informations. Réessayez dans quelques instants.
-
-
refetchPlan()}>
- Réessayer
-
-
- )}
-
- {planData && (
-
-
navigate('/simulation/eo/t2')}
- onT1LiveSelect={() => navigate('/simulation/eo/t1/live/preparation')}
- />
-
- {taskUnavailableMessage && (
-
- {taskUnavailableMessage}
-
- )}
-
- )}
-
-
- )
-}
diff --git a/src/features/simulations/pages/SimulationPage.tsx b/src/features/simulations/pages/SimulationPage.tsx
deleted file mode 100644
index 25b74ef..0000000
--- a/src/features/simulations/pages/SimulationPage.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * Page de simulation Expression Écrite.
- *
- * Orchestre les 3 étapes du flux : sélection de tâche → saisie → rapport.
- * Le choix du sujet est délégué à la page /sujets (refonte UX 2026-04-21).
- *
- * Règle D : quotas et permissions passent par canSimulate() — jamais de plan === '...'
- * Règle H : aucune logique métier — tout est dans useSimulation() et les entités.
- */
-
-import { usePlan } from '@/features/dashboard/hooks/usePlan'
-import { Button } from '@/shared/ui/Button'
-import { useSimulation } from '../hooks/useSimulation'
-import { TaskSelector } from '../components/TaskSelector'
-import { SimulationForm } from '../components/SimulationForm'
-
-function SimulationSkeleton() {
- return (
-
-
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
-
- )
-}
-
-export function SimulationPage() {
- const {
- data: planData,
- isLoading: isPlanLoading,
- isError: isPlanError,
- refetch: refetchPlan,
- } = usePlan()
-
- const {
- step,
- production,
- sujet,
- isCreating,
- isCorrecting,
- correctError,
- selectTask,
- submitText,
- goToSubjectPicker,
- reset,
- } = useSimulation()
-
- // Le reset sticky (step='done' ou 'choosing-subject' au retour) est déclenché
- // explicitement par les callers qui ramènent vers /simulation/ee :
- // - RapportPage.goToSimulations : reset() avant navigate
- // - SujetsPage bouton « ← Retour » : reset() avant navigate
- // Un useEffect réactif ici annulerait les transitions légitimes de
- // createMutation.onSuccess (idle → choosing-subject → navigate /sujets).
-
- return (
-
-
- {isPlanLoading && }
-
- {isPlanError && (
-
-
- Impossible de charger vos informations. Réessayez dans quelques instants.
-
-
refetchPlan()}>
- Réessayer
-
-
- )}
-
- {planData && step === 'idle' && (
-
- )}
-
- {planData && (step === 'task-selected' || step === 'correcting') && production && (
-
- )}
-
-
- )
-}
diff --git a/src/features/simulations/pages/SujetsEOPage.tsx b/src/features/simulations/pages/SujetsEOPage.tsx
deleted file mode 100644
index 3389f88..0000000
--- a/src/features/simulations/pages/SujetsEOPage.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * Page /simulation/eo/sujets — sélection d'un sujet EO_T3 en cartes.
- *
- * Clone fonctionnel de SujetsPage (EE) : même grille, même bouton aléatoire,
- * mêmes redirections de garde — adapté pour rester dans le flow EO et
- * naviguer vers /simulation/eo/pre-enregistrement après sélection.
- */
-
-import { useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Shuffle } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { formatTache } from '@/entities/production/lib'
-import type { SujetData } from '@/entities/production/types'
-import { useSimulationFlow } from '../state/simulationFlow'
-import { useSujets } from '../hooks/useSujets'
-import { SujetCard } from '../components/SujetCard'
-
-function SujetsSkeleton() {
- return (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
- )
-}
-
-export function SujetsEOPage() {
- const navigate = useNavigate()
- const { step, production, changeSubject, setStep, reset } = useSimulationFlow()
-
- // Garde-fous identiques à SujetsPage : refresh direct ou état incohérent
- // → retour au TaskSelector EO. step='done' = simulation déjà corrigée,
- // /sujets ne doit pas patcher (cf. FTD-23).
- const shouldRedirect = !production || step === 'idle' || step === 'done'
- useEffect(() => {
- if (shouldRedirect) navigate('/simulation/eo', { replace: true })
- }, [shouldRedirect, navigate])
-
- const {
- data: sujets,
- isLoading,
- isError,
- refetch,
- } = useSujets(production?.tache ?? 'EO_T3', !!production && !shouldRedirect)
-
- if (shouldRedirect || !production) return null
-
- function handleSelect(sujet: SujetData) {
- changeSubject(sujet)
- setStep('task-selected')
- navigate('/simulation/eo/pre-enregistrement')
- }
-
- function handleRandom() {
- if (!sujets || sujets.length === 0) return
- const pool = production?.sujet ? sujets.filter((s) => s.id !== production.sujet?.id) : sujets
- const list = pool.length > 0 ? pool : sujets
- const pick = list[Math.floor(Math.random() * list.length)]
- if (pick) handleSelect(pick)
- }
-
- const hasSujets = (sujets?.length ?? 0) > 0
-
- return (
-
-
-
- {
- reset()
- navigate('/simulation/eo')
- }}
- className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
- >
- ← Retour
-
-
- Choisir un sujet — {formatTache(production.tache)}
-
-
-
-
-
- {isLoading
- ? 'Chargement des sujets…'
- : hasSujets
- ? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
- : 'Aucun sujet disponible pour cette tâche.'}
-
-
}
- onClick={handleRandom}
- disabled={!hasSujets}
- >
- Sujet aléatoire
-
-
-
- {isError && (
-
- Impossible de charger les sujets.{' '}
- refetch()}
- className="underline underline-offset-2"
- >
- Réessayer
-
-
- )}
-
- {isLoading ? (
-
- ) : hasSujets ? (
-
- {sujets!.map((sujet) => (
-
- ))}
-
- ) : null}
-
-
- )
-}
diff --git a/src/features/simulations/pages/SujetsPage.tsx b/src/features/simulations/pages/SujetsPage.tsx
deleted file mode 100644
index e351854..0000000
--- a/src/features/simulations/pages/SujetsPage.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Page /sujets — sélection d'un sujet en cartes pour une production en cours.
- *
- * Flux :
- * 1. /simulation/ee → selectTask (POST /simulations) → navigate('/sujets')
- * 2. Ici : liste les sujets de la tâche en cours, permet choix manuel ou aléatoire
- * 3. Sélection → changeSubject + navigate('/simulation/ee') (SimulationForm visible)
- *
- * MVP : refresh direct sur /sujets → redirect vers /simulation/ee (pas de state).
- * Règle D : aucun contrôle de plan/quota ici — déjà fait à la création de la simulation.
- * Règle H : aucune logique métier — délègue au provider + useSujets.
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Shuffle } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { formatTache } from '@/entities/production/lib'
-import type { SujetData } from '@/entities/production/types'
-import { useSimulationFlow } from '../state/simulationFlow'
-import { useSujets } from '../hooks/useSujets'
-import { SujetCard } from '../components/SujetCard'
-
-function SujetsSkeleton() {
- return (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
- )
-}
-
-export function SujetsPage() {
- const navigate = useNavigate()
- const { step, production, changeSubject, setStep, reset } = useSimulationFlow()
-
- // Redirige vers /simulation/ee si :
- // - production absente (refresh direct sur /sujets sans contexte)
- // - step === 'idle' (état initial, pas de simulation en cours)
- // - step === 'done' (simulation corrigée — /sujets ne doit pas patcher
- // une simulation dont le rapport est déjà persisté — cf. FTD-23)
- const shouldRedirect = !production || step === 'idle' || step === 'done'
- useEffect(() => {
- if (shouldRedirect) navigate('/simulation/ee', { replace: true })
- }, [shouldRedirect, navigate])
-
- const {
- data: sujets,
- isLoading,
- isError,
- refetch,
- } = useSujets(production?.tache ?? 'EE_T1', !!production && !shouldRedirect)
-
- if (shouldRedirect || !production) return null
-
- function handleSelect(sujet: SujetData) {
- changeSubject(sujet)
- setStep('task-selected')
- navigate('/simulation/ee')
- }
-
- function handleRandom() {
- if (!sujets || sujets.length === 0) return
- const pool = production?.sujet ? sujets.filter((s) => s.id !== production.sujet?.id) : sujets
- const list = pool.length > 0 ? pool : sujets
- const pick = list[Math.floor(Math.random() * list.length)]
- if (pick) handleSelect(pick)
- }
-
- const hasSujets = (sujets?.length ?? 0) > 0
-
- return (
-
-
- {
- // « Retour » = annuler la simulation en cours et revenir au
- // TaskSelector. reset() doit être appelé AVANT navigate pour que
- // step retombe à 'idle' sans repasser par 'choosing-subject'.
- reset()
- navigate('/simulation/ee')
- }}
- className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
- >
- ← Retour
-
-
- Choisir un sujet — {formatTache(production.tache)}
-
-
-
-
-
- {isLoading
- ? 'Chargement des sujets…'
- : hasSujets
- ? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
- : 'Aucun sujet disponible pour cette tâche.'}
-
-
}
- onClick={handleRandom}
- disabled={!hasSujets}
- >
- Sujet aléatoire
-
-
-
- {isError && (
-
- Impossible de charger les sujets.{' '}
- refetch()} className="underline underline-offset-2">
- Réessayer
-
-
- )}
-
- {isLoading ? (
-
- ) : hasSujets ? (
-
- {sujets!.map((sujet) => (
-
- ))}
-
- ) : null}
-
- )
-}
diff --git a/src/features/simulations/pages/__tests__/QuestionnaireT1Page.test.tsx b/src/features/simulations/pages/__tests__/QuestionnaireT1Page.test.tsx
deleted file mode 100644
index dc820ea..0000000
--- a/src/features/simulations/pages/__tests__/QuestionnaireT1Page.test.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * Tests — QuestionnaireT1Page (Sprint 4c-2).
- *
- * Couvre :
- * - bouton désactivé tant que des champs sont vides, actif quand tous remplis
- * - submit appelle generatePresentation avec le bon payload
- * - onSuccess : setPresentationT1 + navigate vers /simulation/eo/t1/presentation
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, cleanup, waitFor } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { MemoryRouter } from 'react-router-dom'
-
-const { navigateMock, generatePresentationMock, setPresentationT1Mock } = vi.hoisted(() => ({
- navigateMock: vi.fn(),
- generatePresentationMock: vi.fn(),
- setPresentationT1Mock: vi.fn(),
-}))
-
-vi.mock('react-router-dom', async () => {
- const actual = await vi.importActual('react-router-dom')
- return { ...actual, useNavigate: () => navigateMock }
-})
-
-vi.mock('@/entities/presentation/api', () => ({
- generatePresentation: generatePresentationMock,
-}))
-
-vi.mock('../../state/simulationFlow', () => ({
- useSimulationFlow: () => ({
- production: {
- id: 'sim-eo-1',
- tache: 'EO_T1',
- mode: 'entrainement',
- created_at: '2026-04-25',
- sujet: null,
- },
- step: 'task-selected',
- setPresentationT1: setPresentationT1Mock,
- }),
-}))
-
-import { QuestionnaireT1Page } from '../QuestionnaireT1Page'
-
-function renderPage() {
- const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
- return render(
-
-
-
-
- ,
- )
-}
-
-const FIELDS = [
- { label: /prénom, âge/i, value: 'Marie, 32 ans, Douala' },
- { label: /formation et ton métier/i, value: 'Master gestion, comptable' },
- { label: /situation familiale/i, value: 'Mariée, 2 enfants' },
- { label: /loisirs ou passions/i, value: 'Lecture, cuisine' },
- { label: /immigrer au Canada/i, value: 'Opportunités, départ 2025' },
-] as const
-
-beforeEach(() => {
- cleanup()
- navigateMock.mockReset()
- generatePresentationMock.mockReset()
- setPresentationT1Mock.mockReset()
-})
-
-describe('QuestionnaireT1Page', () => {
- it('bouton désactivé tant que les champs sont vides', () => {
- renderPage()
- const submit = screen.getByRole('button', { name: /Générer ma présentation/i })
- expect(submit).toBeDisabled()
- })
-
- it('bouton actif quand les 5 champs sont remplis', async () => {
- renderPage()
- const user = userEvent.setup()
-
- for (const field of FIELDS) {
- await user.type(screen.getByLabelText(field.label), field.value)
- }
-
- const submit = screen.getByRole('button', { name: /Générer ma présentation/i })
- expect(submit).not.toBeDisabled()
- })
-
- it('submit appelle generatePresentation, setPresentationT1, puis navigate', async () => {
- generatePresentationMock.mockResolvedValueOnce({
- presentation: 'Bonjour, je m appelle Marie. Voilà.',
- })
-
- renderPage()
- const user = userEvent.setup()
-
- for (const field of FIELDS) {
- await user.type(screen.getByLabelText(field.label), field.value)
- }
-
- await user.click(screen.getByRole('button', { name: /Générer ma présentation/i }))
-
- await waitFor(() => {
- expect(generatePresentationMock).toHaveBeenCalledTimes(1)
- })
- expect(generatePresentationMock.mock.calls[0]?.[0]).toEqual({
- prenom_age_ville: 'Marie, 32 ans, Douala',
- formation_metier: 'Master gestion, comptable',
- situation_familiale: 'Mariée, 2 enfants',
- loisirs: 'Lecture, cuisine',
- motivation_canada: 'Opportunités, départ 2025',
- })
-
- await waitFor(() => {
- expect(setPresentationT1Mock).toHaveBeenCalledWith('Bonjour, je m appelle Marie. Voilà.')
- })
- expect(navigateMock).toHaveBeenCalledWith('/simulation/eo/t1/presentation')
- })
-})
diff --git a/src/features/simulations/pages/__tests__/RapportPage.test.tsx b/src/features/simulations/pages/__tests__/RapportPage.test.tsx
deleted file mode 100644
index df0fa16..0000000
--- a/src/features/simulations/pages/__tests__/RapportPage.test.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * Tests — RapportPage (Sprint 6e, Bug 6).
- *
- * Couvre le routage du retour « Nouvelle simulation » selon `Report.tache` :
- * - tâche EO (EO_T2_LIVE) → /simulation/eo (hub)
- * - tâche EE (défaut) → /simulation/ee
- * - tâche absente → /simulation/ee (fallback chargement)
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, cleanup } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { MemoryRouter } from 'react-router-dom'
-import type { Report } from '@/entities/report/types'
-
-const { navigateMock, useRapportMock, resetMock, usePlanMock } = vi.hoisted(() => ({
- navigateMock: vi.fn(),
- useRapportMock: vi.fn(),
- resetMock: vi.fn(),
- usePlanMock: vi.fn(),
-}))
-
-vi.mock('react-router-dom', async () => {
- const actual = await vi.importActual('react-router-dom')
- return { ...actual, useNavigate: () => navigateMock }
-})
-
-vi.mock('../../hooks/useRapport', () => ({
- useRapport: useRapportMock,
-}))
-
-vi.mock('../../hooks/useSimulation', () => ({
- useSimulation: () => ({ reset: resetMock }),
-}))
-
-vi.mock('@/features/dashboard/hooks/usePlan', () => ({
- usePlan: usePlanMock,
-}))
-
-import { RapportPage } from '../RapportPage'
-
-const baseReport: Report = {
- simulation_id: 'sim-1',
- score: 14,
- nclc: 8,
- nclc_cible: 9,
- revelation: { croyance: 'c', realite: 'r', consequence: 'cs' },
- diagnostic: 'diag',
- criteres: [],
- conseil_nclc: { nclc_cible: 'NCLC 9', ecart: 'e', action_prioritaire: 'a' },
- erreurs_codes: [],
- exercices: null,
- exercices_status: 'error',
- modele: null,
- modele_status: 'error',
-}
-
-function renderWithReport(rapport: Report) {
- useRapportMock.mockReturnValue({
- rapport,
- isLoading: false,
- isError: false,
- error: null,
- refetch: vi.fn(),
- isPolling: false,
- hasTimedOut: false,
- })
- usePlanMock.mockReturnValue({ data: { plan: 'premium' }, isLoading: false, isError: false })
- return render(
-
-
- ,
- )
-}
-
-beforeEach(() => {
- cleanup()
- navigateMock.mockReset()
- useRapportMock.mockReset()
- usePlanMock.mockReset()
- resetMock.mockReset()
-})
-
-describe('RapportPage — routage Nouvelle simulation (Bug 6)', () => {
- it('tâche EO (EO_T2_LIVE) → /simulation/eo', async () => {
- renderWithReport({ ...baseReport, tache: 'EO_T2_LIVE' })
- const user = userEvent.setup()
- await user.click(screen.getByRole('button', { name: /Nouvelle simulation/i }))
- expect(resetMock).toHaveBeenCalled()
- expect(navigateMock).toHaveBeenCalledWith('/simulation/eo')
- })
-
- it('tâche EE → /simulation/ee', async () => {
- renderWithReport({ ...baseReport, tache: 'EE_T1' })
- const user = userEvent.setup()
- await user.click(screen.getByRole('button', { name: /Nouvelle simulation/i }))
- expect(navigateMock).toHaveBeenCalledWith('/simulation/ee')
- })
-
- it('tâche absente → /simulation/ee (fallback)', async () => {
- renderWithReport({ ...baseReport, tache: undefined })
- const user = userEvent.setup()
- await user.click(screen.getByRole('button', { name: /Nouvelle simulation/i }))
- expect(navigateMock).toHaveBeenCalledWith('/simulation/ee')
- })
-})
diff --git a/src/features/simulations/state/SimulationFlowProvider.tsx b/src/features/simulations/state/SimulationFlowProvider.tsx
deleted file mode 100644
index 3b4bbc1..0000000
--- a/src/features/simulations/state/SimulationFlowProvider.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-/**
- * Provider de flux simulation — partage l'état entre /simulation/ee et /sujets.
- *
- * Hérite de la state machine de useSimulation mais déplace la source de vérité
- * hors du hook pour qu'elle survive aux navigations React Router (Option A — cf.
- * plan de refonte UX "page /sujets avec cartes").
- *
- * Règle H : aucune logique métier — les mutations s'appuient sur entities/.
- */
-
-import { useEffect, useRef, useState, type ReactNode } from 'react'
-import { useLocation, useNavigate } from 'react-router-dom'
-import { useMutation } from '@tanstack/react-query'
-import {
- createSimulation,
- getSimulationState,
- updateSujet as updateSujetApi,
-} from '@/entities/production/api'
-import { correctEe, correctEo } from '@/entities/report/api'
-import type { CreateSimulationPayload, Production, Tache } from '@/entities/production/types'
-import type { Report } from '@/entities/report/types'
-import type { ApiError } from '@/shared/types/api'
-import type { SujetData } from '@/entities/production/types'
-import { SimulationFlowContext, type FlowValue, type SimulationStep } from './simulationFlow'
-
-const TACHES_SANS_CATALOGUE: Tache[] = ['EO_T1']
-const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
-const LS_EO_T1_PRESENTATION_KEY = 'expria_eo_t1_presentation'
-
-function isEoTache(tache: Tache): boolean {
- return tache.startsWith('EO_')
-}
-
-export function SimulationFlowProvider({ children }: { children: ReactNode }) {
- const [step, setStep] = useState('idle')
- const [production, setProduction] = useState(null)
- const [taskUnavailableMessage, setTaskUnavailableMessage] = useState(null)
- // Sprint 4c-2 — état initialisé depuis localStorage pour survivre au refresh
- // tout au long du flux T1 (questionnaire → présentation → enregistrement).
- const [presentationT1, setPresentationT1State] = useState(() => {
- if (typeof window === 'undefined') return null
- return window.localStorage.getItem(LS_EO_T1_PRESENTATION_KEY)
- })
-
- function setPresentationT1(text: string | null): void {
- setPresentationT1State(text)
- if (typeof window === 'undefined') return
- if (text === null) window.localStorage.removeItem(LS_EO_T1_PRESENTATION_KEY)
- else window.localStorage.setItem(LS_EO_T1_PRESENTATION_KEY, text)
- }
- const navigate = useNavigate()
- const location = useLocation()
- const hydratedRef = useRef(false)
-
- // FTD-21 — restauration de session depuis localStorage au montage.
- // Si `rapport === null` → simulation en cours, on restaure le state et redirige
- // vers /simulation/ee. Sinon (rapport présent ou erreur/404) → on nettoie.
- useEffect(() => {
- if (hydratedRef.current) return
- hydratedRef.current = true
-
- const id = localStorage.getItem(LS_SIMULATION_ID_KEY)
- if (!id) return
-
- getSimulationState(id)
- .then((state) => {
- if (state.rapport !== null) {
- localStorage.removeItem(LS_SIMULATION_ID_KEY)
- return
- }
- setProduction({
- id: state.simulation_id,
- tache: state.tache,
- mode: state.mode,
- created_at: state.created_at,
- sujet: state.sujet,
- contenu: state.contenu ?? undefined,
- sujet_id: state.sujet?.id,
- })
- setStep('task-selected')
- // Sprint 4c-2 — restauration EO :
- // - EO_T1 + présentation déjà générée → /t1/presentation
- // - EO_T1 sans présentation → /t1/mode (choix mode)
- // - EO_T3 → /pre-enregistrement
- // - EE → /simulation/ee
- let targetBase: string
- if (state.tache === 'EO_T1') {
- const stored =
- typeof window !== 'undefined'
- ? window.localStorage.getItem(LS_EO_T1_PRESENTATION_KEY)
- : null
- targetBase = stored ? '/simulation/eo/t1/presentation' : '/simulation/eo/t1/mode'
- } else if (isEoTache(state.tache)) {
- targetBase = '/simulation/eo/pre-enregistrement'
- } else {
- targetBase = '/simulation/ee'
- }
- if (!location.pathname.startsWith(targetBase)) {
- navigate(targetBase)
- }
- })
- .catch(() => {
- localStorage.removeItem(LS_SIMULATION_ID_KEY)
- })
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
-
- // Persiste l'ID de simulation dès qu'une production est active.
- // Permet le resume au refresh pour TOUS les flows (EE + EO_T1 + EO_T3),
- // indépendamment du composant qui rend le formulaire.
- useEffect(() => {
- if (production?.id) {
- localStorage.setItem(LS_SIMULATION_ID_KEY, production.id)
- }
- }, [production?.id])
-
- const createMutation = useMutation({
- mutationFn: createSimulation,
- onSuccess: (data) => {
- setProduction(data)
- const hasCatalogue = !TACHES_SANS_CATALOGUE.includes(data.tache)
- setStep(hasCatalogue ? 'choosing-subject' : 'task-selected')
- // Sprint 4c-2 — routage post-création :
- // - EE_T1/T2/T3 (avec catalogue) → /sujets (legacy)
- // - EO_T3 (avec catalogue) → /simulation/eo/sujets
- // - EO_T1 (sans catalogue) → /simulation/eo/t1/mode (choix génération
- // vs enregistrement direct).
- if (data.tache === 'EO_T1') {
- navigate('/simulation/eo/t1/mode')
- } else if (hasCatalogue) {
- navigate(isEoTache(data.tache) ? '/simulation/eo/sujets' : '/sujets')
- }
- },
- })
-
- const correctMutation = useMutation({
- mutationFn: correctEe,
- onMutate: () => setStep('correcting'),
- onSuccess: (_data, variables) => {
- setStep('done')
- localStorage.removeItem(LS_SIMULATION_ID_KEY)
- // Navigation vers le rapport déclenchée ici (plutôt que depuis un
- // useEffect sticky côté SimulationPage) — une seule fois par correction,
- // pas de redirection en boucle si l'utilisateur revient sur /simulation/ee.
- navigate(`/rapport/${variables.simulationId}`)
- },
- onError: () => setStep('task-selected'),
- })
-
- // Sprint 4c-1 — mutation EO. Sépare le pipeline pour éviter de devoir
- // discriminer dynamiquement le payload (EE vs EO) côté mutationFn.
- const correctEoMutation = useMutation({
- mutationFn: correctEo,
- onMutate: () => setStep('correcting'),
- onSuccess: (_data, variables) => {
- setStep('done')
- localStorage.removeItem(LS_SIMULATION_ID_KEY)
- navigate(`/rapport/${variables.simulationId}`)
- },
- onError: () => setStep('recording'),
- })
-
- function selectTask(payload: CreateSimulationPayload): void {
- // Sprint 4c-2 — l'interception EO_T1 introduite en 4c-1 est levée :
- // le flux T1 est désormais wired (cf. createMutation.onSuccess).
- // `taskUnavailableMessage` reste exposé pour de futurs cas (ex. T2 Live).
- setTaskUnavailableMessage(null)
- createMutation.mutate(payload)
- }
-
- function submitText(texte: string, nclcCible: 9 | 10 = 9): void {
- if (!production) return
- correctMutation.mutate({
- simulationId: production.id,
- contenu: texte,
- tache: production.tache,
- nclc_cible: nclcCible,
- })
- }
-
- // Sprint 4c-3 — bascule transcription live → audio batch backend.
- // Le frontend envoie l'audio brut en base64 + mimeType ; le backend appelle
- // Gemini batch pour la transcription puis poursuit le pipeline correction
- // (cf. POST /corrections/eo en mode audio).
- function submitEoAudio(audioBase64: string, mimeType: string, nclcCible: 9 | 10 = 9): void {
- if (!production) return
- correctEoMutation.mutate({
- simulationId: production.id,
- tache: production.tache,
- audioBase64,
- mimeType,
- nclc_cible: nclcCible,
- })
- }
-
- function changeSubject(sujet: SujetData): void {
- // FTD-21 — persiste le changement côté backend (best-effort : l'UI ne bloque pas).
- if (production) {
- void updateSujetApi(production.id, sujet.id).catch(() => {
- // silencieux : le sujet reste localement, le resume ramènera l'ancien si échec
- })
- }
- setProduction((p) => (p ? { ...p, sujet } : p))
- }
-
- function reset(): void {
- setStep('idle')
- setProduction(null)
- setTaskUnavailableMessage(null)
- setPresentationT1(null)
- localStorage.removeItem(LS_SIMULATION_ID_KEY)
- createMutation.reset()
- correctMutation.reset()
- correctEoMutation.reset()
- }
-
- const value: FlowValue = {
- step,
- production,
- sujet: production?.sujet ?? null,
- report: (correctMutation.data ?? correctEoMutation.data ?? null) as Report | null,
- isCreating: createMutation.isPending,
- isCorrecting: correctMutation.isPending || correctEoMutation.isPending,
- createError: createMutation.error as ApiError | null,
- correctError: (correctMutation.error ?? correctEoMutation.error) as ApiError | null,
- taskUnavailableMessage,
- presentationT1,
- setPresentationT1,
- selectTask,
- submitText,
- submitEoAudio,
- changeSubject,
- setStep,
- reset,
- }
-
- return {children}
-}
diff --git a/src/features/simulations/state/__tests__/simulationFlowEO.test.tsx b/src/features/simulations/state/__tests__/simulationFlowEO.test.tsx
deleted file mode 100644
index 93699b5..0000000
--- a/src/features/simulations/state/__tests__/simulationFlowEO.test.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-/**
- * Tests du flow EO ajoutés en Sprint 4c-1.
- *
- * Couvre :
- * - selectTask EO_T1 → message inline, pas de création
- * - selectTask EO_T3 → création + step='choosing-subject' (navigation testée
- * via le mock de useNavigate)
- * - submitEoAudio appelle correctEo avec audioBase64 + mimeType (Sprint 4c-3)
- * - non-régression EE : selectTask EE_T1 fonctionne toujours
- */
-
-import React from 'react'
-import { renderHook, act, waitFor } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { MemoryRouter } from 'react-router-dom'
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-
-vi.mock('@/entities/production/api')
-vi.mock('@/entities/report/api')
-
-import { createSimulation, getSimulationState } from '@/entities/production/api'
-import { correctEo } from '@/entities/report/api'
-import { SimulationFlowProvider } from '../SimulationFlowProvider'
-import { useSimulationFlow } from '../simulationFlow'
-import type { Production } from '@/entities/production/types'
-import type { Report } from '@/entities/report/types'
-
-const mockCreate = vi.mocked(createSimulation)
-const mockCorrectEo = vi.mocked(correctEo)
-const mockGetState = vi.mocked(getSimulationState)
-
-const eoT3Production: Production = {
- id: 'sim-eo-1',
- tache: 'EO_T3',
- mode: 'entrainement',
- created_at: '2026-04-25T00:00:00Z',
- sujet: null,
-}
-
-const mockEoReport: Report = {
- simulation_id: 'sim-eo-1',
- score: 14,
- nclc: 9,
- nclc_cible: 9,
- revelation: { croyance: '', realite: '', consequence: '' },
- diagnostic: '',
- criteres: [],
- conseil_nclc: { nclc_cible: 'NCLC 9', ecart: '', action_prioritaire: '' },
- erreurs_codes: [],
- exercices: null,
- exercices_status: 'pending',
- modele: null,
- modele_status: 'pending',
-}
-
-function createWrapper() {
- const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
- return function Wrapper({ children }: { children: React.ReactNode }) {
- return React.createElement(
- MemoryRouter,
- null,
- React.createElement(
- QueryClientProvider,
- { client: queryClient },
- React.createElement(SimulationFlowProvider, null, children),
- ),
- )
- }
-}
-
-beforeEach(() => {
- vi.clearAllMocks()
- localStorage.clear()
- mockGetState.mockRejectedValue(new Error('no resume'))
-})
-
-describe('SimulationFlowProvider EO — Sprint 4c-1', () => {
- it('Sprint 4c-2 — EO_T1 crée la simulation (interception 4c-1 levée)', async () => {
- const eoT1: Production = { ...eoT3Production, tache: 'EO_T1' }
- mockCreate.mockResolvedValue(eoT1)
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.selectTask({ tache: 'EO_T1', mode: 'entrainement' })
- })
-
- await waitFor(() => expect(result.current.step).toBe('task-selected'))
- expect(mockCreate).toHaveBeenCalledTimes(1)
- expect(result.current.taskUnavailableMessage).toBeNull()
- })
-
- it('EO_T3 : selectTask crée la simulation et passe en choosing-subject', async () => {
- mockCreate.mockResolvedValue(eoT3Production)
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.selectTask({ tache: 'EO_T3', mode: 'entrainement' })
- })
-
- await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- expect(result.current.production).toEqual(eoT3Production)
- expect(mockCreate.mock.calls[0]?.[0]).toEqual({ tache: 'EO_T3', mode: 'entrainement' })
- })
-
- it('submitEoAudio appelle correctEo avec audioBase64 + mimeType', async () => {
- mockCreate.mockResolvedValue(eoT3Production)
- mockCorrectEo.mockResolvedValue(mockEoReport)
-
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.selectTask({ tache: 'EO_T3', mode: 'entrainement' })
- })
- await waitFor(() => expect(result.current.production).toEqual(eoT3Production))
-
- act(() => {
- result.current.submitEoAudio('AAAAAA==', 'audio/webm', 9)
- })
-
- await waitFor(() => {
- expect(mockCorrectEo).toHaveBeenCalled()
- })
- expect(mockCorrectEo.mock.calls[0]?.[0]).toEqual({
- simulationId: 'sim-eo-1',
- tache: 'EO_T3',
- audioBase64: 'AAAAAA==',
- mimeType: 'audio/webm',
- nclc_cible: 9,
- })
- })
-
- it('non-régression EE : selectTask EE_T1 reste fonctionnel', async () => {
- const eeProduction: Production = { ...eoT3Production, id: 'sim-ee', tache: 'EE_T1' }
- mockCreate.mockResolvedValue(eeProduction)
-
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.selectTask({ tache: 'EE_T1', mode: 'entrainement' })
- })
-
- await waitFor(() => expect(result.current.step).toBe('choosing-subject'))
- expect(result.current.production).toEqual(eeProduction)
- })
-})
diff --git a/src/features/simulations/state/__tests__/simulationFlowT1.test.tsx b/src/features/simulations/state/__tests__/simulationFlowT1.test.tsx
deleted file mode 100644
index b090027..0000000
--- a/src/features/simulations/state/__tests__/simulationFlowT1.test.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * Tests du flow T1 — Sprint 4c-2.
- *
- * Couvre :
- * - setPresentationT1 expose la valeur via le hook + persiste en localStorage
- * - reset() remet presentationT1 à null + nettoie localStorage
- * - hydratation au mount lit la valeur depuis localStorage
- */
-
-import React from 'react'
-import { renderHook, act, waitFor } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { MemoryRouter } from 'react-router-dom'
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-
-vi.mock('@/entities/production/api')
-vi.mock('@/entities/report/api')
-
-import { getSimulationState } from '@/entities/production/api'
-import { SimulationFlowProvider } from '../SimulationFlowProvider'
-import { useSimulationFlow } from '../simulationFlow'
-
-const mockGetState = vi.mocked(getSimulationState)
-
-const LS_KEY = 'expria_eo_t1_presentation'
-
-function createWrapper() {
- const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
- return function Wrapper({ children }: { children: React.ReactNode }) {
- return React.createElement(
- MemoryRouter,
- null,
- React.createElement(
- QueryClientProvider,
- { client: queryClient },
- React.createElement(SimulationFlowProvider, null, children),
- ),
- )
- }
-}
-
-beforeEach(() => {
- localStorage.clear()
- vi.clearAllMocks()
- mockGetState.mockRejectedValue(new Error('no resume'))
-})
-
-describe('SimulationFlowProvider T1 — Sprint 4c-2', () => {
- it('setPresentationT1 expose la valeur et la persiste en localStorage', () => {
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- expect(result.current.presentationT1).toBeNull()
-
- act(() => {
- result.current.setPresentationT1('Bonjour je m appelle Marie...')
- })
-
- expect(result.current.presentationT1).toBe('Bonjour je m appelle Marie...')
- expect(localStorage.getItem(LS_KEY)).toBe('Bonjour je m appelle Marie...')
- })
-
- it('setPresentationT1(null) supprime la clé localStorage', () => {
- localStorage.setItem(LS_KEY, 'old')
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.setPresentationT1(null)
- })
-
- expect(result.current.presentationT1).toBeNull()
- expect(localStorage.getItem(LS_KEY)).toBeNull()
- })
-
- it('hydrate presentationT1 depuis localStorage au mount', () => {
- localStorage.setItem(LS_KEY, 'présentation persistée')
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- expect(result.current.presentationT1).toBe('présentation persistée')
- })
-
- it('hydrate la simulation EO_T1 + présentation depuis localStorage au refresh', async () => {
- localStorage.setItem('expria_simulation_id', 'sim-eo-t1-42')
- localStorage.setItem(LS_KEY, 'présentation persistée')
- mockGetState.mockResolvedValueOnce({
- simulation_id: 'sim-eo-t1-42',
- tache: 'EO_T1',
- mode: 'entrainement',
- created_at: '2026-04-25T10:00:00.000Z',
- sujet: null,
- contenu: null,
- rapport: null,
- nclc_cible: 9,
- exercices: null,
- exercices_status: 'pending',
- modele: null,
- modele_status: 'pending',
- })
-
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- await waitFor(() => {
- expect(result.current.production?.id).toBe('sim-eo-t1-42')
- })
- expect(result.current.presentationT1).toBe('présentation persistée')
- expect(result.current.step).toBe('task-selected')
- })
-
- it('reset() remet presentationT1 à null et nettoie localStorage', () => {
- const { result } = renderHook(() => useSimulationFlow(), { wrapper: createWrapper() })
-
- act(() => {
- result.current.setPresentationT1('texte')
- })
- expect(result.current.presentationT1).toBe('texte')
-
- act(() => {
- result.current.reset()
- })
-
- expect(result.current.presentationT1).toBeNull()
- expect(localStorage.getItem(LS_KEY)).toBeNull()
- })
-})
diff --git a/src/features/simulations/state/simulationFlow.ts b/src/features/simulations/state/simulationFlow.ts
deleted file mode 100644
index 3b34c54..0000000
--- a/src/features/simulations/state/simulationFlow.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * Types, contexte et hook du flux simulation.
- *
- * Extrait de SimulationFlowProvider.tsx pour respecter la règle
- * `react-refresh/only-export-components` : un fichier de composant ne peut
- * pas ré-exporter types / hooks / contextes.
- */
-
-import { createContext, useContext } from 'react'
-import type { CreateSimulationPayload, Production, SujetData } from '@/entities/production/types'
-import type { Report } from '@/entities/report/types'
-import type { ApiError } from '@/shared/types/api'
-
-export type SimulationStep =
- | 'idle'
- | 'choosing-subject'
- | 'task-selected'
- | 'recording'
- | 'correcting'
- | 'done'
-
-export interface FlowValue {
- step: SimulationStep
- production: Production | null
- sujet: SujetData | null
- report: Report | null
- isCreating: boolean
- isCorrecting: boolean
- createError: ApiError | null
- correctError: ApiError | null
- /**
- * Sprint 4c-1 — message d'info non bloquant remonté par `selectTask` quand
- * l'utilisateur clique sur une tâche temporairement indisponible (EO_T1
- * dans 4c-1). La tâche n'est pas créée et l'UI affiche le message.
- * Réinitialisé à null à chaque nouvelle action.
- */
- taskUnavailableMessage: string | null
- /**
- * Sprint 4c-2 — texte de présentation T1 généré par DeepSeek (ou édité
- * manuellement par l'utilisateur). Utilisé comme texte de référence
- * affiché pendant l'enregistrement EO_T1. Mirroré aussi dans
- * `localStorage.expria_eo_t1_presentation` pour survivre aux refresh.
- * `null` quand aucune présentation n'a encore été générée pour la session
- * en cours, ou quand l'utilisateur a choisi le mode « enregistrer
- * directement » (sans questionnaire).
- */
- presentationT1: string | null
- setPresentationT1: (text: string | null) => void
- selectTask: (payload: CreateSimulationPayload) => void
- submitText: (texte: string, nclcCible?: 9 | 10) => void
- /**
- * Sprint 4c-1 (transcript live Deepgram) → 4c-3 (audio batch Gemini backend) :
- * envoie l'audio brut en base64 au backend qui transcrit puis corrige. Le
- * paramètre `mimeType` indique le format produit par MediaRecorder.
- */
- submitEoAudio: (audioBase64: string, mimeType: string, nclcCible?: 9 | 10) => void
- changeSubject: (sujet: SujetData) => void
- setStep: (step: SimulationStep) => void
- reset: () => void
-}
-
-export const SimulationFlowContext = createContext(null)
-
-export function useSimulationFlow(): FlowValue {
- const ctx = useContext(SimulationFlowContext)
- if (!ctx) {
- throw new Error('useSimulationFlow doit être utilisé dans un .')
- }
- return ctx
-}
diff --git a/src/features/t1-live/components/T1SpeakingIndicator.tsx b/src/features/t1-live/components/T1SpeakingIndicator.tsx
deleted file mode 100644
index 7a6feb9..0000000
--- a/src/features/t1-live/components/T1SpeakingIndicator.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-/**
- * T1SpeakingIndicator — Indicateur de prise de parole T1 Live (Sprint 7b).
- *
- * Calqué sur T2SpeakingIndicator (mêmes garde-fous Voie A : analyser dérivé du
- * graphe de capture lu par ref en rAF, écriture DOM directe, aucun setState).
- * Sémantique adaptée au monologue T1 :
- *
- * - 'presenting' → le candidat présente : barres pilotées par l'amplitude micro
- * RÉELLE (analyser).
- * - 'interrupted' → l'examinateur a pris la parole : barres décoratives pilotées
- * par l'ÉTAT (CSS), sans sonde audio (l'uplink micro est coupé).
- * - autres états → rien (la page affiche le libellé d'état).
- */
-
-import { useEffect, useRef, type RefObject } from 'react'
-import { Mic, Volume2 } from 'lucide-react'
-import type { T1State } from '../state/t1-machine'
-
-const BAR_COUNT = 5
-
-interface T1SpeakingIndicatorProps {
- /** Analyser dérivé du graphe de capture (par ref, jamais en state). */
- analyserRef: RefObject
- state: T1State
-}
-
-export function T1SpeakingIndicator({ analyserRef, state }: T1SpeakingIndicatorProps) {
- const barRefs = useRef>([])
- const rafRef = useRef(null)
- const dataRef = useRef | null>(null)
-
- // rAF actif UNIQUEMENT en 'presenting' : lit l'analyser micro par ref et écrit
- // la hauteur des barres directement dans le DOM (aucun setState).
- useEffect(() => {
- if (state !== 'presenting') return
- let active = true
-
- const tick = () => {
- if (!active) return
- const analyser = analyserRef.current
- if (analyser) {
- if (!dataRef.current || dataRef.current.length !== analyser.fftSize) {
- dataRef.current = new Uint8Array(analyser.fftSize)
- }
- analyser.getByteTimeDomainData(dataRef.current)
- let sumSq = 0
- for (let i = 0; i < dataRef.current.length; i++) {
- const v = (dataRef.current[i]! - 128) / 128
- sumSq += v * v
- }
- const rms = Math.sqrt(sumSq / dataRef.current.length)
- const now = performance.now()
- for (let i = 0; i < barRefs.current.length; i++) {
- const el = barRefs.current[i]
- if (!el) continue
- const wave = 0.55 + 0.45 * Math.sin(now / 110 + i * 0.9)
- const h = Math.max(14, Math.min(100, 14 + rms * 260 * wave))
- el.style.height = `${h}%`
- }
- }
- rafRef.current = requestAnimationFrame(tick)
- }
- rafRef.current = requestAnimationFrame(tick)
-
- return () => {
- active = false
- if (rafRef.current !== null) {
- cancelAnimationFrame(rafRef.current)
- rafRef.current = null
- }
- }
- }, [state, analyserRef])
-
- if (state === 'presenting') {
- return (
-
-
-
- {Array.from({ length: BAR_COUNT }).map((_, i) => (
- {
- barRefs.current[i] = el
- }}
- className="w-1 rounded-full bg-success"
- style={{ height: '14%' }}
- />
- ))}
-
-
À vous — présentez-vous
-
- )
- }
-
- if (state === 'interrupted') {
- return (
-
-
-
- {Array.from({ length: BAR_COUNT }).map((_, i) => (
-
- ))}
-
-
L’examinateur vous interrompt…
-
- )
- }
-
- return null
-}
diff --git a/src/features/t1-live/hooks/useT1LiveSession.ts b/src/features/t1-live/hooks/useT1LiveSession.ts
deleted file mode 100644
index 251cb01..0000000
--- a/src/features/t1-live/hooks/useT1LiveSession.ts
+++ /dev/null
@@ -1,450 +0,0 @@
-/**
- * useT1LiveSession — Hook orchestrateur du dialogue T1 Live (Sprint 7b).
- *
- * Calqué sur useT2LiveSession (même discipline Voie A : AudioContext unique,
- * jamais de MediaStream en state, flags pilotés par ref dans le chemin audio).
- * MAIS la sémantique T1 diffère fondamentalement :
- *
- * 1. URL `wss://${API_URL}/t1/live?token=` — PAS de `&sujet=` : la Tâche 1
- * n'est PAS subject-based. L'examinateur formule ses relances à partir de ce
- * qu'il ENTEND en temps réel (Patch 7a backend — plus de questionnaire).
- * 2. AUCUN message de contexte. La session audio démarre dès `ws.onopen`
- * (WS_OPENED → presenting) : le candidat envoie directement son audio.
- * 3. AUCUN VAD micro (contrairement à T2). T1 est un MONOLOGUE : c'est le BACKEND
- * (horloge probabiliste) qui décide quand l'examinateur interrompt. Le frontend
- * réagit aux signaux applicatifs `{type:'interruption_start'}` /
- * `{type:'interruption_end'}` → dispatch INTERRUPTION_START / INTERRUPTION_END.
- * 4. Pendant une interruption, l'uplink micro est COUPÉ (l'examinateur a la
- * parole) via un ref (`uplinkMutedRef`) — jamais via setState, pour ne pas
- * perturber le chemin source→worklet→WS (leçon Voie A).
- * 5. Timer dur 180 s côté frontend (redondant avec le backend, warning 150 s
- * émis par `{type:'warning'}`).
- * 6. Gère les close codes (1000, 4001, 4003, 4005, 4006).
- *
- * Validation : test manuel uniquement (WebSocket + AudioContext non testables en
- * jsdom — la logique pure de transition est couverte par t1-machine.test.ts).
- */
-
-import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { env } from '@/shared/config/env'
-import { getAccessToken } from '@/shared/lib/auth-client'
-import { useAudioCapture } from '@/shared/lib/audio/useAudioCapture'
-import { useAudioPlayback } from '@/shared/lib/audio/useAudioPlayback'
-import { useAudioRecording } from '@/shared/lib/audio/useAudioRecording'
-import { transition, T1_INITIAL_STATE, type T1State, type T1Event } from '../state/t1-machine'
-
-const DIALOGUE_TIMEOUT_MS = 180_000 // 3 min
-const WS_PING_INTERVAL_MS = 30_000
-
-export interface UseT1LiveSessionOptions {
- /** Appelé quand le rapport est reçu — l'appelant décide de la navigation. */
- onReportReady?: (simulationId: string) => void
-}
-
-export interface UseT1LiveSessionResult {
- state: T1State
- startDialogue: () => Promise
- endDialogue: () => void
- /** Abandon : ferme le WS sans déclencher d'évaluation ni de persistance. */
- cancelDialogue: () => void
- warning: boolean
- errorMessage: string | null
- simulationId: string | null
- recording: ReturnType
- /** Secondes écoulées depuis l'ouverture du WS — pour le timer affiché. */
- elapsedSeconds: number
- /**
- * AnalyserNode dérivé du graphe de capture (par ref stable) — pour
- * l'indicateur de prise de parole, sans re-render ni sonde sur le flux montant.
- */
- analyserRef: RefObject
-}
-
-interface GeminiPart {
- inlineData?: { data?: string; mimeType?: string }
-}
-interface GeminiServerContent {
- modelTurn?: { parts?: GeminiPart[] }
- inputTranscription?: { text?: string }
- outputTranscription?: { text?: string }
- interrupted?: boolean
- turnComplete?: boolean
-}
-interface GeminiMessage {
- serverContent?: GeminiServerContent
-}
-interface AppMessage {
- type: 'warning' | 'report' | 'error' | 'audio' | 'interruption_start' | 'interruption_end'
- data?: { simulation_id?: string } & Record
- message?: string
- code?: string
-}
-
-function buildWsUrl(token: string): string {
- const base = env.VITE_API_URL.replace(/^http/, 'ws')
- return `${base}/t1/live?token=${encodeURIComponent(token)}`
-}
-
-export function useT1LiveSession(opts: UseT1LiveSessionOptions = {}): UseT1LiveSessionResult {
- const { onReportReady } = opts
- const navigate = useNavigate()
-
- const [state, setState] = useState(T1_INITIAL_STATE)
- const [warning, setWarning] = useState(false)
- const [errorMessage, setErrorMessage] = useState(null)
- const [simulationId, setSimulationId] = useState(null)
- const [elapsedSeconds, setElapsedSeconds] = useState(0)
-
- const wsRef = useRef(null)
- const sessionEndedRef = useRef(false)
- const timeoutTimerRef = useRef | null>(null)
- const elapsedTimerRef = useRef | null>(null)
- const pingTimerRef = useRef | null>(null)
- // Uplink micro coupé pendant une interruption (l'examinateur a la parole).
- // Piloté par ref — JAMAIS setState — pour ne pas perturber le chemin audio
- // source→worklet→WS (leçon Voie A : un setState dans ce chemin affame l'uplink).
- const uplinkMutedRef = useRef(false)
- // Sprint 6d (repris) — token de cancellation pour rendre `startDialogue`
- // idempotent sur les appels rapprochés (StrictMode dev double-mount, etc.).
- const cancelTokenRef = useRef<{ cancelled: boolean } | null>(null)
-
- const recording = useAudioRecording()
-
- // Déclaré avant `capture` car onChunk en dépend.
- const dispatch = useCallback((event: T1Event) => {
- setState((prev) => transition(prev, event))
- }, [])
-
- // Capture branchée à l'envoi WS. Aucun VAD (T1 = monologue) : on transmet le
- // chunk uplink tel quel SAUF pendant une interruption (uplinkMutedRef).
- const capture = useAudioCapture({
- onChunk: (base64: string) => {
- if (uplinkMutedRef.current) return
- const ws = wsRef.current
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'audio', data: base64 }))
- }
- },
- })
-
- // Playback déclaré APRÈS capture : il consomme le contexte + le mix PARTAGÉS
- // (Voie A — horloge unique, voix examinateur routée vers le mix + destination).
- const playback = useAudioPlayback({
- contextRef: capture.contextRef,
- mixNodeRef: capture.mixNodeRef,
- })
-
- const cleanupTimers = useCallback(() => {
- if (timeoutTimerRef.current !== null) {
- clearTimeout(timeoutTimerRef.current)
- timeoutTimerRef.current = null
- }
- if (elapsedTimerRef.current !== null) {
- clearInterval(elapsedTimerRef.current)
- elapsedTimerRef.current = null
- }
- if (pingTimerRef.current !== null) {
- clearInterval(pingTimerRef.current)
- pingTimerRef.current = null
- }
- }, [])
-
- const closeAll = useCallback(() => {
- sessionEndedRef.current = true
- cleanupTimers()
- if (cancelTokenRef.current) {
- cancelTokenRef.current.cancelled = true
- cancelTokenRef.current = null
- }
- capture.stop()
- if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
- try {
- wsRef.current.close()
- } catch {
- /* ignore */
- }
- }
- // playback continue jusqu'à fin de file pour ne pas couper la dernière phrase.
- }, [capture, cleanupTimers])
-
- const handleAudioReceived = useCallback(
- (base64: string) => {
- // Voie A : playChunk route la voix examinateur vers destination ET vers le
- // mixGain ; le tap d'enregistrement la capte sur ce mix en temps réel.
- playback.playChunk(base64)
- },
- [playback],
- )
-
- const handleGeminiMessage = useCallback(
- (msg: GeminiMessage) => {
- const sc = msg.serverContent
- if (!sc) return
- if (sc.modelTurn?.parts) {
- for (const part of sc.modelTurn.parts) {
- if (part.inlineData?.data) {
- handleAudioReceived(part.inlineData.data)
- }
- }
- }
- },
- [handleAudioReceived],
- )
-
- const handleAppMessage = useCallback(
- (msg: AppMessage) => {
- if (msg.type === 'warning') {
- setWarning(true)
- return
- }
- // Interruption NON DÉTERMINISTE pilotée par le backend (jamais déduite côté
- // front). L'examinateur prend / rend la parole : on coupe / rétablit l'uplink
- // micro via ref et on dispatche la transition d'état.
- if (msg.type === 'interruption_start') {
- uplinkMutedRef.current = true
- dispatch({ type: 'INTERRUPTION_START' })
- return
- }
- if (msg.type === 'interruption_end') {
- uplinkMutedRef.current = false
- dispatch({ type: 'INTERRUPTION_END' })
- return
- }
- if (msg.type === 'report' && msg.data) {
- const simId = (msg.data.simulation_id as string | undefined) ?? null
- if (simId) setSimulationId(simId)
- dispatch({ type: 'REPORT_READY' })
- if (simId && onReportReady) onReportReady(simId)
- return
- }
- if (msg.type === 'error') {
- setErrorMessage(msg.message ?? 'Une erreur est survenue.')
- dispatch({ type: 'ERROR', message: msg.message })
- }
- },
- [dispatch, onReportReady],
- )
-
- const handleWsMessage = useCallback(
- (evt: MessageEvent) => {
- let text: string
- if (typeof evt.data === 'string') {
- text = evt.data
- } else if (evt.data instanceof ArrayBuffer) {
- text = new TextDecoder().decode(evt.data)
- } else if (evt.data instanceof Blob) {
- console.warn('[T1] Frame Blob reçu, attendu string/ArrayBuffer')
- return
- } else {
- return
- }
-
- try {
- const parsed = JSON.parse(text) as GeminiMessage & AppMessage
- if (
- parsed.type === 'warning' ||
- parsed.type === 'report' ||
- parsed.type === 'error' ||
- parsed.type === 'interruption_start' ||
- parsed.type === 'interruption_end'
- ) {
- handleAppMessage(parsed as AppMessage)
- } else if (parsed.serverContent) {
- handleGeminiMessage(parsed as GeminiMessage)
- }
- } catch {
- /* JSON malformé — ignorer */
- }
- },
- [handleAppMessage, handleGeminiMessage],
- )
-
- // Indirection par ref : le binding ws.onmessage appelle TOUJOURS le handler
- // courant (immunisé au gel de closure / HMR). Cf. INSTRUMENT REPAIR 6e (T2).
- const handleWsMessageRef = useRef(handleWsMessage)
- useEffect(() => {
- handleWsMessageRef.current = handleWsMessage
- }, [handleWsMessage])
-
- const handleWsClose = useCallback(
- (evt: CloseEvent) => {
- if (sessionEndedRef.current) return
- sessionEndedRef.current = true
- cleanupTimers()
- recording.stop()
- capture.stop()
-
- switch (evt.code) {
- case 1000:
- if (state !== 'ended') {
- dispatch({ type: 'ERROR', message: 'Session interrompue' })
- }
- break
- case 4001:
- setErrorMessage('Authentification expirée.')
- dispatch({ type: 'ERROR', code: 4001 })
- navigate('/login')
- break
- case 4003:
- setErrorMessage('La Tâche 1 Live est réservée au plan Premium.')
- dispatch({ type: 'ERROR', code: 4003 })
- break
- case 4005:
- setErrorMessage('Service temporairement indisponible.')
- dispatch({ type: 'ERROR', code: 4005 })
- break
- case 4006:
- setErrorMessage('Connexion à l’examinateur perdue.')
- dispatch({ type: 'ERROR', code: 4006 })
- break
- default:
- setErrorMessage('Une erreur est survenue. Réessayez dans quelques instants.')
- dispatch({ type: 'ERROR', code: evt.code })
- }
- },
- [capture, cleanupTimers, dispatch, navigate, recording, state],
- )
-
- const startDialogue = useCallback(async () => {
- if (wsRef.current || cancelTokenRef.current) return
- const localToken = { cancelled: false }
- cancelTokenRef.current = localToken
-
- setErrorMessage(null)
- setWarning(false)
- sessionEndedRef.current = false
- uplinkMutedRef.current = false
- dispatch({ type: 'START_DIALOGUE' })
-
- const token = await getAccessToken()
- if (localToken.cancelled) return
- if (!token) {
- cancelTokenRef.current = null
- setErrorMessage('Authentification requise.')
- dispatch({ type: 'ERROR', code: 4001 })
- navigate('/login')
- return
- }
-
- let ws: WebSocket
- try {
- ws = new WebSocket(buildWsUrl(token))
- ws.binaryType = 'arraybuffer'
- } catch (err) {
- cancelTokenRef.current = null
- const message = err instanceof Error ? err.message : 'Connexion impossible'
- setErrorMessage(message)
- dispatch({ type: 'ERROR' })
- return
- }
- if (localToken.cancelled) {
- try {
- ws.close()
- } catch {
- /* ignore */
- }
- return
- }
- wsRef.current = ws
-
- ws.onopen = () => {
- // Aucun message de contexte (Patch 7a backend) : la session audio démarre
- // dès l'ouverture du WS, le candidat a la parole (monologue).
- dispatch({ type: 'WS_OPENED' })
-
- // Démarrer la capture micro PUIS brancher le tap d'enregistrement sur le
- // contexte + mixGain (qui n'existent qu'après résolution de capture.start()).
- void capture.start().then(() => {
- const ctx = capture.contextRef.current
- const mix = capture.mixNodeRef.current
- if (ctx && mix) {
- recording.reset()
- void recording.start(ctx, mix)
- }
- })
-
- const startTime = Date.now()
- elapsedTimerRef.current = setInterval(() => {
- setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000))
- }, 250)
-
- // Timeout dur frontend (redondance avec le 180 s backend).
- timeoutTimerRef.current = setTimeout(() => {
- if (sessionEndedRef.current) return
- if (ws.readyState === WebSocket.OPEN) {
- try {
- ws.send(JSON.stringify({ type: 'end' }))
- } catch {
- /* ignore */
- }
- }
- dispatch({ type: 'END_REQUESTED' })
- }, DIALOGUE_TIMEOUT_MS)
-
- pingTimerRef.current = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- try {
- ws.send(JSON.stringify({ type: 'ping' }))
- } catch {
- /* ignore */
- }
- }
- }, WS_PING_INTERVAL_MS)
- }
- ws.onmessage = (evt) => handleWsMessageRef.current(evt)
- ws.onclose = handleWsClose
- ws.onerror = () => {
- // 'close' suit toujours 'error' — gestion centralisée dans handleWsClose.
- }
- }, [capture, dispatch, handleWsClose, navigate, recording])
-
- const endDialogue = useCallback(() => {
- if (sessionEndedRef.current) return
- cleanupTimers()
- recording.stop()
- capture.stop()
- if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
- try {
- wsRef.current.send(JSON.stringify({ type: 'end' }))
- } catch {
- /* ignore */
- }
- }
- dispatch({ type: 'END_REQUESTED' })
- }, [capture, cleanupTimers, dispatch, recording])
-
- // Abandon utilisateur : ferme le WS SANS envoyer `{type:'end'}` → le backend ne
- // déclenche NI correction NI persistance. Machine → 'idle' via CANCEL.
- const cancelDialogue = useCallback(() => {
- if (sessionEndedRef.current) return
- closeAll()
- playback.stop()
- dispatch({ type: 'CANCEL' })
- }, [closeAll, playback, dispatch])
-
- // Cleanup au démontage UNIQUEMENT (cf. T2 : ref tenant la dernière version de
- // closeAll + effet à deps vides, pour ne pas fermer le WS à chaque render).
- const closeAllRef = useRef(closeAll)
- useEffect(() => {
- closeAllRef.current = closeAll
- })
- useEffect(() => {
- return () => {
- closeAllRef.current()
- }
- }, [])
-
- return {
- state,
- startDialogue,
- endDialogue,
- cancelDialogue,
- warning,
- errorMessage,
- simulationId,
- recording,
- elapsedSeconds,
- analyserRef: capture.analyserRef,
- }
-}
diff --git a/src/features/t1-live/pages/T1DialoguePage.tsx b/src/features/t1-live/pages/T1DialoguePage.tsx
deleted file mode 100644
index 43a6953..0000000
--- a/src/features/t1-live/pages/T1DialoguePage.tsx
+++ /dev/null
@@ -1,237 +0,0 @@
-/**
- * Page /simulation/eo/t1/live/dialogue — phase de dialogue live T1 (Sprint 7b).
- *
- * Démarre la session WS au mount, pilote l'UI selon l'état machine T1, affiche le
- * timer 3:00 et l'indicateur d'état. Spécificité T1 : l'examinateur peut
- * INTERROMPRE le monologue de façon NON DÉTERMINISTE (état `interrupted`) — l'UI
- * ne suppose JAMAIS qu'une relance suit. À la fin (REPORT_READY), écran terminal
- * avec « Télécharger l'audio » + « Voir le rapport ».
- */
-
-import { useEffect, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Mic, Volume2, Download, FileText, Loader2 } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { Card } from '@/shared/ui/Card'
-import { T1SpeakingIndicator } from '../components/T1SpeakingIndicator'
-import { useT1LiveSession } from '../hooks/useT1LiveSession'
-
-const DIALOGUE_SECONDS = 180 // 3:00
-
-function formatMmSs(totalSeconds: number): string {
- const remaining = Math.max(0, totalSeconds)
- const m = Math.floor(remaining / 60)
- const s = remaining % 60
- return `${m}:${s.toString().padStart(2, '0')}`
-}
-
-export function T1DialoguePage() {
- const navigate = useNavigate()
- const [autoStarted, setAutoStarted] = useState(false)
-
- const session = useT1LiveSession()
-
- // Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
- useEffect(() => {
- if (autoStarted) return
- setAutoStarted(true)
- void session.startDialogue()
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [autoStarted])
-
- const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
- const stateLabel = (() => {
- switch (session.state) {
- case 'idle':
- case 'connecting':
- return 'Connexion à l’examinateur…'
- case 'presenting':
- return 'À vous — présentez-vous.'
- case 'interrupted':
- return 'L’examinateur vous interrompt — répondez-lui.'
- case 'processing':
- return 'Évaluation en cours…'
- case 'ended':
- return 'Session terminée.'
- case 'error':
- return 'Erreur.'
- case 'preparing':
- return 'Préparation…'
- }
- })()
-
- function handleDownload() {
- const blob = session.recording.exportWAV()
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `expria-t1-${new Date().toISOString().slice(0, 10)}.wav`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
-
- function handleViewReport() {
- if (!session.simulationId) return
- navigate(`/rapport/${session.simulationId}`)
- }
-
- function handleRestart() {
- navigate('/simulation/eo/t1/live/preparation')
- }
-
- // Abandon : ferme la session sans évaluation (cancelDialogue ne déclenche ni
- // correction ni persistance), puis sortie du flux.
- function handleCancel() {
- session.cancelDialogue()
- navigate('/simulation/eo')
- }
-
- // « Annuler » / « Terminer » n'ont de sens que pendant la session active
- // (connexion, présentation ou interruption), pas en évaluation.
- const canCancel =
- session.state === 'connecting' ||
- session.state === 'presenting' ||
- session.state === 'interrupted'
-
- // ── État terminal : rapport prêt ─────────────────────────────────────────
- if (session.state === 'ended') {
- return (
-
-
- Session terminée
-
-
- Votre présentation a été évaluée. Vous pouvez télécharger l'enregistrement audio avant
- de consulter le rapport.
-
-
- Durée enregistrée :{' '}
-
- {session.recording.durationSeconds.toFixed(1)} s
-
-
-
-
- }
- onClick={handleDownload}
- disabled={session.recording.durationSeconds === 0}
- >
- Télécharger l'audio
-
- }
- onClick={handleViewReport}
- disabled={!session.simulationId}
- >
- Voir le rapport
-
-
-
-
- )
- }
-
- // ── État erreur ──────────────────────────────────────────────────────────
- if (session.state === 'error') {
- return (
-
-
- Erreur
-
- {session.errorMessage ?? 'Une erreur est survenue.'}
-
-
-
- Recommencer
-
-
-
-
- )
- }
-
- // ── État dialogue actif ──────────────────────────────────────────────────
- return (
-
-
-
-
Présentation en cours
-
- {formatMmSs(remaining)}
-
-
-
- {session.warning && (
-
- ⏱ 30 secondes restantes.
-
- )}
-
-
-
- {session.state === 'processing' ? (
-
- ) : session.state === 'interrupted' ? (
-
- ) : (
-
- )}
-
{stateLabel}
-
- {/* Indicateur : 'presenting' = amplitude micro réelle (analyser lu par
- ref en rAF) ; 'interrupted' = animation décorative (uplink coupé). */}
- {canCancel && (
-
- )}
-
- Présentez-vous naturellement. L'examinateur peut vous interrompre à tout moment.
-
-
-
-
- {canCancel && (
-
- Annuler
-
- )}
- session.endDialogue()}
- disabled={session.state === 'processing'}
- >
- Terminer
-
-
-
- « Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation.
-
-
-
- )
-}
diff --git a/src/features/t1-live/pages/T1PreparationPage.tsx b/src/features/t1-live/pages/T1PreparationPage.tsx
deleted file mode 100644
index 2afb405..0000000
--- a/src/features/t1-live/pages/T1PreparationPage.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * Page /simulation/eo/t1/live/preparation — préparation T1 Live (Sprint 7b).
- *
- * Calquée sur T2PreparationPage MAIS sans « Suggestions d'idées » (outil de
- * questions de service propre au T2, inapplicable à un monologue de présentation
- * — DÉCISION 3). La prépa T1 = rappel des points de présentation (les 5 réponses
- * saisies) + pré-warm micro + timer.
- *
- * - Timer 2 min visible (countdown), auto-navigation à 0:00.
- * - Pré-warm permission micro pour éviter la latence au début du dialogue.
- * - Entrée directe depuis la carte TaskSelector (plus de questionnaire — Patch 7a).
- */
-
-import { useEffect, useRef, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Button } from '@/shared/ui/Button'
-import { Card } from '@/shared/ui/Card'
-
-const PREPARATION_SECONDS = 120
-
-function formatMmSs(totalSeconds: number): string {
- const m = Math.floor(totalSeconds / 60)
- const s = totalSeconds % 60
- return `${m}:${s.toString().padStart(2, '0')}`
-}
-
-export function T1PreparationPage() {
- const navigate = useNavigate()
-
- const [secondsLeft, setSecondsLeft] = useState(PREPARATION_SECONDS)
- const [micWarmed, setMicWarmed] = useState(null)
- const expiredRef = useRef(false)
-
- // Pré-warm permission micro (cf. T2PreparationPage — évite la latence à 0:00).
- useEffect(() => {
- let cancelled = false
- let stream: MediaStream | null = null
- void navigator.mediaDevices
- .getUserMedia({
- audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
- })
- .then((s) => {
- stream = s
- if (cancelled) {
- s.getTracks().forEach((t) => t.stop())
- return
- }
- s.getTracks().forEach((t) => t.stop())
- setMicWarmed(true)
- })
- .catch(() => {
- if (!cancelled) setMicWarmed(false)
- })
- return () => {
- cancelled = true
- if (stream) stream.getTracks().forEach((t) => t.stop())
- }
- }, [])
-
- // Timer countdown 2 min → auto-navigation vers le dialogue.
- useEffect(() => {
- const id = setInterval(() => {
- setSecondsLeft((s) => {
- if (s <= 1) {
- clearInterval(id)
- if (!expiredRef.current) {
- expiredRef.current = true
- queueMicrotask(() => navigate('/simulation/eo/t1/live/dialogue'))
- }
- return 0
- }
- return s - 1
- })
- }, 1000)
- return () => clearInterval(id)
- }, [navigate])
-
- function handleReady() {
- navigate('/simulation/eo/t1/live/dialogue')
- }
-
- return (
-
-
-
-
Préparation — Tâche 1 Live
-
- {formatMmSs(secondsLeft)}
-
-
-
-
-
- Comment ça se passe : à votre signal,
- présentez-vous à l'oral en quelques phrases. L'examinateur IA pourra vous interrompre
- spontanément pour vous poser des questions — répondez-lui, puis poursuivez votre
- présentation.
-
-
-
- {micWarmed === false && (
-
- Accès au micro refusé. Activez-le dans les paramètres du navigateur avant de démarrer le
- dialogue.
-
- )}
-
-
-
- Je suis prêt — démarrer la présentation
-
-
-
-
- )
-}
diff --git a/src/features/t1-live/state/__tests__/t1-machine.test.ts b/src/features/t1-live/state/__tests__/t1-machine.test.ts
deleted file mode 100644
index e27e304..0000000
--- a/src/features/t1-live/state/__tests__/t1-machine.test.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { transition, T1_INITIAL_STATE } from '../t1-machine'
-import type { T1State } from '../t1-machine'
-
-describe('T1 state machine — transitions nominales', () => {
- it('idle → preparing sur START_PREPARATION', () => {
- expect(transition('idle', { type: 'START_PREPARATION' })).toBe('preparing')
- })
-
- it('preparing → connecting sur START_DIALOGUE', () => {
- expect(transition('preparing', { type: 'START_DIALOGUE' })).toBe('connecting')
- })
-
- it('idle → connecting sur START_DIALOGUE (saut de prépa autorisé)', () => {
- expect(transition('idle', { type: 'START_DIALOGUE' })).toBe('connecting')
- })
-
- it('connecting → presenting sur WS_OPENED (candidat a la parole)', () => {
- expect(transition('connecting', { type: 'WS_OPENED' })).toBe('presenting')
- })
-
- it('processing → ended sur REPORT_READY', () => {
- expect(transition('processing', { type: 'REPORT_READY' })).toBe('ended')
- })
-})
-
-describe('T1 state machine — interruption / reprise (cœur du sprint)', () => {
- it('presenting → interrupted sur INTERRUPTION_START', () => {
- expect(transition('presenting', { type: 'INTERRUPTION_START' })).toBe('interrupted')
- })
-
- it('interrupted → presenting sur INTERRUPTION_END', () => {
- expect(transition('interrupted', { type: 'INTERRUPTION_END' })).toBe('presenting')
- })
-
- it('cycle complet presenting ⇄ interrupted (plusieurs relances possibles)', () => {
- let s: T1State = 'presenting'
- s = transition(s, { type: 'INTERRUPTION_START' })
- expect(s).toBe('interrupted')
- s = transition(s, { type: 'INTERRUPTION_END' })
- expect(s).toBe('presenting')
- s = transition(s, { type: 'INTERRUPTION_START' })
- expect(s).toBe('interrupted')
- s = transition(s, { type: 'INTERRUPTION_END' })
- expect(s).toBe('presenting')
- })
-
- it('chemin SANS interruption est nominal (presenting → processing → ended)', () => {
- // Non déterminisme P0=0.2 : une session peut n'avoir AUCUNE relance.
- let s: T1State = 'presenting'
- s = transition(s, { type: 'END_REQUESTED' })
- expect(s).toBe('processing')
- s = transition(s, { type: 'REPORT_READY' })
- expect(s).toBe('ended')
- })
-
- it('INTERRUPTION_START est ignoré hors de presenting', () => {
- expect(transition('interrupted', { type: 'INTERRUPTION_START' })).toBe('interrupted')
- expect(transition('connecting', { type: 'INTERRUPTION_START' })).toBe('connecting')
- expect(transition('processing', { type: 'INTERRUPTION_START' })).toBe('processing')
- })
-
- it('INTERRUPTION_END est ignoré hors de interrupted', () => {
- expect(transition('presenting', { type: 'INTERRUPTION_END' })).toBe('presenting')
- })
-})
-
-describe('T1 state machine — END_REQUESTED → processing depuis tout état actif', () => {
- it.each(['connecting', 'presenting', 'interrupted'])(
- 'transition %s → processing sur END_REQUESTED',
- (from) => {
- expect(transition(from, { type: 'END_REQUESTED' })).toBe('processing')
- },
- )
-})
-
-describe('T1 state machine — ERROR terminal', () => {
- it.each(['idle', 'preparing', 'connecting', 'presenting', 'interrupted', 'processing'])(
- 'transition %s → error sur ERROR',
- (from) => {
- expect(transition(from, { type: 'ERROR', code: 4006 })).toBe('error')
- },
- )
-
- it('ended est insensible à ERROR (état terminal protégé)', () => {
- expect(transition('ended', { type: 'ERROR', code: 4006 })).toBe('ended')
- })
-})
-
-describe('T1 state machine — CANCEL (abandon) → idle depuis tout état actif', () => {
- it.each(['preparing', 'connecting', 'presenting', 'interrupted', 'processing'])(
- 'transition %s → idle sur CANCEL',
- (from) => {
- expect(transition(from, { type: 'CANCEL' })).toBe('idle')
- },
- )
-
- it('CANCEL en idle reste idle (no-op)', () => {
- expect(transition('idle', { type: 'CANCEL' })).toBe('idle')
- })
-
- it('états terminaux (ended, error) sont protégés contre CANCEL', () => {
- expect(transition('ended', { type: 'CANCEL' })).toBe('ended')
- expect(transition('error', { type: 'CANCEL' })).toBe('error')
- })
-})
-
-describe('T1 state machine — événements invalides ignorés', () => {
- it('INTERRUPTION_START en idle est ignoré', () => {
- expect(transition('idle', { type: 'INTERRUPTION_START' })).toBe('idle')
- })
-
- it('REPORT_READY en presenting est ignoré (doit passer par END_REQUESTED → processing)', () => {
- expect(transition('presenting', { type: 'REPORT_READY' })).toBe('presenting')
- })
-
- it('états terminaux (ended, error) sont insensibles aux events nominaux', () => {
- expect(transition('ended', { type: 'INTERRUPTION_START' })).toBe('ended')
- expect(transition('error', { type: 'WS_OPENED' })).toBe('error')
- })
-})
-
-describe('T1_INITIAL_STATE', () => {
- it('vaut idle', () => {
- expect(T1_INITIAL_STATE).toBe('idle')
- })
-})
diff --git a/src/features/t1-live/state/t1-machine.ts b/src/features/t1-live/state/t1-machine.ts
deleted file mode 100644
index 4475b03..0000000
--- a/src/features/t1-live/state/t1-machine.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * t1-machine — State machine pure pour le flux T1 Live (Sprint 7b).
- *
- * Calquée sur t2-machine (même discipline : fonction pure, aucun side-effect,
- * aucune référence DOM/React/WebSocket — l'orchestration vit dans
- * useT1LiveSession). MAIS la sémantique diffère fondamentalement :
- *
- * - T2 : le candidat initie une interaction de service ; les états
- * speaking/listening sont pilotés par le VAD micro côté frontend.
- * - T1 : le candidat fait un MONOLOGUE de présentation. C'est le BACKEND
- * (horloge probabiliste, Sprint 7a) qui décide QUAND l'examinateur
- * interrompt. Le frontend ne fait pas de VAD : il réagit aux signaux
- * {type:'interruption_start'} / {type:'interruption_end'}.
- *
- * CŒUR DU SPRINT : l'interruption / reprise est modélisée ICI, dans la machine,
- * via l'état `interrupted`. L'interruption est NON DÉTERMINISTE (P0=0.2,
- * P1=0.6, P2=0.2) — la machine ne suppose JAMAIS qu'une relance suit : le
- * chemin `presenting → processing → ended` SANS aucune interruption est
- * parfaitement nominal.
- *
- * Cycle de vie d'une session :
- *
- * idle
- * └─ START_PREPARATION ─▶ preparing
- * └─ START_DIALOGUE ─▶ connecting
- * └─ WS_OPENED ─▶ presenting
- * ├─ INTERRUPTION_START ─▶ interrupted
- * │ └─ INTERRUPTION_END ─▶ presenting
- * ├─ END_REQUESTED ─▶ processing
- * │ └─ REPORT_READY ─▶ ended
- * └─ ERROR ─▶ error
- *
- * Toute transition non listée est ignorée (état conservé).
- */
-
-export type T1State =
- | 'idle'
- | 'preparing'
- | 'connecting'
- | 'presenting'
- | 'interrupted'
- | 'processing'
- | 'ended'
- | 'error'
-
-export type T1Event =
- | { type: 'START_PREPARATION' }
- | { type: 'START_DIALOGUE' }
- // WS_OPENED — la socket est ouverte : le candidat a la parole (monologue).
- // Pas d'état 'ready' distinct comme en T2, car aucun VAD ne pilote la prise
- // de parole, et aucun message de contexte n'est envoyé (Patch 7a backend).
- | { type: 'WS_OPENED' }
- // INTERRUPTION_START / END — pilotés par les signaux backend (jamais déduits
- // côté frontend). L'examinateur prend / rend la parole.
- | { type: 'INTERRUPTION_START' }
- | { type: 'INTERRUPTION_END' }
- | { type: 'END_REQUESTED' }
- | { type: 'REPORT_READY' }
- // CANCEL — abandon utilisateur : ferme la session SANS évaluation (cf.
- // useT1LiveSession.cancelDialogue, qui ferme le WS sans envoyer `{type:'end'}`).
- // La machine revient à 'idle'.
- | { type: 'CANCEL' }
- | { type: 'ERROR'; code?: number; message?: string }
-
-/**
- * Transition pure : (state, event) → newState.
- *
- * Les événements `WARNING` (timer 30 s restantes) ne sont pas modélisés ici
- * car ils n'affectent pas l'état — ils déclenchent un side-effect d'affichage
- * géré directement par le hook orchestrateur.
- */
-export function transition(state: T1State, event: T1Event): T1State {
- // ERROR est terminal et bypasse tous les guards : peut être émis depuis
- // n'importe quel état non-terminal.
- if (event.type === 'ERROR' && state !== 'ended') {
- return 'error'
- }
-
- // CANCEL (abandon) bypasse les guards depuis tout état non-terminal et
- // ramène la machine à 'idle'. Les états terminaux ('ended', 'error') sont
- // protégés.
- if (event.type === 'CANCEL' && state !== 'ended' && state !== 'error') {
- return 'idle'
- }
-
- switch (state) {
- case 'idle':
- if (event.type === 'START_PREPARATION') return 'preparing'
- // Permet de sauter la prépa si l'appelant le souhaite.
- if (event.type === 'START_DIALOGUE') return 'connecting'
- return state
-
- case 'preparing':
- if (event.type === 'START_DIALOGUE') return 'connecting'
- return state
-
- case 'connecting':
- if (event.type === 'WS_OPENED') return 'presenting'
- if (event.type === 'END_REQUESTED') return 'processing'
- return state
-
- case 'presenting':
- if (event.type === 'INTERRUPTION_START') return 'interrupted'
- if (event.type === 'END_REQUESTED') return 'processing'
- return state
-
- case 'interrupted':
- if (event.type === 'INTERRUPTION_END') return 'presenting'
- // « Terminer » ou le timeout 180 s peuvent tomber pendant une relance.
- if (event.type === 'END_REQUESTED') return 'processing'
- return state
-
- case 'processing':
- if (event.type === 'REPORT_READY') return 'ended'
- return state
-
- case 'ended':
- case 'error':
- // États terminaux — aucune transition.
- return state
- }
-}
-
-/** État initial de toute session T1 Live. */
-export const T1_INITIAL_STATE: T1State = 'idle'
diff --git a/src/features/t2-live/components/T2SpeakingIndicator.tsx b/src/features/t2-live/components/T2SpeakingIndicator.tsx
deleted file mode 100644
index e494dd1..0000000
--- a/src/features/t2-live/components/T2SpeakingIndicator.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * T2SpeakingIndicator — Indicateur de prise de parole T2 Live (Sprint 6e, ré-impl).
- *
- * Remplace l'ancien RecordingWaveform (Step 4 neutralisé : il ouvrait son propre
- * AudioContext + republiait le stream en state réactif → re-renders →
- * ré-exécution d'effects → famine du flux montant micro→Gemini).
- *
- * Garde-fous (la régression précédente venait de leur violation) :
- * 1. Le stream micro reste en ref dans useAudioCapture, jamais en state.
- * 2. L'AnalyserNode est une DÉRIVATION du graphe de capture (source.connect en
- * parallèle du worklet) ; il ne s'insère PAS dans le chemin montant.
- * 3. La lecture d'amplitude se fait en requestAnimationFrame, lue par ref, et
- * écrit la hauteur des barres DIRECTEMENT dans le DOM → aucun setState,
- * aucun re-render du parent.
- * 4. Le rAF s'arrête au changement d'état et au démontage (pas d'orphelin) ;
- * l'analyser lui-même est libéré par le cleanup de useAudioCapture.
- *
- * Comportement par état :
- * - 'ready' → signal de départ « À vous de parler » (point pulsant).
- * - 'speaking' → barres pilotées par l'amplitude micro RÉELLE (analyser).
- * - 'listening' → barres décoratives pilotées par l'ÉTAT (CSS), sans sonde audio.
- */
-
-import { useEffect, useRef, type RefObject } from 'react'
-import { Mic, Volume2 } from 'lucide-react'
-import type { T2State } from '../state/t2-machine'
-
-const BAR_COUNT = 5
-
-interface T2SpeakingIndicatorProps {
- /** Analyser dérivé du graphe de capture (par ref, jamais en state). */
- analyserRef: RefObject
- state: T2State
-}
-
-export function T2SpeakingIndicator({ analyserRef, state }: T2SpeakingIndicatorProps) {
- const barRefs = useRef>([])
- const rafRef = useRef(null)
- const dataRef = useRef | null>(null)
-
- // rAF actif UNIQUEMENT en 'speaking' : lit l'analyser micro par ref et écrit
- // la hauteur des barres directement dans le DOM (aucun setState).
- useEffect(() => {
- if (state !== 'speaking') return
- let active = true
-
- const tick = () => {
- if (!active) return
- const analyser = analyserRef.current
- if (analyser) {
- if (!dataRef.current || dataRef.current.length !== analyser.fftSize) {
- dataRef.current = new Uint8Array(analyser.fftSize)
- }
- analyser.getByteTimeDomainData(dataRef.current)
- // RMS de l'onde temporelle (centrée sur 128) → 0..1.
- let sumSq = 0
- for (let i = 0; i < dataRef.current.length; i++) {
- const v = (dataRef.current[i]! - 128) / 128
- sumSq += v * v
- }
- const rms = Math.sqrt(sumSq / dataRef.current.length)
- const now = performance.now()
- for (let i = 0; i < barRefs.current.length; i++) {
- const el = barRefs.current[i]
- if (!el) continue
- // Onde par barre pour un rendu vivant, modulée par l'amplitude réelle.
- const wave = 0.55 + 0.45 * Math.sin(now / 110 + i * 0.9)
- const h = Math.max(14, Math.min(100, 14 + rms * 260 * wave))
- el.style.height = `${h}%`
- }
- }
- rafRef.current = requestAnimationFrame(tick)
- }
- rafRef.current = requestAnimationFrame(tick)
-
- return () => {
- active = false
- if (rafRef.current !== null) {
- cancelAnimationFrame(rafRef.current)
- rafRef.current = null
- }
- }
- }, [state, analyserRef])
-
- if (state === 'ready') {
- return (
-
-
-
-
-
-
À vous de parler
-
- )
- }
-
- if (state === 'listening') {
- return (
-
-
-
- {Array.from({ length: BAR_COUNT }).map((_, i) => (
-
- ))}
-
-
L’examinateur parle…
-
- )
- }
-
- if (state === 'speaking') {
- return (
-
-
-
- {Array.from({ length: BAR_COUNT }).map((_, i) => (
- {
- barRefs.current[i] = el
- }}
- className="w-1 rounded-full bg-success"
- style={{ height: '14%' }}
- />
- ))}
-
-
- )
- }
-
- return null
-}
diff --git a/src/features/t2-live/hooks/useT2LiveSession.ts b/src/features/t2-live/hooks/useT2LiveSession.ts
deleted file mode 100644
index e725ab2..0000000
--- a/src/features/t2-live/hooks/useT2LiveSession.ts
+++ /dev/null
@@ -1,544 +0,0 @@
-/**
- * useT2LiveSession — Hook orchestrateur du dialogue T2 Live (Sprint 6c).
- *
- * Responsabilités :
- * 1. Ouvre la WebSocket vers `wss://${API_URL}/t2/live?token=&sujet=`
- * au démarrage du dialogue (PAS pendant la prépa).
- * 2. Branche les hooks audio :
- * - useAudioCapture → onChunk → ws.send + addCandidateChunk(recording)
- * - useAudioPlayback → playChunk(base64 IA reçu)
- * - useAudioRecording → buffer chronologique pour exportWAV
- * 3. Parse les messages WS :
- * - Frames Gemini natifs forwardés (serverContent.modelTurn.parts.inlineData)
- * - Messages applicatifs backend (warning, report, error)
- * 4. Pilote la state machine T2 (ready → speaking → listening → processing → ended).
- * 5. Timer 210 s côté frontend (redondant avec le timer backend, ceinture+bretelles).
- * 6. Gère les close codes (1000, 4001, 4003, 4004, 4005, 4006).
- *
- * Validation : test manuel uniquement (WebSocket + AudioContext non testables en jsdom).
- */
-
-import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { env } from '@/shared/config/env'
-import { getAccessToken } from '@/shared/lib/auth-client'
-import { useAudioCapture } from '@/shared/lib/audio/useAudioCapture'
-import { useAudioPlayback } from '@/shared/lib/audio/useAudioPlayback'
-import { useAudioRecording } from '@/shared/lib/audio/useAudioRecording'
-import { transition, T2_INITIAL_STATE, type T2State, type T2Event } from '../state/t2-machine'
-
-const DIALOGUE_TIMEOUT_MS = 210_000 // 3 min 30
-const WS_PING_INTERVAL_MS = 30_000
-
-// Sprint 6e — VAD micro qui pilote les états speaking/listening de la machine.
-// RMS sur l'Int16 brut (déjà décodé pour le recording), avec hystérésis pour
-// éviter le flapping : on entre en 'speaking' au-dessus de SPEAK_RMS, on ne
-// déclare USER_SILENT qu'après SILENCE_DEBOUNCE_MS *soutenus* sous SILENCE_RMS.
-// La zone morte [SILENCE_RMS, SPEAK_RMS] absorbe les micro-pauses intra-phrase.
-const VAD_SPEAK_RMS = 500
-const VAD_SILENCE_RMS = 250
-const VAD_SILENCE_DEBOUNCE_MS = 700
-
-export interface UseT2LiveSessionOptions {
- sujetId: string
- /** Appelé quand le rapport est reçu — l'appelant décide de la navigation. */
- onReportReady?: (simulationId: string) => void
-}
-
-export interface UseT2LiveSessionResult {
- state: T2State
- startDialogue: () => Promise
- endDialogue: () => void
- /** Abandon : ferme le WS sans déclencher d'évaluation ni de persistance. */
- cancelDialogue: () => void
- warning: boolean
- errorMessage: string | null
- simulationId: string | null
- recording: ReturnType
- /** Secondes écoulées depuis l'ouverture du WS — pour le timer affiché. */
- elapsedSeconds: number
- /**
- * AnalyserNode dérivé du graphe de capture (par ref stable) — pour
- * l'indicateur de prise de parole, sans re-render ni sonde sur le flux montant.
- */
- analyserRef: RefObject
-}
-
-interface GeminiPart {
- inlineData?: { data?: string; mimeType?: string }
-}
-interface GeminiServerContent {
- modelTurn?: { parts?: GeminiPart[] }
- inputTranscription?: { text?: string }
- outputTranscription?: { text?: string }
- interrupted?: boolean
- turnComplete?: boolean
-}
-interface GeminiMessage {
- serverContent?: GeminiServerContent
-}
-interface AppMessage {
- type: 'warning' | 'report' | 'error' | 'audio'
- data?: { simulation_id?: string } & Record
- message?: string
- code?: string
-}
-
-function buildWsUrl(token: string, sujetId: string): string {
- const base = env.VITE_API_URL.replace(/^http/, 'ws')
- return `${base}/t2/live?token=${encodeURIComponent(token)}&sujet=${encodeURIComponent(sujetId)}`
-}
-
-export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessionResult {
- const { sujetId, onReportReady } = opts
- const navigate = useNavigate()
-
- const [state, setState] = useState(T2_INITIAL_STATE)
- const [warning, setWarning] = useState(false)
- const [errorMessage, setErrorMessage] = useState(null)
- const [simulationId, setSimulationId] = useState(null)
- const [elapsedSeconds, setElapsedSeconds] = useState(0)
-
- const wsRef = useRef(null)
- const sessionEndedRef = useRef(false)
- const timeoutTimerRef = useRef | null>(null)
- const elapsedTimerRef = useRef | null>(null)
- const pingTimerRef = useRef | null>(null)
- const userSpeakingTimerRef = useRef | null>(null)
- // Edge-tracking du VAD micro : true = on a déjà dispatché USER_SPEAKING pour
- // la prise de parole en cours. Garantit un dispatch par FRONT (pas par chunk).
- const micSpeakingRef = useRef(false)
- // Sprint 6d — token de cancellation pour rendre `startDialogue` idempotent
- // sur les appels rapprochés (StrictMode dev double-mount, double-clic, etc.).
- // Si une connexion est déjà en cours (token non null), un second appel est
- // no-op. Le cleanup d'unmount invalide le token + ferme tout WS en flight.
- const cancelTokenRef = useRef<{ cancelled: boolean } | null>(null)
-
- const recording = useAudioRecording()
-
- // Horodatage du dernier chunk audio IA reçu. Sert au VAD : un nouveau chunk
- // après > 800 ms de silence IA marque le début d'une réplique de l'examinateur
- // (newTurn) → on réaligne l'edge-tracking micro et on quitte 'speaking'.
- const lastAiChunkTsRef = useRef(0)
-
- // Déclaré avant `capture` car onChunk infère les transitions de la machine.
- const dispatch = useCallback((event: T2Event) => {
- setState((prev) => transition(prev, event))
- }, [])
-
- // Capture branchée à l'envoi WS + au buffer recording.
- const capture = useAudioCapture({
- onChunk: (base64: string) => {
- const ws = wsRef.current
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'audio', data: base64 }))
- }
- // Décoder le chunk uplink pour le VAD micro UNIQUEMENT (Sprint 6e Voie A :
- // l'enregistrement WAV ne passe plus par ici — il est prélevé en temps réel
- // par le tap sur le mixGain, cf. useAudioRecording).
- try {
- const binary = atob(base64)
- const bytes = new Uint8Array(binary.length)
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
-
- // VAD micro → pilote speaking/listening. RMS sur l'Int16 brut (lecture
- // seule du buffer décodé : aucun setState dans le chemin source→worklet→WS,
- // qui reste strictement inchangé). Dispatch par FRONT
- // uniquement (micSpeakingRef) + debounce franc avant USER_SILENT, donc
- // pas de rafale de re-renders (garde-fou Step 4).
- const samples = new Int16Array(bytes.buffer)
- let sumSq = 0
- for (let i = 0; i < samples.length; i++) sumSq += samples[i]! * samples[i]!
- const rms = Math.sqrt(sumSq / Math.max(1, samples.length))
- if (rms > VAD_SPEAK_RMS) {
- // Voix présente : annule un éventuel armement de silence et, si c'est
- // un nouveau front, passe en 'speaking'.
- if (userSpeakingTimerRef.current !== null) {
- clearTimeout(userSpeakingTimerRef.current)
- userSpeakingTimerRef.current = null
- }
- if (!micSpeakingRef.current) {
- micSpeakingRef.current = true
- dispatch({ type: 'USER_SPEAKING' })
- }
- } else if (rms < VAD_SILENCE_RMS) {
- // Sous le plancher : arme une seule fois un timer de silence soutenu.
- if (micSpeakingRef.current && userSpeakingTimerRef.current === null) {
- userSpeakingTimerRef.current = setTimeout(() => {
- userSpeakingTimerRef.current = null
- micSpeakingRef.current = false
- dispatch({ type: 'USER_SILENT' })
- }, VAD_SILENCE_DEBOUNCE_MS)
- }
- }
- } catch {
- /* ignore */
- }
- },
- })
-
- // Playback déclaré APRÈS capture : il consomme le contexte + le mix PARTAGÉS
- // exposés par la capture (Voie A — horloge unique, voix IA routée vers le mix
- // en plus du destination).
- const playback = useAudioPlayback({
- contextRef: capture.contextRef,
- mixNodeRef: capture.mixNodeRef,
- })
-
- const cleanupTimers = useCallback(() => {
- if (timeoutTimerRef.current !== null) {
- clearTimeout(timeoutTimerRef.current)
- timeoutTimerRef.current = null
- }
- if (elapsedTimerRef.current !== null) {
- clearInterval(elapsedTimerRef.current)
- elapsedTimerRef.current = null
- }
- if (pingTimerRef.current !== null) {
- clearInterval(pingTimerRef.current)
- pingTimerRef.current = null
- }
- if (userSpeakingTimerRef.current !== null) {
- clearTimeout(userSpeakingTimerRef.current)
- userSpeakingTimerRef.current = null
- }
- }, [])
-
- const closeAll = useCallback(() => {
- sessionEndedRef.current = true
- cleanupTimers()
- // Invalide tout `startDialogue` async en flight : il s'arrêtera après
- // l'await de getAccessToken sans ouvrir / en fermant immédiatement le WS.
- if (cancelTokenRef.current) {
- cancelTokenRef.current.cancelled = true
- cancelTokenRef.current = null
- }
- capture.stop()
- if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
- try {
- wsRef.current.close()
- } catch {
- /* ignore */
- }
- }
- // playback continue jusqu'à fin de file pour ne pas couper la dernière phrase IA.
- }, [capture, cleanupTimers])
-
- const handleAudioReceived = useCallback(
- (base64: string) => {
- // Début de réplique IA = 1er chunk après > 800 ms sans audio IA.
- const _now = performance.now()
- const newTurn = lastAiChunkTsRef.current === 0 || _now - lastAiChunkTsRef.current > 800
- if (newTurn) {
- // L'IA prend la parole → on quitte 'speaking' si on y était encore (le
- // debounce micro y mène aussi, mais l'audio IA tranche). On réaligne
- // l'edge-tracking du VAD : USER_SILENT est no-op depuis 'ready'/'listening'
- // (machine idempotente → pas de re-render superflu).
- if (userSpeakingTimerRef.current !== null) {
- clearTimeout(userSpeakingTimerRef.current)
- userSpeakingTimerRef.current = null
- }
- if (micSpeakingRef.current) {
- micSpeakingRef.current = false
- dispatch({ type: 'USER_SILENT' })
- }
- }
- lastAiChunkTsRef.current = _now
-
- // Sprint 6e Voie A : on ne pousse plus la voix IA dans le recording ici.
- // playChunk la route vers destination ET vers le mixGain ; le tap
- // d'enregistrement la capte sur ce mix en temps réel.
- playback.playChunk(base64)
- },
- [dispatch, playback],
- )
-
- const handleGeminiMessage = useCallback(
- (msg: GeminiMessage) => {
- const sc = msg.serverContent
- if (!sc) return
- if (sc.modelTurn?.parts) {
- for (const part of sc.modelTurn.parts) {
- if (part.inlineData?.data) {
- handleAudioReceived(part.inlineData.data)
- }
- }
- }
- // Les transcriptions sont accumulées côté backend pour la correction
- // finale — le frontend n'en a pas besoin pour l'UI temps réel.
- },
- [handleAudioReceived],
- )
-
- const handleAppMessage = useCallback(
- (msg: AppMessage) => {
- if (msg.type === 'warning') {
- setWarning(true)
- return
- }
- if (msg.type === 'report' && msg.data) {
- const simId = (msg.data.simulation_id as string | undefined) ?? null
- if (simId) setSimulationId(simId)
- dispatch({ type: 'REPORT_READY' })
- if (simId && onReportReady) onReportReady(simId)
- return
- }
- if (msg.type === 'error') {
- setErrorMessage(msg.message ?? 'Une erreur est survenue.')
- dispatch({ type: 'ERROR', message: msg.message })
- }
- },
- [dispatch, onReportReady],
- )
-
- const handleWsMessage = useCallback(
- (evt: MessageEvent) => {
- // Les chunks audio Gemini peuvent arriver en binary ou en JSON
- // (selon proxy). Le backend Sprint 6a forward les frames Gemini telles
- // quelles — donc majoritairement string JSON.
- let text: string
- if (typeof evt.data === 'string') {
- text = evt.data
- } else if (evt.data instanceof ArrayBuffer) {
- text = new TextDecoder().decode(evt.data)
- } else if (evt.data instanceof Blob) {
- // Fallback async — ignorer ce frame, log seulement.
- console.warn('[T2] Frame Blob reçu, attendu string/ArrayBuffer')
- return
- } else {
- return
- }
-
- try {
- const parsed = JSON.parse(text) as GeminiMessage & AppMessage
- if (parsed.type === 'warning' || parsed.type === 'report' || parsed.type === 'error') {
- handleAppMessage(parsed as AppMessage)
- } else if (parsed.serverContent) {
- handleGeminiMessage(parsed as GeminiMessage)
- }
- } catch {
- /* JSON malformé — ignorer */
- }
- },
- [handleAppMessage, handleGeminiMessage],
- )
-
- // INSTRUMENT REPAIR 6e — la socket est ouverte UNE fois (startDialogue) et
- // `ws.onmessage` y était affecté en DUR → closure GELÉ : sous HMR, la socket
- // continuait d'appeler l'ancien `handleAudioReceived` (audio joué mais log
- // jamais émis). On passe par une ref toujours à jour : le binding appelle
- // TOUJOURS le handler courant. Aucun changement de logique audio.
- const handleWsMessageRef = useRef(handleWsMessage)
- useEffect(() => {
- handleWsMessageRef.current = handleWsMessage
- }, [handleWsMessage])
-
- const handleWsClose = useCallback(
- (evt: CloseEvent) => {
- if (sessionEndedRef.current) return
- sessionEndedRef.current = true
- cleanupTimers()
- // Débrancher le tap AVANT de fermer le contexte (capture.stop) ; le buffer
- // WAV survit pour exportWAV (déclenché à la réception du rapport).
- recording.stop()
- capture.stop()
-
- switch (evt.code) {
- case 1000:
- // Fermeture normale — si on n'a pas reçu le rapport, c'est anormal.
- if (state !== 'ended') {
- dispatch({ type: 'ERROR', message: 'Session interrompue' })
- }
- break
- case 4001:
- setErrorMessage('Authentification expirée.')
- dispatch({ type: 'ERROR', code: 4001 })
- navigate('/login')
- break
- case 4003:
- setErrorMessage('La Tâche 2 Live est réservée au plan Premium.')
- dispatch({ type: 'ERROR', code: 4003 })
- break
- case 4004:
- setErrorMessage('Sujet introuvable.')
- dispatch({ type: 'ERROR', code: 4004 })
- break
- case 4005:
- setErrorMessage('Service temporairement indisponible.')
- dispatch({ type: 'ERROR', code: 4005 })
- break
- case 4006:
- setErrorMessage('Connexion à l’examinateur perdue.')
- dispatch({ type: 'ERROR', code: 4006 })
- break
- default:
- setErrorMessage('Une erreur est survenue. Réessayez dans quelques instants.')
- dispatch({ type: 'ERROR', code: evt.code })
- }
- },
- [capture, cleanupTimers, dispatch, navigate, recording, state],
- )
-
- const startDialogue = useCallback(async () => {
- // Idempotent : ws déjà ouvert OU connexion en flight → no-op.
- if (wsRef.current || cancelTokenRef.current) return
- const localToken = { cancelled: false }
- cancelTokenRef.current = localToken
-
- setErrorMessage(null)
- setWarning(false)
- sessionEndedRef.current = false
- dispatch({ type: 'START_DIALOGUE' })
-
- const token = await getAccessToken()
- if (localToken.cancelled) return
- if (!token) {
- cancelTokenRef.current = null
- setErrorMessage('Authentification requise.')
- dispatch({ type: 'ERROR', code: 4001 })
- navigate('/login')
- return
- }
-
- let ws: WebSocket
- try {
- ws = new WebSocket(buildWsUrl(token, sujetId))
- ws.binaryType = 'arraybuffer'
- } catch (err) {
- cancelTokenRef.current = null
- const message = err instanceof Error ? err.message : 'Connexion impossible'
- setErrorMessage(message)
- dispatch({ type: 'ERROR' })
- return
- }
- // Si le composant a été démonté pendant le `new WebSocket(...)` (très rare
- // mais possible en StrictMode dev), on ferme immédiatement.
- if (localToken.cancelled) {
- try {
- ws.close()
- } catch {
- /* ignore */
- }
- return
- }
- wsRef.current = ws
-
- ws.onopen = () => {
- dispatch({ type: 'WS_OPENED' })
- // Démarrer la capture micro UNE FOIS le WS ouvert, PUIS brancher le tap
- // d'enregistrement sur le contexte + mixGain (qui n'existent qu'après que
- // capture.start() ait résolu : getUserMedia + addModule). Cycle de vie
- // Voie A : start = ici ; stop = endDialogue / fermeture WS uniquement.
- void capture.start().then(() => {
- const ctx = capture.contextRef.current
- const mix = capture.mixNodeRef.current
- if (ctx && mix) {
- recording.reset()
- void recording.start(ctx, mix)
- }
- })
-
- // Timer écoulé pour l'UI.
- const startTime = Date.now()
- elapsedTimerRef.current = setInterval(() => {
- setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000))
- }, 250)
-
- // Timeout dur côté frontend (redondance avec le 210 s backend).
- timeoutTimerRef.current = setTimeout(() => {
- if (sessionEndedRef.current) return
- if (ws.readyState === WebSocket.OPEN) {
- try {
- ws.send(JSON.stringify({ type: 'end' }))
- } catch {
- /* ignore */
- }
- }
- dispatch({ type: 'END_REQUESTED' })
- }, DIALOGUE_TIMEOUT_MS)
-
- // Keep-alive ping (certains proxies coupent à 30s d'inactivité).
- pingTimerRef.current = setInterval(() => {
- if (ws.readyState === WebSocket.OPEN) {
- try {
- ws.send(JSON.stringify({ type: 'ping' }))
- } catch {
- /* ignore */
- }
- }
- }, WS_PING_INTERVAL_MS)
- }
- // Indirection par ref : appelle TOUJOURS le handleWsMessage courant (immunisé
- // au gel de closure / HMR). Cf. INSTRUMENT REPAIR 6e.
- ws.onmessage = (evt) => handleWsMessageRef.current(evt)
- ws.onclose = handleWsClose
- ws.onerror = () => {
- // 'close' suit toujours 'error' — gestion centralisée dans handleWsClose.
- }
- }, [capture, dispatch, handleWsClose, navigate, recording, sujetId])
-
- const endDialogue = useCallback(() => {
- if (sessionEndedRef.current) return
- cleanupTimers()
- // Cycle de vie Voie A : « Terminer le dialogue » débranche le tap (le buffer
- // WAV survit pour exportWAV) PUIS ferme le contexte de capture.
- recording.stop()
- capture.stop()
- if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
- try {
- wsRef.current.send(JSON.stringify({ type: 'end' }))
- } catch {
- /* ignore */
- }
- }
- dispatch({ type: 'END_REQUESTED' })
- }, [capture, cleanupTimers, dispatch, recording])
-
- // Bug 5 — Abandon utilisateur. Contrairement à `endDialogue`, on ferme le WS
- // SANS envoyer `{type:'end'}` : le backend (geminiLive.ts close handler) ne
- // déclenche alors NI correction NI persistance. `closeAll()` invalide tout
- // `startDialogue` en flight, coupe la capture (micro libéré via tracks.stop())
- // et ferme le WS ; on stoppe en plus la lecture IA en cours (pas d'attente de
- // fin de file, c'est un abandon). La machine revient à 'idle' via CANCEL.
- const cancelDialogue = useCallback(() => {
- if (sessionEndedRef.current) return
- closeAll()
- playback.stop()
- dispatch({ type: 'CANCEL' })
- }, [closeAll, playback, dispatch])
-
- // Cleanup au démontage UNIQUEMENT.
- //
- // ⚠ Bug subtil corrigé : `closeAll` dépend de `capture` (objet retourné
- // par useAudioCapture, recréé à chaque render). Mettre `closeAll` en
- // dépendance de cet effet ferait s'exécuter le cleanup à chaque render —
- // donc fermer le WS dès la première transition d'état (ws.onopen →
- // dispatch WS_OPENED → setState → re-render → cleanup → close).
- //
- // Pattern : ref qui tient toujours la dernière version de closeAll, effet
- // de cleanup avec deps vides qui n'exécute la fonction qu'au démontage.
- const closeAllRef = useRef(closeAll)
- useEffect(() => {
- closeAllRef.current = closeAll
- })
- useEffect(() => {
- return () => {
- closeAllRef.current()
- }
- }, [])
-
- return {
- state,
- startDialogue,
- endDialogue,
- cancelDialogue,
- warning,
- errorMessage,
- simulationId,
- recording,
- elapsedSeconds,
- // Indicateur de prise de parole : analyser dérivé, exposé par ref stable
- // (jamais de stream en state → pas de famine du flux montant).
- analyserRef: capture.analyserRef,
- }
-}
diff --git a/src/features/t2-live/pages/T2DialoguePage.tsx b/src/features/t2-live/pages/T2DialoguePage.tsx
deleted file mode 100644
index 2a6cbac..0000000
--- a/src/features/t2-live/pages/T2DialoguePage.tsx
+++ /dev/null
@@ -1,258 +0,0 @@
-/**
- * Page /simulation/eo/t2/dialogue — phase de dialogue live (Sprint 6c).
- *
- * Démarre la session WS au mount, pilote l'UI selon l'état machine T2, affiche
- * le timer 3:30 et l'indicateur d'état. À la fin (REPORT_READY), affiche un
- * écran terminal avec deux boutons : "Télécharger l'audio" et "Voir le rapport".
- */
-
-import { useEffect, useRef, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Mic, Download, FileText, Loader2 } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { Card } from '@/shared/ui/Card'
-import { T2SpeakingIndicator } from '../components/T2SpeakingIndicator'
-import { useT2LiveContext } from '../state/T2LiveContext'
-import { useT2LiveSession } from '../hooks/useT2LiveSession'
-
-const DIALOGUE_SECONDS = 210 // 3:30
-
-function formatMmSs(totalSeconds: number): string {
- const remaining = Math.max(0, totalSeconds)
- const m = Math.floor(remaining / 60)
- const s = remaining % 60
- return `${m}:${s.toString().padStart(2, '0')}`
-}
-
-export function T2DialoguePage() {
- const navigate = useNavigate()
- const { sujet, reset: resetContext } = useT2LiveContext()
- const [autoStarted, setAutoStarted] = useState(false)
- // Bug 4 — neutralise le garde-fou `!sujet` lors d'une navigation volontaire
- // (Voir le rapport, Retour aux sujets) : sinon resetContext() déclenche la
- // redirection parasite vers /simulation/eo/t2 et écrase la navigation voulue.
- const navigatingAwayRef = useRef(false)
-
- const session = useT2LiveSession({
- sujetId: sujet?.id ?? '',
- })
-
- // Garde-fou : pas de sujet → retour à la sélection (sauf navigation volontaire).
- useEffect(() => {
- if (!sujet && !navigatingAwayRef.current) {
- navigate('/simulation/eo/t2', { replace: true })
- }
- }, [sujet, navigate])
-
- // Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
- useEffect(() => {
- if (!sujet) return
- if (autoStarted) return
- setAutoStarted(true)
- void session.startDialogue()
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sujet, autoStarted])
-
- const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
- const stateLabel = (() => {
- switch (session.state) {
- case 'idle':
- case 'connecting':
- return 'Connexion à l’examinateur…'
- case 'ready':
- return 'À vous — prenez la parole.'
- case 'speaking':
- return 'L’examinateur écoute…'
- case 'listening':
- return 'L’examinateur répond…'
- case 'processing':
- return 'Évaluation en cours…'
- case 'ended':
- return 'Session terminée.'
- case 'error':
- return 'Erreur.'
- case 'preparing':
- return 'Préparation…'
- }
- })()
-
- function handleDownload() {
- const blob = session.recording.exportWAV()
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `expria-t2-${new Date().toISOString().slice(0, 10)}.wav`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
-
- function handleViewReport() {
- if (!session.simulationId) return
- // Bug 4 — neutralise le garde-fou avant resetContext() pour que la
- // navigation vers le rapport aboutisse. Le routage EO/EE du retour est
- // géré par RapportPage via `Report.tache` (Bug 6, voie B).
- navigatingAwayRef.current = true
- resetContext()
- navigate(`/rapport/${session.simulationId}`)
- }
-
- function handleBackToSujets() {
- navigatingAwayRef.current = true
- resetContext()
- navigate('/simulation/eo/t2')
- }
-
- // Bug 5 — Abandon : ferme la session sans évaluation (cancelDialogue ne
- // déclenche ni correction ni persistance), puis retour à la sélection T2.
- function handleCancel() {
- navigatingAwayRef.current = true
- session.cancelDialogue()
- resetContext()
- navigate('/simulation/eo/t2')
- }
-
- // « Annuler » n'a de sens que pendant le dialogue actif (pas en connexion
- // ni en évaluation).
- const canCancel =
- session.state === 'ready' || session.state === 'speaking' || session.state === 'listening'
-
- if (!sujet) return null
-
- // ── État terminal : rapport prêt ─────────────────────────────────────────
- if (session.state === 'ended') {
- return (
-
-
- Session terminée
-
-
- Votre dialogue a été évalué. Vous pouvez télécharger l'enregistrement audio avant de
- consulter le rapport.
-
-
- Durée enregistrée :{' '}
-
- {session.recording.durationSeconds.toFixed(1)} s
-
-
-
-
- }
- onClick={handleDownload}
- disabled={session.recording.durationSeconds === 0}
- >
- Télécharger l'audio
-
- }
- onClick={handleViewReport}
- disabled={!session.simulationId}
- >
- Voir le rapport
-
-
-
-
- )
- }
-
- // ── État erreur ──────────────────────────────────────────────────────────
- if (session.state === 'error') {
- return (
-
-
- Erreur
-
- {session.errorMessage ?? 'Une erreur est survenue.'}
-
-
-
- Retour aux sujets
-
-
-
-
- )
- }
-
- // ── État dialogue actif ──────────────────────────────────────────────────
- return (
-
-
-
-
Dialogue en cours
-
- {formatMmSs(remaining)}
-
-
-
- {session.warning && (
-
- ⏱ 30 secondes restantes.
-
- )}
-
-
-
- {session.state === 'processing' ? (
-
- ) : (
-
- )}
-
{stateLabel}
-
- {/* Indicateur de prise de parole. 'speaking' = amplitude micro réelle
- (analyser dérivé du graphe de capture, lu par ref en rAF) ;
- 'listening' = animation décorative pilotée par l'état (pas de sonde
- playback) ; 'ready' = signal « À vous de parler ». */}
- {canCancel && (
-
- )}
- {sujet.consigne}
-
-
-
- {canCancel && (
-
- Annuler
-
- )}
- session.endDialogue()}
- disabled={session.state === 'processing'}
- >
- Terminer le dialogue
-
-
-
- « Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation.
-
-
-
- )
-}
diff --git a/src/features/t2-live/pages/T2PreparationPage.tsx b/src/features/t2-live/pages/T2PreparationPage.tsx
deleted file mode 100644
index ccd736a..0000000
--- a/src/features/t2-live/pages/T2PreparationPage.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-/**
- * Page /simulation/eo/t2/preparation — phase de préparation T2 Live (Sprint 6c).
- *
- * - Timer 2 min visible (countdown).
- * - Consigne + contexte du sujet affichés.
- * - Zone de notes locale (état interne, non sauvegardée).
- * - Bouton "Suggestions d'idées" → réutilise useIdees (POST /sujets/idees).
- * Parité EE (SimulationForm) : on passe les NOTES du candidat comme
- * contenu_partiel et le bouton reste désactivé tant que < 30 mots — l'aide
- * récompense l'effort (anti-blanc), elle ne se déclenche pas à vide.
- * - Bouton "Je suis prêt" → navigation vers /dialogue avant la fin du timer.
- * - Auto-navigation à 0:00.
- * - Pré-warm de la permission micro pour éviter le délai au début du dialogue.
- */
-
-import { useEffect, useRef, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Sparkles, Loader2 } from 'lucide-react'
-import { Button } from '@/shared/ui/Button'
-import { Card } from '@/shared/ui/Card'
-import { useIdees } from '@/features/simulations/hooks/useIdees'
-import { IdeesSuggestions } from '@/features/simulations/components/IdeesSuggestions'
-import { countWords } from '@/features/simulations/lib/simulationConfig'
-import { useT2LiveContext } from '../state/T2LiveContext'
-
-const PREPARATION_SECONDS = 120
-const MIN_WORDS_IDEES = 30
-
-function formatMmSs(totalSeconds: number): string {
- const m = Math.floor(totalSeconds / 60)
- const s = totalSeconds % 60
- return `${m}:${s.toString().padStart(2, '0')}`
-}
-
-export function T2PreparationPage() {
- const navigate = useNavigate()
- const { sujet } = useT2LiveContext()
-
- const [secondsLeft, setSecondsLeft] = useState(PREPARATION_SECONDS)
- const [notes, setNotes] = useState('')
- const [showIdees, setShowIdees] = useState(false)
- const [micWarmed, setMicWarmed] = useState(null)
- const expiredRef = useRef(false)
-
- const idees = useIdees()
-
- // Garde-fou : pas de sujet → retour à la sélection.
- useEffect(() => {
- if (!sujet) navigate('/simulation/eo/t2', { replace: true })
- }, [sujet, navigate])
-
- // Pré-warm permission micro (le hook useAudioCapture le fera de toute façon
- // au start, mais on demande la permission ici pour éviter la latence à 0:00).
- useEffect(() => {
- let cancelled = false
- let stream: MediaStream | null = null
- void navigator.mediaDevices
- .getUserMedia({
- audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
- })
- .then((s) => {
- stream = s
- if (cancelled) {
- s.getTracks().forEach((t) => t.stop())
- return
- }
- // Permission acquise — on relâche le stream pour ne pas bloquer le micro.
- s.getTracks().forEach((t) => t.stop())
- setMicWarmed(true)
- })
- .catch(() => {
- if (!cancelled) setMicWarmed(false)
- })
- return () => {
- cancelled = true
- if (stream) stream.getTracks().forEach((t) => t.stop())
- }
- }, [])
-
- // Timer countdown 2 min.
- useEffect(() => {
- const id = setInterval(() => {
- setSecondsLeft((s) => {
- if (s <= 1) {
- clearInterval(id)
- if (!expiredRef.current) {
- expiredRef.current = true
- // Auto-navigation à la fin de la prépa.
- queueMicrotask(() => navigate('/simulation/eo/t2/dialogue'))
- }
- return 0
- }
- return s - 1
- })
- }, 1000)
- return () => clearInterval(id)
- }, [navigate])
-
- function handleReady() {
- navigate('/simulation/eo/t2/dialogue')
- }
-
- const wordCount = countWords(notes)
- const ideesDisabled = idees.isLoading || wordCount < MIN_WORDS_IDEES
- const ideesTitle =
- wordCount < MIN_WORDS_IDEES ? `Écrivez au moins ${MIN_WORDS_IDEES} mots` : undefined
-
- function handleIdees() {
- if (!sujet) return
- // Parité EE : contenu_partiel = NOTES du candidat (pas la consigne). Le seuil
- // ≥ 30 mots s'applique aux notes et gatekeepe le bouton ci-dessous, donc aucun
- // 400 ne part : le candidat doit avoir produit un minimum avant de demander
- // l'aide. Le mécanisme DeepSeek génère des idées de questions à poser.
- const consigne = sujet.consigne ?? 'Tâche 2 — interaction de service'
- setShowIdees(true)
- idees.fetchIdees({ consigne, contenu: notes })
- }
-
- if (!sujet) return null
-
- return (
-
-
-
-
Préparation — Tâche 2 Live
-
- {formatMmSs(secondsLeft)}
-
-
-
-
-
- Consigne
-
- {sujet.consigne}
- {(sujet as { contexte?: string | null }).contexte && (
- <>
-
- Contexte
-
-
- {(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.
-
-
-
-
-
- Vos notes (locales — non sauvegardées)
-
- setNotes(e.target.value)}
- rows={6}
- placeholder="Notez vos questions, vos points à aborder…"
- className="w-full resize-y rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:outline-none focus:shadow-focus"
- />
-
-
- {micWarmed === false && (
-
- Accès au micro refusé. Activez-le dans les paramètres du navigateur avant de démarrer le
- dialogue.
-
- )}
-
-
-
- ) : (
-
- )
- }
- onClick={handleIdees}
- disabled={ideesDisabled}
- title={ideesTitle}
- >
- Suggestions d'idées
-
-
- Je suis prêt — démarrer le dialogue
-
-
-
- {
- setShowIdees(false)
- idees.reset()
- }}
- />
-
-
- )
-}
diff --git a/src/features/t2-live/pages/T2SujetsPage.tsx b/src/features/t2-live/pages/T2SujetsPage.tsx
deleted file mode 100644
index df6b531..0000000
--- a/src/features/t2-live/pages/T2SujetsPage.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * 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 (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
- )
-}
-
-export function T2SujetsPage() {
- const navigate = useNavigate()
- const { setSujet } = useT2LiveContext()
-
- const { data: sujets, isLoading, isError, refetch } = useSujets('EO_T2_LIVE', true)
-
- function handleSelect(sujet: SujetData) {
- setSujet(sujet)
- navigate('/simulation/eo/t2/preparation')
- }
-
- function handleRandom() {
- if (!sujets || sujets.length === 0) return
- const pick = sujets[Math.floor(Math.random() * sujets.length)]
- if (pick) handleSelect(pick)
- }
-
- const hasSujets = (sujets?.length ?? 0) > 0
-
- return (
-
-
-
- navigate('/simulation/eo')}
- className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
- >
- ← Retour
-
-
- Choisir un sujet — Tâche 2 Live
-
-
-
-
-
- {isLoading
- ? 'Chargement des sujets…'
- : hasSujets
- ? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
- : 'Aucun sujet disponible pour cette tâche.'}
-
-
}
- onClick={handleRandom}
- disabled={!hasSujets}
- >
- Sujet aléatoire
-
-
-
- {isError && (
-
- Impossible de charger les sujets.{' '}
- refetch()}
- className="underline underline-offset-2"
- >
- Réessayer
-
-
- )}
-
- {isLoading ? (
-
- ) : hasSujets ? (
-
- {sujets!.map((sujet) => (
-
- ))}
-
- ) : null}
-
-
- )
-}
diff --git a/src/features/t2-live/pages/__tests__/T2PreparationPage.test.tsx b/src/features/t2-live/pages/__tests__/T2PreparationPage.test.tsx
deleted file mode 100644
index 1fe2566..0000000
--- a/src/features/t2-live/pages/__tests__/T2PreparationPage.test.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * Tests — T2PreparationPage (Sprint 6e, fix idees Option C / parité EE).
- *
- * Couvre le gatekeeping « Suggestions d'idées » aligné sur SimulationForm :
- * - notes < 30 mots → bouton désactivé (aucun appel /sujets/idees → aucun 400)
- * - notes ≥ 30 mots → bouton actif ET fetchIdees appelé avec contenu = notes
- * (les notes RÉELLES du candidat, pas la consigne du sujet).
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, cleanup, fireEvent } from '@testing-library/react'
-import { MemoryRouter } from 'react-router-dom'
-
-const { navigateMock, fetchIdeesMock, resetMock } = vi.hoisted(() => ({
- navigateMock: vi.fn(),
- fetchIdeesMock: vi.fn(),
- resetMock: vi.fn(),
-}))
-
-vi.mock('react-router-dom', async () => {
- const actual = await vi.importActual('react-router-dom')
- return { ...actual, useNavigate: () => navigateMock }
-})
-
-vi.mock('../../state/T2LiveContext', () => ({
- useT2LiveContext: () => ({
- sujet: {
- id: 'sujet-1',
- consigne: 'Réservez une table au restaurant.',
- contexte: 'Vous appelez un restaurant.',
- },
- }),
-}))
-
-vi.mock('@/features/simulations/hooks/useIdees', () => ({
- useIdees: () => ({
- fetchIdees: fetchIdeesMock,
- reset: resetMock,
- idees: null,
- isLoading: false,
- error: null,
- }),
-}))
-
-import { T2PreparationPage } from '../T2PreparationPage'
-
-function renderPage() {
- return render(
-
-
- ,
- )
-}
-
-// 30 mots exactement → countWords renvoie 30 (≥ seuil).
-const NOTES_30 = Array.from({ length: 30 }, (_, i) => `mot${i + 1}`).join(' ')
-
-beforeEach(() => {
- cleanup()
- navigateMock.mockReset()
- fetchIdeesMock.mockReset()
- resetMock.mockReset()
- // Le pré-warm micro appelle getUserMedia dans un effect — stub jsdom.
- Object.defineProperty(navigator, 'mediaDevices', {
- configurable: true,
- value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [] }) },
- })
-})
-
-describe('T2PreparationPage — gatekeeping Suggestions d’idées (parité EE)', () => {
- it('bouton désactivé tant que les notes font < 30 mots', () => {
- renderPage()
- const btn = screen.getByRole('button', { name: /Suggestions/i })
- expect(btn).toBeDisabled()
- expect(btn).toHaveAttribute('title', 'Écrivez au moins 30 mots')
- })
-
- it('bouton actif à ≥ 30 mots et fetchIdees appelé avec contenu = notes', () => {
- renderPage()
- const textarea = screen.getByLabelText(/Vos notes/i)
- fireEvent.change(textarea, { target: { value: NOTES_30 } })
-
- const btn = screen.getByRole('button', { name: /Suggestions/i })
- expect(btn).not.toBeDisabled()
-
- fireEvent.click(btn)
- expect(fetchIdeesMock).toHaveBeenCalledTimes(1)
- expect(fetchIdeesMock).toHaveBeenCalledWith({
- consigne: 'Réservez une table au restaurant.',
- contenu: NOTES_30,
- })
- })
-})
diff --git a/src/features/t2-live/state/T2LiveContext.tsx b/src/features/t2-live/state/T2LiveContext.tsx
deleted file mode 100644
index 45f0233..0000000
--- a/src/features/t2-live/state/T2LiveContext.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * T2LiveContext — partage le sujet sélectionné entre les pages T2 Live.
- *
- * Les pages /simulation/eo/t2/preparation et /simulation/eo/t2/dialogue
- * ont besoin du sujet choisi sur /simulation/eo/t2 (id, role, contexte,
- * consigne). Le sujet n'est pas persisté en backend tant que le rapport
- * n'est pas généré — donc un Provider React simple suffit.
- *
- * Si un utilisateur arrive directement sur /preparation ou /dialogue
- * sans sujet (ex: refresh), les pages redirigent vers /simulation/eo/t2.
- */
-
-import { createContext, useContext, useState, type ReactNode } from 'react'
-import type { SujetData } from '@/entities/production/types'
-
-interface T2LiveContextValue {
- sujet: SujetData | null
- setSujet: (sujet: SujetData | null) => void
- reset: () => void
-}
-
-const T2LiveContext = createContext(null)
-
-export function T2LiveProvider({ children }: { children: ReactNode }) {
- const [sujet, setSujet] = useState(null)
- const reset = () => setSujet(null)
- return (
- {children}
- )
-}
-
-export function useT2LiveContext(): T2LiveContextValue {
- const ctx = useContext(T2LiveContext)
- if (!ctx) {
- throw new Error('useT2LiveContext must be used within T2LiveProvider')
- }
- return ctx
-}
diff --git a/src/features/t2-live/state/__tests__/t2-machine.test.ts b/src/features/t2-live/state/__tests__/t2-machine.test.ts
deleted file mode 100644
index 6265926..0000000
--- a/src/features/t2-live/state/__tests__/t2-machine.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { transition, T2_INITIAL_STATE } from '../t2-machine'
-import type { T2State } from '../t2-machine'
-
-describe('T2 state machine — transitions nominales', () => {
- it('idle → preparing sur START_PREPARATION', () => {
- expect(transition('idle', { type: 'START_PREPARATION' })).toBe('preparing')
- })
-
- it('preparing → connecting sur START_DIALOGUE', () => {
- expect(transition('preparing', { type: 'START_DIALOGUE' })).toBe('connecting')
- })
-
- it('connecting → ready sur WS_OPENED', () => {
- expect(transition('connecting', { type: 'WS_OPENED' })).toBe('ready')
- })
-
- it('ready ↔ speaking ↔ listening (cycle de dialogue)', () => {
- expect(transition('ready', { type: 'USER_SPEAKING' })).toBe('speaking')
- expect(transition('speaking', { type: 'USER_SILENT' })).toBe('listening')
- expect(transition('listening', { type: 'USER_SPEAKING' })).toBe('speaking')
- })
-
- it('processing → ended sur REPORT_READY', () => {
- expect(transition('processing', { type: 'REPORT_READY' })).toBe('ended')
- })
-})
-
-describe('T2 state machine — END_REQUESTED → processing depuis tout état actif', () => {
- it.each(['connecting', 'ready', 'speaking', 'listening'])(
- 'transition %s → processing sur END_REQUESTED',
- (from) => {
- expect(transition(from, { type: 'END_REQUESTED' })).toBe('processing')
- },
- )
-})
-
-describe('T2 state machine — ERROR terminal', () => {
- it.each([
- 'idle',
- 'preparing',
- 'connecting',
- 'ready',
- 'speaking',
- 'listening',
- 'processing',
- ])('transition %s → error sur ERROR', (from) => {
- expect(transition(from, { type: 'ERROR', code: 4001 })).toBe('error')
- })
-
- it('ended est insensible à ERROR (état terminal protégé)', () => {
- expect(transition('ended', { type: 'ERROR', code: 4001 })).toBe('ended')
- })
-})
-
-describe('T2 state machine — CANCEL (abandon) → idle depuis tout état actif', () => {
- it.each(['preparing', 'connecting', 'ready', 'speaking', 'listening', 'processing'])(
- 'transition %s → idle sur CANCEL',
- (from) => {
- expect(transition(from, { type: 'CANCEL' })).toBe('idle')
- },
- )
-
- it('CANCEL en idle reste idle (no-op)', () => {
- expect(transition('idle', { type: 'CANCEL' })).toBe('idle')
- })
-
- it('états terminaux (ended, error) sont protégés contre CANCEL', () => {
- expect(transition('ended', { type: 'CANCEL' })).toBe('ended')
- expect(transition('error', { type: 'CANCEL' })).toBe('error')
- })
-})
-
-describe('T2 state machine — événements invalides ignorés', () => {
- it('USER_SPEAKING en idle est ignoré', () => {
- expect(transition('idle', { type: 'USER_SPEAKING' })).toBe('idle')
- })
-
- it('REPORT_READY en ready est ignoré (doit passer par END_REQUESTED → processing)', () => {
- expect(transition('ready', { type: 'REPORT_READY' })).toBe('ready')
- })
-
- it('états terminaux (ended, error) sont insensibles aux events nominaux', () => {
- expect(transition('ended', { type: 'USER_SPEAKING' })).toBe('ended')
- expect(transition('error', { type: 'WS_OPENED' })).toBe('error')
- })
-})
-
-describe('T2_INITIAL_STATE', () => {
- it('vaut idle', () => {
- expect(T2_INITIAL_STATE).toBe('idle')
- })
-})
diff --git a/src/features/t2-live/state/t2-machine.ts b/src/features/t2-live/state/t2-machine.ts
deleted file mode 100644
index 944d15e..0000000
--- a/src/features/t2-live/state/t2-machine.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * t2-machine — State machine pure pour le flux T2 Live (Sprint 6c).
- *
- * Résout FTD-09 (planifiée Sprint 2.5, déclenchée Sprint 6).
- *
- * Cycle de vie d'une session :
- *
- * idle
- * └─ START_PREPARATION ─▶ preparing
- * └─ START_DIALOGUE ─▶ connecting
- * └─ WS_OPENED ─▶ ready
- * ├─ USER_SPEAKING ─▶ speaking
- * │ └─ USER_SILENT ─▶ listening
- * │ └─ USER_SPEAKING ─▶ speaking
- * ├─ END_REQUESTED ─▶ processing
- * │ └─ REPORT_READY ─▶ ended
- * └─ ERROR ─▶ error
- *
- * Toute transition non listée est ignorée (état conservé). La machine est
- * une fonction pure — aucun side-effect, aucune référence à du DOM, à React
- * ou à un WebSocket. Les side-effects sont orchestrés par useT2LiveSession.
- */
-
-export type T2State =
- | 'idle'
- | 'preparing'
- | 'connecting'
- | 'ready'
- | 'speaking'
- | 'listening'
- | 'processing'
- | 'ended'
- | 'error'
-
-export type T2Event =
- | { type: 'START_PREPARATION' }
- | { type: 'START_DIALOGUE' }
- | { type: 'WS_OPENED' }
- | { type: 'USER_SPEAKING' }
- | { type: 'USER_SILENT' }
- | { type: 'END_REQUESTED' }
- | { type: 'REPORT_READY' }
- // CANCEL — abandon utilisateur (bouton « Annuler ») : la session est fermée
- // SANS déclencher d'évaluation (cf. useT2LiveSession.cancelDialogue, qui ferme
- // le WS sans envoyer `{type:'end'}`). La machine revient à 'idle'.
- | { type: 'CANCEL' }
- | { type: 'ERROR'; code?: number; message?: string }
-
-/**
- * Transition pure : (state, event) → newState.
- *
- * Les événements `WARNING` (timer 30 s restantes) ne sont pas modélisés ici
- * car ils n'affectent pas l'état — ils déclenchent un side-effect d'affichage
- * géré directement par le hook orchestrateur.
- */
-export function transition(state: T2State, event: T2Event): T2State {
- // ERROR est terminal et bypasse tous les guards : peut être émis depuis
- // n'importe quel état non-terminal.
- if (event.type === 'ERROR' && state !== 'ended') {
- return 'error'
- }
-
- // CANCEL (abandon) bypasse les guards depuis tout état non-terminal et
- // ramène la machine à 'idle' (aucune évaluation déclenchée). Les états
- // terminaux ('ended', 'error') sont protégés.
- if (event.type === 'CANCEL' && state !== 'ended' && state !== 'error') {
- return 'idle'
- }
-
- switch (state) {
- case 'idle':
- if (event.type === 'START_PREPARATION') return 'preparing'
- // Permet de sauter la prépa si l'appelant le souhaite.
- if (event.type === 'START_DIALOGUE') return 'connecting'
- return state
-
- case 'preparing':
- if (event.type === 'START_DIALOGUE') return 'connecting'
- return state
-
- case 'connecting':
- if (event.type === 'WS_OPENED') return 'ready'
- if (event.type === 'END_REQUESTED') return 'processing'
- return state
-
- case 'ready':
- if (event.type === 'USER_SPEAKING') return 'speaking'
- if (event.type === 'END_REQUESTED') return 'processing'
- return state
-
- case 'speaking':
- if (event.type === 'USER_SILENT') return 'listening'
- if (event.type === 'END_REQUESTED') return 'processing'
- return state
-
- case 'listening':
- if (event.type === 'USER_SPEAKING') return 'speaking'
- if (event.type === 'END_REQUESTED') return 'processing'
- return state
-
- case 'processing':
- if (event.type === 'REPORT_READY') return 'ended'
- return state
-
- case 'ended':
- case 'error':
- // États terminaux — aucune transition.
- return state
- }
-}
-
-/** État initial de toute session T2 Live. */
-export const T2_INITIAL_STATE: T2State = 'idle'
diff --git a/src/index.css b/src/index.css
index d1b1948..d4b5078 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,148 +1 @@
-@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@import 'tailwindcss';
-
-/* Dark = défaut. `.light` sur active le mode clair (override sur --color-*). */
-@custom-variant light (&:where(.light, .light *));
-
-@theme {
- /* ══════════════════════════════════════════════════════════════════════
- INVARIANTS — identiques dark et light
- ══════════════════════════════════════════════════════════════════════ */
-
- /* ── Sidebar (navy permanent) ── */
- --color-sidebar-bg: #0c1528;
- --color-sidebar-border: rgba(255, 255, 255, 0.07);
- --color-sidebar-text: rgba(255, 255, 255, 0.6);
- --color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
- --color-sidebar-text-active: #ffffff;
- --color-sidebar-nav-hover: rgba(255, 255, 255, 0.07);
- --color-sidebar-nav-active: rgba(255, 255, 255, 0.1);
- --color-sidebar-section-label: rgba(255, 255, 255, 0.3);
-
- /* ── Brand ── */
- --color-brand: #1b4fd8;
- --color-brand-hover: #1744b8;
- --color-brand-active: #13379c;
- --color-brand-dark: #1740b0;
- --color-brand-ink: #ffffff;
-
- /* ── Semantic ── */
- --color-warning: #f59e0b;
- --color-warning-soft: rgba(245, 158, 11, 0.12);
- --color-danger: #ef4444;
- --color-danger-soft: rgba(239, 68, 68, 0.12);
-
- /* ── Typography ── */
- --font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
- --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
-
- --text-xs: 11px;
- --text-sm: 13px;
- --text-base: 14px;
- --text-md: 15px;
- --text-lg: 17px;
- --text-xl: 20px;
- --text-2xl: 24px;
- --text-3xl: 32px;
- --text-display: 40px;
-
- /* ── Rayons ── */
- --radius-xs: 6px;
- --radius-sm: 8px;
- --radius-md: 12px;
- --radius-lg: 16px;
- --radius-xl: 20px;
- --radius-pill: 999px;
-
- /* ── Focus ── */
- --shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18);
-
- /* ══════════════════════════════════════════════════════════════════════
- DARK MODE (default) — tokens de contenu
- ══════════════════════════════════════════════════════════════════════ */
-
- --color-canvas: #111111;
- --color-surface: rgba(255, 255, 255, 0.035);
- --color-surface-hover: rgba(255, 255, 255, 0.055);
- --color-surface-solid: #1e1e1e;
- --color-surface-raised: #222222;
- --color-border: rgba(255, 255, 255, 0.06);
- --color-border-strong: rgba(255, 255, 255, 0.12);
-
- --color-ink-primary: #e5e5e5;
- --color-ink-secondary: rgba(255, 255, 255, 0.55);
- --color-ink-tertiary: rgba(255, 255, 255, 0.3);
- --color-ink-inverse: #111111;
-
- --color-brand-soft: rgba(27, 79, 216, 0.1);
- --color-brand-text: #7da4f0;
-
- --color-success: #4ade80;
- --color-success-soft: rgba(74, 222, 128, 0.12);
-
- --color-topbar-bg: rgba(17, 17, 17, 0.88);
- --color-gradient-a: rgba(27, 79, 216, 0.05);
- --color-gradient-b: rgba(27, 79, 216, 0.03);
-
- --shadow-card: none;
- --shadow-raised: none;
-}
-
-/* ══════════════════════════════════════════════════════════════════════
- LIGHT MODE — override .light sur /
- ══════════════════════════════════════════════════════════════════════ */
-
-.light {
- --color-canvas: #f3f4f6;
- --color-surface: #ffffff;
- --color-surface-hover: #f8f9fb;
- --color-surface-solid: #ffffff;
- --color-surface-raised: #ffffff;
- --color-border: rgba(0, 0, 0, 0.07);
- --color-border-strong: rgba(0, 0, 0, 0.14);
-
- --color-ink-primary: #0f0f1a;
- --color-ink-secondary: rgba(0, 0, 0, 0.55);
- --color-ink-tertiary: rgba(0, 0, 0, 0.3);
- --color-ink-inverse: #ffffff;
-
- --color-brand-soft: rgba(27, 79, 216, 0.06);
- --color-brand-text: #1b4fd8;
-
- --color-success: #16a34a;
- --color-success-soft: rgba(22, 163, 74, 0.1);
-
- --color-topbar-bg: rgba(243, 244, 246, 0.88);
- --color-gradient-a: rgba(27, 79, 216, 0.025);
- --color-gradient-b: rgba(27, 79, 216, 0.01);
-
- --shadow-card: 0 1px 2px rgba(15, 18, 32, 0.04), 0 1px 8px rgba(15, 18, 32, 0.03);
- --shadow-raised: 0 4px 16px rgba(15, 18, 32, 0.06), 0 1px 2px rgba(15, 18, 32, 0.04);
-}
-
-/* ── Globals ── */
-
-html,
-body {
- background: var(--color-canvas);
- color: var(--color-ink-primary);
- font-family: var(--font-sans);
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- font-variant-numeric: tabular-nums;
-}
-
-*:focus-visible {
- outline: none;
- box-shadow: var(--shadow-focus);
- border-radius: var(--radius-xs);
-}
-
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0ms !important;
- transition-duration: 0ms !important;
- }
-}
diff --git a/src/shared/components/Logo.tsx b/src/shared/components/Logo.tsx
deleted file mode 100644
index 6e1b993..0000000
--- a/src/shared/components/Logo.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { cn } from '@/shared/lib/utils'
-
-type LogoSize = 'sm' | 'md'
-type LogoVariant = 'icon' | 'full'
-
-interface LogoProps {
- size?: LogoSize
- variant?: LogoVariant
- className?: string
-}
-
-const markStyles: Record = {
- sm: 'size-6 text-[11px]',
- md: 'size-8 text-[13px]',
-}
-
-const wordmarkStyles: Record = {
- sm: 'text-sm',
- md: 'text-base',
-}
-
-export function Logo({ size = 'md', variant = 'full', className }: LogoProps) {
- return (
-
-
- EX
-
- {variant === 'full' && (
- Expria
- )}
-
- )
-}
diff --git a/src/shared/components/ThemeToggle.tsx b/src/shared/components/ThemeToggle.tsx
deleted file mode 100644
index 5189302..0000000
--- a/src/shared/components/ThemeToggle.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Moon, Sun } from 'lucide-react'
-import { useTheme } from '@/shared/hooks/useTheme'
-import { Button } from '@/shared/components/ui/button'
-
-interface ThemeToggleProps {
- className?: string
-}
-
-export function ThemeToggle({ className }: ThemeToggleProps) {
- const { theme, setTheme } = useTheme()
- const isDark = theme === 'dark'
-
- return (
- setTheme(isDark ? 'light' : 'dark')}
- >
- {isDark ? : }
-
- )
-}
diff --git a/src/shared/components/ui/avatar.tsx b/src/shared/components/ui/avatar.tsx
deleted file mode 100644
index e41f06e..0000000
--- a/src/shared/components/ui/avatar.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import * as React from 'react'
-import { Avatar as AvatarPrimitive } from 'radix-ui'
-
-import { cn } from '@/shared/lib/utils'
-
-function Avatar({
- className,
- size = 'default',
- ...props
-}: React.ComponentProps & {
- size?: 'default' | 'sm' | 'lg'
-}) {
- return (
-
- )
-}
-
-function AvatarImage({ className, ...props }: React.ComponentProps) {
- return (
-
- )
-}
-
-function AvatarFallback({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
- return (
- svg]:hidden',
- 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
- 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
- className,
- )}
- {...props}
- />
- )
-}
-
-function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
- return (
-
- )
-}
-
-function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) {
- return (
- svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
- className,
- )}
- {...props}
- />
- )
-}
-
-export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount }
diff --git a/src/shared/components/ui/badge.tsx b/src/shared/components/ui/badge.tsx
deleted file mode 100644
index 0baf53e..0000000
--- a/src/shared/components/ui/badge.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import * as React from 'react'
-import { cva, type VariantProps } from 'class-variance-authority'
-import { Slot } from 'radix-ui'
-
-import { cn } from '@/shared/lib/utils'
-
-const badgeVariants = cva(
- 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-brand focus-visible:ring-[3px] focus-visible:ring-brand/30 aria-invalid:border-danger aria-invalid:ring-danger/40 light:aria-invalid:ring-danger/20 [&>svg]:pointer-events-none [&>svg]:size-3',
- {
- variants: {
- variant: {
- default: 'bg-brand text-white [a&]:hover:bg-brand-hover',
- secondary: 'bg-surface-hover text-ink-primary [a&]:hover:bg-surface-hover/90',
- destructive:
- 'bg-danger/60 text-white focus-visible:ring-danger/40 light:bg-danger light:focus-visible:ring-danger/20 [a&]:hover:bg-danger/80',
- outline:
- 'border-border text-ink-primary [a&]:hover:bg-surface-hover [a&]:hover:text-ink-primary',
- ghost: '[a&]:hover:bg-surface-hover [a&]:hover:text-ink-primary',
- link: 'text-brand-text underline-offset-4 [a&]:hover:underline',
- },
- },
- defaultVariants: {
- variant: 'default',
- },
- },
-)
-
-function Badge({
- className,
- variant = 'default',
- asChild = false,
- ...props
-}: React.ComponentProps<'span'> & VariantProps
& { asChild?: boolean }) {
- const Comp = asChild ? Slot.Root : 'span'
-
- return (
-
- )
-}
-
-// eslint-disable-next-line react-refresh/only-export-components
-export { Badge, badgeVariants }
diff --git a/src/shared/components/ui/button.tsx b/src/shared/components/ui/button.tsx
deleted file mode 100644
index 851e973..0000000
--- a/src/shared/components/ui/button.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import * as React from 'react'
-import { cva, type VariantProps } from 'class-variance-authority'
-import { Slot } from 'radix-ui'
-
-import { cn } from '@/shared/lib/utils'
-
-const buttonVariants = cva(
- "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-brand focus-visible:ring-[3px] focus-visible:ring-brand/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-danger aria-invalid:ring-danger/40 light:aria-invalid:ring-danger/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
- {
- variants: {
- variant: {
- default: 'bg-brand text-white hover:bg-brand-hover',
- destructive:
- 'bg-danger/60 text-white hover:bg-danger/80 focus-visible:ring-danger/40 light:bg-danger light:hover:bg-danger/90 light:focus-visible:ring-danger/20',
- outline:
- 'border border-border bg-surface/30 shadow-xs hover:bg-surface/50 hover:text-ink-primary light:border light:bg-surface light:hover:bg-surface-hover',
- secondary: 'bg-surface-hover text-ink-primary hover:bg-surface-hover/80',
- ghost: 'hover:bg-surface-hover hover:text-ink-primary',
- link: 'text-brand-text underline-offset-4 hover:underline',
- },
- size: {
- default: 'h-9 px-4 py-2 has-[>svg]:px-3',
- xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
- lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
- icon: 'size-9',
- 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
- 'icon-sm': 'size-8',
- 'icon-lg': 'size-10',
- },
- },
- defaultVariants: {
- variant: 'default',
- size: 'default',
- },
- },
-)
-
-function Button({
- className,
- variant = 'default',
- size = 'default',
- asChild = false,
- ...props
-}: React.ComponentProps<'button'> &
- VariantProps & {
- asChild?: boolean
- }) {
- const Comp = asChild ? Slot.Root : 'button'
-
- return (
-
- )
-}
-
-// eslint-disable-next-line react-refresh/only-export-components
-export { Button, buttonVariants }
diff --git a/src/shared/components/ui/dialog.tsx b/src/shared/components/ui/dialog.tsx
deleted file mode 100644
index 78e6b0b..0000000
--- a/src/shared/components/ui/dialog.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import * as React from 'react'
-import { XIcon } from 'lucide-react'
-import { Dialog as DialogPrimitive } from 'radix-ui'
-
-import { cn } from '@/shared/lib/utils'
-import { Button } from '@/shared/components/ui/button'
-
-function Dialog({ ...props }: React.ComponentProps) {
- return
-}
-
-function DialogTrigger({ ...props }: React.ComponentProps) {
- return
-}
-
-function DialogPortal({ ...props }: React.ComponentProps) {
- return
-}
-
-function DialogClose({ ...props }: React.ComponentProps) {
- return
-}
-
-function DialogOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DialogContent({
- className,
- children,
- showCloseButton = true,
- ...props
-}: React.ComponentProps & {
- showCloseButton?: boolean
-}) {
- return (
-
-
-
- {children}
- {showCloseButton && (
-
-
- Close
-
- )}
-
-
- )
-}
-
-function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
- return (
-
- )
-}
-
-function DialogFooter({
- className,
- showCloseButton = false,
- children,
- ...props
-}: React.ComponentProps<'div'> & {
- showCloseButton?: boolean
-}) {
- return (
-
- {children}
- {showCloseButton && (
-
- Close
-
- )}
-
- )
-}
-
-function DialogTitle({ className, ...props }: React.ComponentProps) {
- return (
-
- )
-}
-
-function DialogDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-export {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogOverlay,
- DialogPortal,
- DialogTitle,
- DialogTrigger,
-}
diff --git a/src/shared/components/ui/input.tsx b/src/shared/components/ui/input.tsx
deleted file mode 100644
index cf7dc38..0000000
--- a/src/shared/components/ui/input.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from 'react'
-
-import { cn } from '@/shared/lib/utils'
-
-function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
- return (
-
- )
-}
-
-export { Input }
diff --git a/src/shared/components/ui/label.tsx b/src/shared/components/ui/label.tsx
deleted file mode 100644
index 2cebcda..0000000
--- a/src/shared/components/ui/label.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from 'react'
-import { Label as LabelPrimitive } from 'radix-ui'
-
-import { cn } from '@/shared/lib/utils'
-
-function Label({ className, ...props }: React.ComponentProps) {
- return (
-
- )
-}
-
-export { Label }
diff --git a/src/shared/components/ui/progress.tsx b/src/shared/components/ui/progress.tsx
deleted file mode 100644
index f158be5..0000000
--- a/src/shared/components/ui/progress.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as React from 'react'
-import { Progress as ProgressPrimitive } from 'radix-ui'
-
-import { cn } from '@/shared/lib/utils'
-
-function Progress({
- className,
- value,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-export { Progress }
diff --git a/src/shared/components/ui/separator.tsx b/src/shared/components/ui/separator.tsx
deleted file mode 100644
index 09ca796..0000000
--- a/src/shared/components/ui/separator.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as React from 'react'
-import { Separator as SeparatorPrimitive } from 'radix-ui'
-
-import { cn } from '@/shared/lib/utils'
-
-function Separator({
- className,
- orientation = 'horizontal',
- decorative = true,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-export { Separator }
diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts
index e25521f..bae1c42 100644
--- a/src/shared/config/env.ts
+++ b/src/shared/config/env.ts
@@ -6,12 +6,6 @@ const envSchema = z.object({
VITE_SUPABASE_ANON_KEY: z.string().min(1),
VITE_ENABLE_T2_LIVE: z.enum(['true', 'false']).optional(),
VITE_SENTRY_DSN: z.string().url().optional(),
- VITE_MAINTENANCE_MODE: z.enum(['true', 'false']).optional(),
- // Sprint 5b — price_ids Stripe (publics — visibles dans le dashboard Stripe).
- // Optionnels : permet à la suite de tests Vitest de tourner sans config Stripe.
- // Au runtime, l'absence déclenche une erreur explicite côté `features/billing/api.ts`.
- VITE_STRIPE_PRICE_STANDARD: z.string().min(1).optional(),
- VITE_STRIPE_PRICE_PREMIUM: z.string().min(1).optional(),
})
const parsed = envSchema.safeParse(import.meta.env)
@@ -24,4 +18,3 @@ if (!parsed.success) {
}
export const env = parsed.data
-export const isMaintenanceMode = parsed.data.VITE_MAINTENANCE_MODE === 'true'
diff --git a/src/shared/hooks/useTheme.ts b/src/shared/hooks/useTheme.ts
deleted file mode 100644
index 45a8206..0000000
--- a/src/shared/hooks/useTheme.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { useContext } from 'react'
-import { ThemeContext } from '@/shared/lib/theme'
-
-export function useTheme() {
- const ctx = useContext(ThemeContext)
- if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
- return ctx
-}
diff --git a/src/shared/lib/__tests__/audio-utils.test.ts b/src/shared/lib/__tests__/audio-utils.test.ts
deleted file mode 100644
index 913eedf..0000000
--- a/src/shared/lib/__tests__/audio-utils.test.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import {
- arrayBufferToBase64,
- base64ToArrayBuffer,
- int16ToFloat32,
- float32ToInt16,
- buildWavHeader,
- concatInt16,
-} from '../audio-utils'
-
-describe('arrayBufferToBase64 / base64ToArrayBuffer', () => {
- it('round-trip sur un buffer court (4 octets)', () => {
- const original = new Uint8Array([0x01, 0x02, 0x03, 0xff])
- const b64 = arrayBufferToBase64(original.buffer)
- const decoded = new Uint8Array(base64ToArrayBuffer(b64))
- expect(decoded).toEqual(original)
- })
-
- it('round-trip sur un buffer vide', () => {
- const b64 = arrayBufferToBase64(new ArrayBuffer(0))
- expect(b64).toBe('')
- expect(base64ToArrayBuffer('').byteLength).toBe(0)
- })
-
- it('round-trip sur 8 KB (taille typique chunk T2 Live)', () => {
- const bytes = new Uint8Array(8192)
- for (let i = 0; i < 8192; i++) bytes[i] = i % 256
- const b64 = arrayBufferToBase64(bytes.buffer)
- const decoded = new Uint8Array(base64ToArrayBuffer(b64))
- expect(decoded.length).toBe(8192)
- expect(decoded[0]).toBe(0)
- expect(decoded[255]).toBe(255)
- expect(decoded[8191]).toBe(8191 % 256)
- })
-})
-
-describe('int16ToFloat32 / float32ToInt16', () => {
- it('int16ToFloat32 mappe 0 → 0, 32767 → ~1, -32768 → -1', () => {
- const out = int16ToFloat32(new Int16Array([0, 32767, -32768]))
- expect(out[0]).toBe(0)
- expect(out[1]).toBeCloseTo(0.99997, 4)
- expect(out[2]).toBe(-1)
- })
-
- it('float32ToInt16 clamp les valeurs hors plage', () => {
- const out = float32ToInt16(new Float32Array([2.0, -2.0, 0]))
- expect(out[0]).toBe(32767)
- expect(out[1]).toBe(-32768)
- expect(out[2]).toBe(0)
- })
-
- it('round-trip int16 → float32 → int16 préserve les valeurs (à 1 unité près)', () => {
- const original = new Int16Array([-30000, -100, 0, 100, 30000])
- const back = float32ToInt16(int16ToFloat32(original))
- for (let i = 0; i < original.length; i++) {
- expect(Math.abs(back[i]! - original[i]!)).toBeLessThanOrEqual(1)
- }
- })
-})
-
-describe('concatInt16', () => {
- it('concatène plusieurs chunks dans l’ordre', () => {
- const out = concatInt16([new Int16Array([1, 2]), new Int16Array([3]), new Int16Array([4, 5])])
- expect(Array.from(out)).toEqual([1, 2, 3, 4, 5])
- })
-
- it('renvoie un buffer vide pour une liste vide', () => {
- expect(concatInt16([]).length).toBe(0)
- })
-
- it('préserve un chunk unique à l’identique', () => {
- const out = concatInt16([new Int16Array([7, 8, 9])])
- expect(Array.from(out)).toEqual([7, 8, 9])
- })
-})
-
-describe('buildWavHeader', () => {
- it('renvoie 44 octets', () => {
- const header = buildWavHeader(1000, 24000)
- expect(header.byteLength).toBe(44)
- })
-
- it('contient les magic strings RIFF / WAVE / fmt / data', () => {
- const view = new DataView(buildWavHeader(1000, 24000))
- const readString = (offset: number, len: number) => {
- let s = ''
- for (let i = 0; i < len; i++) s += String.fromCharCode(view.getUint8(offset + i))
- return s
- }
- expect(readString(0, 4)).toBe('RIFF')
- expect(readString(8, 4)).toBe('WAVE')
- expect(readString(12, 4)).toBe('fmt ')
- expect(readString(36, 4)).toBe('data')
- })
-
- it('encode sampleRate et dataLength en little-endian', () => {
- const view = new DataView(buildWavHeader(2000, 24000))
- expect(view.getUint32(24, true)).toBe(24000) // sampleRate
- expect(view.getUint32(40, true)).toBe(2000) // dataLength
- expect(view.getUint32(4, true)).toBe(36 + 2000) // chunkSize total
- expect(view.getUint32(28, true)).toBe(24000 * 2) // byteRate (mono 16-bit)
- expect(view.getUint16(22, true)).toBe(1) // numChannels mono
- expect(view.getUint16(34, true)).toBe(16) // bitsPerSample
- })
-})
diff --git a/src/shared/lib/__tests__/audio.test.ts b/src/shared/lib/__tests__/audio.test.ts
deleted file mode 100644
index 1eba65a..0000000
--- a/src/shared/lib/__tests__/audio.test.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * Tests de `blobToBase64` — Sprint 4c-3.
- *
- * jsdom fournit FileReader. On vérifie :
- * - encodage correct (base64 sans préfixe data URI)
- * - rejet propre si le reader émet onerror
- */
-
-import { describe, it, expect, vi } from 'vitest'
-import { blobToBase64 } from '../audio'
-
-describe('blobToBase64', () => {
- it('encode un Blob en base64 sans le préfixe data URI', async () => {
- const blob = new Blob(['hello'], { type: 'audio/webm' })
- const base64 = await blobToBase64(blob)
- // 'hello' en base64 = 'aGVsbG8='
- expect(base64).toBe('aGVsbG8=')
- })
-
- it('reject si FileReader émet une erreur', async () => {
- class FailingFileReader {
- onerror: (() => void) | null = null
- onload: (() => void) | null = null
- result: unknown = null
- readAsDataURL() {
- // Simule une erreur asynchrone.
- setTimeout(() => this.onerror?.(), 0)
- }
- }
- vi.stubGlobal('FileReader', FailingFileReader)
-
- await expect(blobToBase64(new Blob(['x']))).rejects.toThrow(
- /FileReader: lecture du Blob audio impossible/,
- )
-
- vi.unstubAllGlobals()
- })
-
- it("reject si le résultat n'est pas une data URI bien formée", async () => {
- class WeirdFileReader {
- onerror: (() => void) | null = null
- onload: (() => void) | null = null
- result: string = 'pas-une-data-uri'
- readAsDataURL() {
- setTimeout(() => this.onload?.(), 0)
- }
- }
- vi.stubGlobal('FileReader', WeirdFileReader)
-
- await expect(blobToBase64(new Blob(['x']))).rejects.toThrow(/format data URI/)
-
- vi.unstubAllGlobals()
- })
-})
diff --git a/src/shared/lib/__tests__/date.test.ts b/src/shared/lib/__tests__/date.test.ts
deleted file mode 100644
index af3429d..0000000
--- a/src/shared/lib/__tests__/date.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { formatRelativeDate } from '../date'
-
-const NOW = new Date('2026-04-22T12:00:00Z')
-
-describe('formatRelativeDate', () => {
- it('il y a quelques secondes', () => {
- const d = new Date(NOW.getTime() - 10 * 1000).toISOString()
- expect(formatRelativeDate(d, NOW)).toMatch(/seconde/i)
- })
-
- it('il y a 5 minutes', () => {
- const d = new Date(NOW.getTime() - 5 * 60 * 1000).toISOString()
- expect(formatRelativeDate(d, NOW)).toContain('5')
- expect(formatRelativeDate(d, NOW)).toMatch(/minute/i)
- })
-
- it('il y a 3 heures', () => {
- const d = new Date(NOW.getTime() - 3 * 60 * 60 * 1000).toISOString()
- expect(formatRelativeDate(d, NOW)).toContain('3')
- expect(formatRelativeDate(d, NOW)).toMatch(/heure/i)
- })
-
- it('avant-hier (numeric: auto)', () => {
- const d = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString()
- expect(formatRelativeDate(d, NOW)).toBe('avant-hier')
- })
-
- it('il y a 4 jours', () => {
- const d = new Date(NOW.getTime() - 4 * 24 * 60 * 60 * 1000).toISOString()
- expect(formatRelativeDate(d, NOW)).toContain('4')
- expect(formatRelativeDate(d, NOW)).toMatch(/jour/i)
- })
-
- it('la semaine dernière (numeric: auto)', () => {
- const d = new Date(NOW.getTime() - 8 * 24 * 60 * 60 * 1000).toISOString()
- expect(formatRelativeDate(d, NOW)).toMatch(/semaine/i)
- })
-
- it('retourne une chaîne vide sur ISO invalide', () => {
- expect(formatRelativeDate('not-a-date', NOW)).toBe('')
- })
-})
diff --git a/src/shared/lib/api-client.ts b/src/shared/lib/api-client.ts
index 75c6a11..05edd8f 100644
--- a/src/shared/lib/api-client.ts
+++ b/src/shared/lib/api-client.ts
@@ -4,7 +4,7 @@ import { getAccessToken } from './auth-client'
import { logger } from './logger'
const API_VERSION = '1.0'
-const DEFAULT_TIMEOUT_MS = 15000
+const DEFAULT_TIMEOUT_MS = 5000
const DEFAULT_RETRY = { max: 2, baseDelayMs: 250 }
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE'])
diff --git a/src/shared/lib/audio-utils.ts b/src/shared/lib/audio-utils.ts
deleted file mode 100644
index 0ce30bb..0000000
--- a/src/shared/lib/audio-utils.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * Helpers audio purs — Sprint 6b T2 Live.
- *
- * Conversions entre formats utilisés par Gemini Live et les Web Audio APIs :
- * - PCM 16 bits little-endian ↔ Float32 [-1, 1]
- * - Concaténation de pistes Int16
- * - Encodage WAV mono pour téléchargement de la session
- *
- * Toutes les fonctions sont pures (sans état, sans side-effect) et
- * cross-env (Node ≥ 16 + tous navigateurs cibles via `btoa`/`atob`).
- */
-
-/**
- * Encode un ArrayBuffer en base64.
- *
- * Note : pour des chunks > 64 KB, `String.fromCharCode(...arr)` peut dépasser
- * la stack limit du runtime. Les chunks T2 Live (256 ms à 16 kHz ≈ 8 KB)
- * restent largement sous cette limite.
- */
-export function arrayBufferToBase64(buffer: ArrayBuffer): string {
- const bytes = new Uint8Array(buffer)
- let binary = ''
- for (let i = 0; i < bytes.length; i++) {
- binary += String.fromCharCode(bytes[i]!)
- }
- return btoa(binary)
-}
-
-/**
- * Décode une chaîne base64 en ArrayBuffer.
- */
-export function base64ToArrayBuffer(base64: string): ArrayBuffer {
- const binary = atob(base64)
- const bytes = new Uint8Array(binary.length)
- for (let i = 0; i < binary.length; i++) {
- bytes[i] = binary.charCodeAt(i)
- }
- return bytes.buffer
-}
-
-/**
- * Convertit un buffer Int16 PCM en Float32 [-1, 1].
- * Convention symétrique : on divise par 32768 (= 2^15) pour mapper
- * [-32768, 32767] vers [-1, 0.99997).
- */
-export function int16ToFloat32(int16: Int16Array): Float32Array {
- const out = new Float32Array(int16.length)
- for (let i = 0; i < int16.length; i++) {
- out[i] = int16[i]! / 0x8000
- }
- return out
-}
-
-/**
- * Convertit un buffer Float32 [-1, 1] en Int16 PCM.
- * Clamp les valeurs hors plage avant conversion.
- */
-export function float32ToInt16(float32: Float32Array): Int16Array {
- const out = new Int16Array(float32.length)
- for (let i = 0; i < float32.length; i++) {
- const s = Math.max(-1, Math.min(1, float32[i]!))
- out[i] = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff)
- }
- return out
-}
-
-/**
- * Concatène une liste de buffers Int16 en un seul buffer contigu. Pur.
- *
- * Utilisé pour reconstituer la piste WAV complète à partir des chunks Int16
- * accumulés par le tap d'enregistrement (Sprint 6e Voie A).
- */
-export function concatInt16(chunks: Int16Array[]): Int16Array {
- let total = 0
- for (const c of chunks) total += c.length
- const out = new Int16Array(total)
- let offset = 0
- for (const c of chunks) {
- out.set(c, offset)
- offset += c.length
- }
- return out
-}
-
-/**
- * Construit un header WAV de 44 octets pour PCM 16 bits mono.
- *
- * Format RIFF/WAVE standard :
- * - bytes 0-3 : "RIFF"
- * - bytes 4-7 : taille totale - 8 (uint32 LE)
- * - bytes 8-11 : "WAVE"
- * - bytes 12-15 : "fmt "
- * - bytes 16-19 : taille du sous-chunk fmt = 16
- * - bytes 20-21 : format = 1 (PCM)
- * - bytes 22-23 : numChannels = 1
- * - bytes 24-27 : sampleRate
- * - bytes 28-31 : byteRate = sampleRate * 2
- * - bytes 32-33 : blockAlign = 2
- * - bytes 34-35 : bitsPerSample = 16
- * - bytes 36-39 : "data"
- * - bytes 40-43 : dataLength
- *
- * `dataLength` = nombre d'octets de PCM (= samples * 2 pour 16 bits).
- */
-export function buildWavHeader(dataLength: number, sampleRate: number): ArrayBuffer {
- const buffer = new ArrayBuffer(44)
- const view = new DataView(buffer)
- const numChannels = 1
- const bitsPerSample = 16
- const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
- const blockAlign = numChannels * (bitsPerSample / 8)
-
- const writeString = (offset: number, s: string) => {
- for (let i = 0; i < s.length; i++) {
- view.setUint8(offset + i, s.charCodeAt(i))
- }
- }
-
- writeString(0, 'RIFF')
- view.setUint32(4, 36 + dataLength, true)
- writeString(8, 'WAVE')
- writeString(12, 'fmt ')
- view.setUint32(16, 16, true)
- view.setUint16(20, 1, true)
- view.setUint16(22, numChannels, true)
- view.setUint32(24, sampleRate, true)
- view.setUint32(28, byteRate, true)
- view.setUint16(32, blockAlign, true)
- view.setUint16(34, bitsPerSample, true)
- writeString(36, 'data')
- view.setUint32(40, dataLength, true)
-
- return buffer
-}
diff --git a/src/shared/lib/audio.ts b/src/shared/lib/audio.ts
deleted file mode 100644
index 3ac68b9..0000000
--- a/src/shared/lib/audio.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * Helpers audio partagés — Sprint 4c-3.
- */
-
-/**
- * Convertit un Blob en chaîne base64 (sans le préfixe `data:;base64,`).
- *
- * Utilise FileReader.readAsDataURL puis strip le préfixe avant retour. Le
- * payload audio EO est ensuite envoyé tel quel dans le body JSON de
- * `POST /corrections/eo` (cf. SimulationFlowProvider.submitEoAudio).
- *
- * Reject si le reader émet une erreur ou si le résultat n'est pas une chaîne
- * data URI bien formée.
- */
-export function blobToBase64(blob: Blob): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onerror = () => {
- reject(new Error('FileReader: lecture du Blob audio impossible.'))
- }
- reader.onload = () => {
- const result = reader.result
- if (typeof result !== 'string') {
- reject(new Error('FileReader: résultat inattendu (non-string).'))
- return
- }
- const commaIdx = result.indexOf(',')
- if (commaIdx < 0 || !result.startsWith('data:')) {
- reject(new Error('FileReader: résultat non conforme au format data URI.'))
- return
- }
- resolve(result.slice(commaIdx + 1))
- }
- reader.readAsDataURL(blob)
- })
-}
diff --git a/src/shared/lib/audio/__tests__/useAudioRecording.test.ts b/src/shared/lib/audio/__tests__/useAudioRecording.test.ts
deleted file mode 100644
index 1bad4da..0000000
--- a/src/shared/lib/audio/__tests__/useAudioRecording.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Tests — useAudioRecording (Sprint 6e, Voie A — tap temps réel).
- *
- * L'accumulation passe désormais par un AudioWorklet branché sur le mix du
- * contexte partagé (start(ctx, mixNode)). Ni AudioContext ni AudioWorklet ne
- * sont matérialisables en jsdom : l'enregistrement réel est validé À L'OREILLE
- * (objectif de la session). On couvre ici la surface pure et testable :
- * l'export WAV (header RIFF/WAVE valide, rate natif) et reset.
- */
-
-import { describe, it, expect } from 'vitest'
-import { renderHook, act } from '@testing-library/react'
-import { useAudioRecording } from '../useAudioRecording'
-
-/** Lit un Blob en ArrayBuffer via FileReader (fiable en jsdom). */
-function blobToArrayBuffer(blob: Blob): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onload = () => resolve(reader.result as ArrayBuffer)
- reader.onerror = () => reject(reader.error)
- reader.readAsArrayBuffer(blob)
- })
-}
-
-describe('useAudioRecording (Voie A)', () => {
- it('durationSeconds initial = 0', () => {
- const { result } = renderHook(() => useAudioRecording())
- expect(result.current.durationSeconds).toBe(0)
- })
-
- it('exportWAV sans chunks : Blob avec uniquement le header (44 octets)', () => {
- const { result } = renderHook(() => useAudioRecording())
- const blob = result.current.exportWAV()
- expect(blob.size).toBe(44)
- expect(blob.type).toBe('audio/wav')
- })
-
- it('exportWAV : header RIFF/WAVE/fmt/data valide + rate natif (fallback 48000) LE', async () => {
- const { result } = renderHook(() => useAudioRecording())
- const blob = result.current.exportWAV()
- const buf = await blobToArrayBuffer(blob)
- const view = new DataView(buf)
-
- const readString = (off: number, len: number) => {
- let s = ''
- for (let i = 0; i < len; i++) s += String.fromCharCode(view.getUint8(off + i))
- return s
- }
- expect(readString(0, 4)).toBe('RIFF')
- expect(readString(8, 4)).toBe('WAVE')
- expect(readString(12, 4)).toBe('fmt ')
- expect(readString(36, 4)).toBe('data')
-
- // Sans start() (pas de contexte en jsdom) → rate par défaut 48000.
- expect(view.getUint32(24, true)).toBe(48000)
- // Aucun chunk → dataLength = 0.
- expect(view.getUint32(40, true)).toBe(0)
- })
-
- it('reset : remet durationSeconds à 0 et exportWAV au header seul', () => {
- const { result } = renderHook(() => useAudioRecording())
- act(() => {
- result.current.reset()
- })
- expect(result.current.durationSeconds).toBe(0)
- expect(result.current.exportWAV().size).toBe(44)
- })
-})
diff --git a/src/shared/lib/audio/useAudioCapture.ts b/src/shared/lib/audio/useAudioCapture.ts
deleted file mode 100644
index e618580..0000000
--- a/src/shared/lib/audio/useAudioCapture.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-/**
- * useAudioCapture — Hook de capture micro pour T2 Live (Sprint 6b).
- *
- * Encapsule le pipeline :
- * getUserMedia → AudioContext → AudioWorklet (pcm-capture-processor.js)
- * → chunks PCM 16 kHz Int16 LE → base64 → onChunk()
- *
- * Le worklet gère le rééchantillonnage si le sample rate natif diffère de 16 kHz.
- * Le hook ne touche pas au WebSocket — l'appelant (Sprint 6c) branche `onChunk`
- * sur `ws.send`.
- *
- * Cleanup garanti : tracks.stop(), worklet.disconnect(), context.close() au
- * stop() ou au démontage du composant.
- */
-
-import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
-import { arrayBufferToBase64 } from '@/shared/lib/audio-utils'
-
-export interface UseAudioCaptureOptions {
- /** Callback invoqué pour chaque chunk PCM 16 kHz encodé en base64. */
- onChunk: (base64: string) => void
-}
-
-export interface UseAudioCaptureResult {
- start: () => Promise
- stop: () => void
- isCapturing: boolean
- error: string | null
- /**
- * MediaStream micro actif (null hors capture). Lecture NON réactive (ref) —
- * jamais republié en state (cause de la régression Step 4).
- */
- stream: MediaStream | null
- /**
- * AnalyserNode DÉRIVÉ du graphe de capture (source.connect en parallèle du
- * worklet) — pour visualiser l'amplitude micro sans toucher au flux montant.
- * Exposé par REF stable : le consommateur le lit en rAF sans déclencher de
- * re-render. null hors capture.
- */
- analyserRef: RefObject
- /**
- * AudioContext de capture (rate natif). Exposé par REF pour que la lecture IA
- * (useAudioPlayback) et l'enregistrement WAV (useAudioRecording) partagent la
- * MÊME horloge — condition de l'alignement temporel natif (Voie A). null hors
- * capture.
- */
- contextRef: RefObject
- /**
- * GainNode de mixage : point unique où convergent le micro et la voix IA. Le
- * tap d'enregistrement (Sprint 6e Step 3) s'y branche. Le micro y est routé
- * EN PLUS du worklet/analyser ; il n'est PAS connecté au destination (pas
- * d'écho de sa propre voix). null hors capture.
- */
- mixNodeRef: RefObject
-}
-
-const WORKLET_URL = '/pcm-capture-processor.js'
-
-export function useAudioCapture(options: UseAudioCaptureOptions): UseAudioCaptureResult {
- const [isCapturing, setIsCapturing] = useState(false)
- const [error, setError] = useState(null)
- // BISECTION 6e — Step 4 neutralisé : plus de state réactif sur le stream
- // (aucun setState → aucun re-render déclenché par la publication du stream).
- // Le stream reste interne (streamRef) et est exposé en lecture non réactive.
-
- const contextRef = useRef(null)
- const streamRef = useRef(null)
- const workletNodeRef = useRef(null)
- const sourceNodeRef = useRef(null)
- const analyserRef = useRef(null)
- const mixNodeRef = useRef(null)
-
- // Capture options dans une ref pour éviter de réabonner les effets
- // sur chaque render (l'appelant fournit souvent un onChunk inline).
- const optionsRef = useRef(options)
- useEffect(() => {
- optionsRef.current = options
- })
-
- const cleanup = useCallback(() => {
- if (workletNodeRef.current) {
- try {
- workletNodeRef.current.port.onmessage = null
- workletNodeRef.current.disconnect()
- } catch {
- /* ignore */
- }
- workletNodeRef.current = null
- }
- if (analyserRef.current) {
- try {
- analyserRef.current.disconnect()
- } catch {
- /* ignore */
- }
- analyserRef.current = null
- }
- if (mixNodeRef.current) {
- try {
- mixNodeRef.current.disconnect()
- } catch {
- /* ignore */
- }
- mixNodeRef.current = null
- }
- if (sourceNodeRef.current) {
- try {
- sourceNodeRef.current.disconnect()
- } catch {
- /* ignore */
- }
- sourceNodeRef.current = null
- }
- if (streamRef.current) {
- streamRef.current.getTracks().forEach((t) => {
- try {
- t.stop()
- } catch {
- /* ignore */
- }
- })
- streamRef.current = null
- }
- if (contextRef.current) {
- try {
- void contextRef.current.close()
- } catch {
- /* ignore */
- }
- contextRef.current = null
- }
- }, [])
-
- const start = useCallback(async () => {
- if (isCapturing) return
- setError(null)
-
- try {
- const stream = await navigator.mediaDevices.getUserMedia({
- audio: {
- channelCount: 1,
- echoCancellation: true,
- noiseSuppression: true,
- },
- })
- streamRef.current = stream
-
- // Rate NATIF (Voie A) : on ne force plus 16 kHz. Le worklet uplink
- // (pcm-capture-processor) est rate-aware — il lit le sampleRate global du
- // contexte et rééchantillonne vers 16 kHz au besoin, donc le flux montant
- // reste un vrai 16 kHz quelle que soit la fréquence native. Garder le rate
- // natif permet à la lecture IA et à l'enregistrement de partager une seule
- // horloge (alignement temporel natif sans resample dans le chemin WAV).
- const ctx = new AudioContext()
- contextRef.current = ctx
-
- await ctx.audioWorklet.addModule(WORKLET_URL)
-
- const source = ctx.createMediaStreamSource(stream)
- sourceNodeRef.current = source
-
- const workletNode = new AudioWorkletNode(ctx, 'pcm-capture-processor')
- workletNodeRef.current = workletNode
-
- workletNode.port.onmessage = (e: MessageEvent) => {
- try {
- optionsRef.current.onChunk(arrayBufferToBase64(e.data))
- } catch {
- /* ignore — ne pas casser le worklet sur callback throw */
- }
- }
-
- source.connect(workletNode)
- // Pas besoin de connecter au destination — on ne lit pas le micro local.
-
- // DÉRIVATION : branche un analyser EN PARALLÈLE sur la même source. Il
- // n'est pas inséré dans le chemin source→worklet→WS (flux montant
- // strictement inchangé) et ne se connecte pas au destination.
- const analyser = ctx.createAnalyser()
- analyser.fftSize = 256
- analyser.smoothingTimeConstant = 0.6
- source.connect(analyser)
- analyserRef.current = analyser
-
- // MIX (Voie A) : point de convergence unique micro + voix IA. Le micro y
- // est routé EN PLUS du worklet/analyser. Le mixGain n'est PAS connecté au
- // destination ici (pas d'écho de la voix du candidat) ; la voix IA s'y
- // branchera (Step 2) et le tap d'enregistrement le captera (Step 3).
- const mixGain = ctx.createGain()
- source.connect(mixGain)
- mixNodeRef.current = mixGain
-
- setIsCapturing(true)
- } catch (err) {
- const message = err instanceof Error ? err.message : 'Unknown error'
- setError(message)
- cleanup()
- }
- }, [cleanup, isCapturing])
-
- const stop = useCallback(() => {
- cleanup()
- setIsCapturing(false)
- }, [cleanup])
-
- // Cleanup au démontage.
- useEffect(() => {
- return () => {
- cleanup()
- }
- }, [cleanup])
-
- // Lecture non réactive (ref) — stream, analyser, contexte et mix exposés sans setState.
- return {
- start,
- stop,
- isCapturing,
- error,
- stream: streamRef.current,
- analyserRef,
- contextRef,
- mixNodeRef,
- }
-}
diff --git a/src/shared/lib/audio/useAudioPlayback.ts b/src/shared/lib/audio/useAudioPlayback.ts
deleted file mode 100644
index 7bc9940..0000000
--- a/src/shared/lib/audio/useAudioPlayback.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * useAudioPlayback — Hook de lecture audio pour T2 Live (Sprint 6b ; Voie A 6e).
- *
- * Reçoit des chunks PCM 24 kHz Int16 LE encodés en base64 (format Gemini Live)
- * et les joue séquentiellement sans gaps via AudioBufferSourceNode.
- *
- * Sprint 6e (Voie A) : ce hook ne crée PLUS son propre AudioContext. Il utilise
- * le contexte PARTAGÉ de la capture (rate natif), exposé par ref. La voix IA est
- * connectée à `ctx.destination` (audible) ET au `mixNode` de capture (point de
- * convergence où le tap d'enregistrement Step 3 prélèvera le mix). Partager une
- * seule horloge est la condition de l'alignement temporel natif du WAV.
- *
- * Le buffer reste créé au rate Gemini (24 kHz) ; le contexte (rate natif, ex.
- * 48 kHz) le rééchantillonne automatiquement à la lecture.
- *
- * Stratégie de planification : chaque chunk est programmé via
- * `source.start(max(ctx.currentTime, lastEndTime))` → lecture continue même si
- * les chunks arrivent par bursts.
- *
- * Le hook ne touche pas au WebSocket — l'appelant appelle `playChunk(base64)`
- * à chaque message audio reçu.
- */
-
-import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
-import { base64ToArrayBuffer, int16ToFloat32 } from '@/shared/lib/audio-utils'
-
-const PLAYBACK_SAMPLE_RATE = 24000
-
-export interface UseAudioPlaybackOptions {
- /**
- * Contexte de capture PARTAGÉ (rate natif). null tant que la capture n'a pas
- * démarré — un chunk IA reçu avant est ignoré (cf. race dans playChunk).
- */
- contextRef: RefObject
- /**
- * Point de mixage de la capture : on y route la voix IA EN PLUS du
- * destination, pour que le tap d'enregistrement (Step 3) capte le mix. null
- * hors capture.
- */
- mixNodeRef: RefObject
-}
-
-export interface UseAudioPlaybackResult {
- playChunk: (base64: string) => void
- stop: () => void
- isPlaying: boolean
-}
-
-export function useAudioPlayback({
- contextRef,
- mixNodeRef,
-}: UseAudioPlaybackOptions): UseAudioPlaybackResult {
- const [isPlaying, setIsPlaying] = useState(false)
-
- const lastEndTimeRef = useRef(0)
- // Timer qui repasse `isPlaying` à false quand la file se vide.
- const isPlayingTimerRef = useRef | null>(null)
- const activeSourcesRef = useRef>(new Set())
-
- // Arrête les sources IA en cours SANS fermer le contexte (la capture en est
- // propriétaire — le fermer ici couperait l'uplink et l'enregistrement).
- const stopSources = useCallback(() => {
- if (isPlayingTimerRef.current !== null) {
- clearTimeout(isPlayingTimerRef.current)
- isPlayingTimerRef.current = null
- }
- activeSourcesRef.current.forEach((s) => {
- try {
- s.stop()
- s.disconnect()
- } catch {
- /* ignore */
- }
- })
- activeSourcesRef.current.clear()
- lastEndTimeRef.current = 0
- }, [])
-
- const playChunk = useCallback(
- (base64: string) => {
- const ctx = contextRef.current
- // Race : un chunk IA peut arriver avant que la capture ait fini de créer
- // le contexte partagé (addModule). Dans ce cas on ignore le chunk plutôt
- // que d'ouvrir un contexte concurrent (qui casserait l'horloge unique).
- if (!ctx || ctx.state === 'closed') {
- return
- }
- const mix = mixNodeRef.current
- try {
- const arrayBuffer = base64ToArrayBuffer(base64)
- const int16 = new Int16Array(arrayBuffer)
- const float32 = int16ToFloat32(int16)
-
- if (float32.length === 0) return
-
- const audioBuffer = ctx.createBuffer(1, float32.length, PLAYBACK_SAMPLE_RATE)
- audioBuffer.getChannelData(0).set(float32)
-
- const source = ctx.createBufferSource()
- source.buffer = audioBuffer
- // Audible via destination ET routé vers le mix de capture (le tap
- // d'enregistrement Step 3 y prélève le mix micro + voix IA).
- source.connect(ctx.destination)
- if (mix) source.connect(mix)
-
- const startTime = Math.max(ctx.currentTime, lastEndTimeRef.current)
- source.start(startTime)
- const duration = float32.length / PLAYBACK_SAMPLE_RATE
- lastEndTimeRef.current = startTime + duration
-
- activeSourcesRef.current.add(source)
- source.onended = () => {
- activeSourcesRef.current.delete(source)
- try {
- source.disconnect()
- } catch {
- /* ignore */
- }
- }
-
- setIsPlaying(true)
- // Replanifier le passage à false après la fin programmée.
- if (isPlayingTimerRef.current !== null) {
- clearTimeout(isPlayingTimerRef.current)
- }
- const remainingMs = (lastEndTimeRef.current - ctx.currentTime) * 1000
- isPlayingTimerRef.current = setTimeout(() => {
- setIsPlaying(false)
- isPlayingTimerRef.current = null
- }, remainingMs + 50)
- } catch {
- /* ignore — ne pas casser l'app sur un chunk malformé */
- }
- },
- [contextRef, mixNodeRef],
- )
-
- const stop = useCallback(() => {
- stopSources()
- setIsPlaying(false)
- }, [stopSources])
-
- // Cleanup au démontage : on arrête seulement les sources (le contexte est
- // fermé par la capture, propriétaire de l'horloge partagée).
- useEffect(() => {
- return () => {
- stopSources()
- }
- }, [stopSources])
-
- return { playChunk, stop, isPlaying }
-}
diff --git a/src/shared/lib/audio/useAudioRecording.ts b/src/shared/lib/audio/useAudioRecording.ts
deleted file mode 100644
index 6c7eb92..0000000
--- a/src/shared/lib/audio/useAudioRecording.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-/**
- * useAudioRecording — Hook d'enregistrement WAV pour T2 Live
- * (Sprint 6b ; réécrit Sprint 6e — Voie A, tap temps réel).
- *
- * Abandon du réassemblage offline deux pistes (offsets + resample + concat +
- * mix) qui collapsait les tours IA (Bug 3, ancrage unique). Nouvelle approche :
- * un AudioWorklet d'enregistrement (`pcm-record-processor`) est branché EN
- * DÉRIVATION sur le `mixGain` du contexte PARTAGÉ de capture, où convergent déjà
- * le micro et la voix IA. Il prélève le mix au rate NATIF du contexte en temps
- * réel → alignement temporel natif, une seule horloge, zéro resample.
- *
- * Graphe du tap (le sink gain(0) garantit le pull du graphe SANS résidu
- * audible) : mixGain → recordNode → gain(0) → destination.
- *
- * Cycle de vie (piloté par useT2LiveSession) :
- * - start(ctx, mixNode) = à l'ouverture du WS, une fois capture.start() résolu
- * (contexte + mixGain existants).
- * - stop() = « Terminer le dialogue » UNIQUEMENT : on débranche le
- * tap, mais le buffer Int16 accumulé SURVIT (il vit dans une ref hors du
- * cycle de vie du contexte) → exportWAV() reste appelable après fermeture du
- * contexte.
- * - « Annuler » = pas d'export ; closeAll() ferme le contexte, le
- * buffer est simplement abandonné.
- *
- * Le hook ne touche pas au WebSocket.
- */
-
-import { useCallback, useRef, useState } from 'react'
-import { buildWavHeader, concatInt16 } from '@/shared/lib/audio-utils'
-
-const RECORD_WORKLET_URL = '/pcm-record-processor.js'
-const FALLBACK_SAMPLE_RATE = 48000
-
-export interface UseAudioRecordingResult {
- /** Branche le tap d'enregistrement sur le mix du contexte partagé. */
- start: (ctx: AudioContext, mixNode: GainNode) => Promise
- /** Débranche le tap. Le buffer accumulé survit pour exportWAV(). */
- stop: () => void
- /** Construit un Blob WAV mono au rate natif du contexte d'enregistrement. */
- exportWAV: () => Blob
- /** Durée totale en secondes (mise à jour à chaque chunk reçu du worklet). */
- durationSeconds: number
- /** Vide le buffer accumulé. */
- reset: () => void
-}
-
-export function useAudioRecording(): UseAudioRecordingResult {
- // Buffer Int16 accumulé HORS du cycle de vie du contexte (ref pure) : il
- // survit à la fermeture du contexte sur endDialogue → exportWAV reste
- // appelable. Jamais une closure du worklet (qui meurt avec le contexte).
- const chunksRef = useRef([])
- const totalSamplesRef = useRef(0)
- // Rate capturé au start (= ctx.sampleRate natif) : le WAV est écrit à ce rate.
- const sampleRateRef = useRef(0)
-
- const recordNodeRef = useRef(null)
- const sinkRef = useRef(null)
- const mixRef = useRef(null)
-
- const [durationSeconds, setDurationSeconds] = useState(0)
-
- const start = useCallback(async (ctx: AudioContext, mixNode: GainNode) => {
- // Idempotent : un tap déjà actif n'est pas redoublé.
- if (recordNodeRef.current) return
- sampleRateRef.current = ctx.sampleRate
-
- await ctx.audioWorklet.addModule(RECORD_WORKLET_URL)
-
- const recordNode = new AudioWorkletNode(ctx, 'pcm-record-processor')
- recordNode.port.onmessage = (e: MessageEvent) => {
- const int16 = new Int16Array(e.data)
- if (int16.length === 0) return
- chunksRef.current.push(int16)
- totalSamplesRef.current += int16.length
- setDurationSeconds(totalSamplesRef.current / (sampleRateRef.current || FALLBACK_SAMPLE_RATE))
- }
-
- // Tap : mix → recordNode → sink(gain 0) → destination. Le gain est
- // STRICTEMENT 0 : il force le graphe à tirer le recordNode (pull
- // cross-navigateur) sans laisser passer le moindre résidu audible vers les
- // haut-parleurs (pas d'écho du mix).
- const sink = ctx.createGain()
- sink.gain.value = 0
-
- mixNode.connect(recordNode)
- recordNode.connect(sink)
- sink.connect(ctx.destination)
-
- recordNodeRef.current = recordNode
- sinkRef.current = sink
- mixRef.current = mixNode
- }, [])
-
- const stop = useCallback(() => {
- // Débranche le tap proprement. Le buffer (chunksRef) n'est PAS touché : il
- // survit pour exportWAV(), y compris après fermeture du contexte.
- if (mixRef.current && recordNodeRef.current) {
- try {
- mixRef.current.disconnect(recordNodeRef.current)
- } catch {
- /* ignore */
- }
- }
- if (recordNodeRef.current) {
- try {
- recordNodeRef.current.port.onmessage = null
- recordNodeRef.current.disconnect()
- } catch {
- /* ignore */
- }
- recordNodeRef.current = null
- }
- if (sinkRef.current) {
- try {
- sinkRef.current.disconnect()
- } catch {
- /* ignore */
- }
- sinkRef.current = null
- }
- mixRef.current = null
- }, [])
-
- const exportWAV = useCallback((): Blob => {
- const pcm = concatInt16(chunksRef.current)
- const rate = sampleRateRef.current || FALLBACK_SAMPLE_RATE
- const dataLength = pcm.byteLength
- const header = buildWavHeader(dataLength, rate)
- // Uint8Array : certains environnements (jsdom) ne gèrent pas les ArrayBuffer
- // bruts dans le constructeur Blob. `pcm.buffer` est un ArrayBuffer exact
- // (alloué par concatInt16) — le cast resserre le type ArrayBufferLike.
- const pcmBytes = new Uint8Array(pcm.buffer as ArrayBuffer)
- return new Blob([new Uint8Array(header), pcmBytes], { type: 'audio/wav' })
- }, [])
-
- const reset = useCallback(() => {
- chunksRef.current = []
- totalSamplesRef.current = 0
- setDurationSeconds(0)
- }, [])
-
- return {
- start,
- stop,
- exportWAV,
- durationSeconds,
- reset,
- }
-}
diff --git a/src/shared/lib/auth-client.ts b/src/shared/lib/auth-client.ts
index 3d7c73b..de6cca3 100644
--- a/src/shared/lib/auth-client.ts
+++ b/src/shared/lib/auth-client.ts
@@ -1,4 +1,4 @@
-import { createClient, type Session, type User } from '@supabase/supabase-js'
+import { createClient } from '@supabase/supabase-js'
import { env } from '@/shared/config/env'
import { logger } from './logger'
@@ -17,32 +17,6 @@ export async function signIn(email: string, password: string) {
return supabase.auth.signInWithPassword({ email, password })
}
-export async function signUp(email: string, password: string) {
- return supabase.auth.signUp({ email, password })
-}
-
export async function signOut() {
return supabase.auth.signOut()
}
-
-export async function getCurrentSession(): Promise {
- const { data, error } = await supabase.auth.getSession()
- if (error) {
- logger.error('Auth session fetch failed', { name: error.name })
- return null
- }
- return data.session
-}
-
-/**
- * S'abonne aux changements d'état d'authentification Supabase.
- * Retourne une fonction de désabonnement à appeler au cleanup.
- */
-export function subscribeToAuthChanges(callback: (session: Session | null) => void): () => void {
- const { data } = supabase.auth.onAuthStateChange((_event, session) => {
- callback(session)
- })
- return () => data.subscription.unsubscribe()
-}
-
-export type { Session, User }
diff --git a/src/shared/lib/date.ts b/src/shared/lib/date.ts
deleted file mode 100644
index f6d06ba..0000000
--- a/src/shared/lib/date.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Helper de formatage de dates relatives — zéro dépendance (Intl.RelativeTimeFormat).
- *
- * Exemple : `formatRelativeDate('2026-04-22T10:00:00Z', now)` → « il y a 2 jours »
- *
- * Seuils : secondes → minutes → heures → jours → semaines → mois → années.
- * Locale fixée à `'fr'` — Expria est monolingue français (cf. DESIGN_SYSTEM.md §10).
- */
-
-const RTF = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
-
-const MINUTE = 60
-const HOUR = 60 * MINUTE
-const DAY = 24 * HOUR
-const WEEK = 7 * DAY
-const MONTH = 30 * DAY
-const YEAR = 365 * DAY
-
-export function formatRelativeDate(iso: string, now: Date = new Date()): string {
- const then = new Date(iso).getTime()
- if (!Number.isFinite(then)) return ''
- const diffSec = Math.round((then - now.getTime()) / 1000)
- const abs = Math.abs(diffSec)
-
- if (abs < MINUTE) return RTF.format(Math.round(diffSec / 1), 'second')
- if (abs < HOUR) return RTF.format(Math.round(diffSec / MINUTE), 'minute')
- if (abs < DAY) return RTF.format(Math.round(diffSec / HOUR), 'hour')
- if (abs < WEEK) return RTF.format(Math.round(diffSec / DAY), 'day')
- if (abs < MONTH) return RTF.format(Math.round(diffSec / WEEK), 'week')
- if (abs < YEAR) return RTF.format(Math.round(diffSec / MONTH), 'month')
- return RTF.format(Math.round(diffSec / YEAR), 'year')
-}
diff --git a/src/shared/lib/theme.ts b/src/shared/lib/theme.ts
deleted file mode 100644
index 5545cfb..0000000
--- a/src/shared/lib/theme.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { createContext } from 'react'
-
-export type Theme = 'light' | 'dark'
-
-export interface ThemeContextValue {
- theme: Theme
- setTheme: (t: Theme) => void
-}
-
-export const ThemeContext = createContext(null)
-
-const STORAGE_KEY = 'expria-theme'
-
-export function getInitialTheme(): Theme {
- const stored = localStorage.getItem(STORAGE_KEY)
- if (stored === 'light' || stored === 'dark') return stored
- if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light'
- return 'dark'
-}
-
-export function applyTheme(theme: Theme): void {
- document.documentElement.classList.toggle('light', theme === 'light')
-}
-
-export function persistTheme(theme: Theme): void {
- localStorage.setItem(STORAGE_KEY, theme)
-}
diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts
deleted file mode 100644
index d32b0fe..0000000
--- a/src/shared/lib/utils.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { type ClassValue, clsx } from 'clsx'
-import { twMerge } from 'tailwind-merge'
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
-}
diff --git a/src/shared/ui/Badge.tsx b/src/shared/ui/Badge.tsx
deleted file mode 100644
index f91a4a4..0000000
--- a/src/shared/ui/Badge.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * Primitive Badge — Design System Expria (DESIGN_SYSTEM.md §4).
- *
- * Variants :
- * plan — couleur selon le plan (free / standard / premium)
- * nclc — score NCLC (bleu Expria)
- * neutral — étiquette générique (gris)
- *
- * Taille fixe, text-xs uppercase tracking-wide (DESIGN_SYSTEM.md §3 — eyebrow style).
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { cn } from '@/shared/lib/utils'
-
-export type BadgeVariant = 'plan' | 'nclc' | 'neutral'
-export type BadgePlanValue = 'free' | 'standard' | 'premium'
-
-export interface BadgeProps {
- variant: BadgeVariant
- planValue?: BadgePlanValue
- className?: string
- children: React.ReactNode
-}
-
-const planClasses: Record = {
- free: 'bg-surface text-ink-secondary',
- standard: 'bg-brand-soft text-brand-text',
- premium: 'bg-sidebar-bg text-white',
-}
-
-const variantClasses: Record = {
- plan: '', // résolu dynamiquement via planValue
- nclc: 'bg-brand-soft text-brand-text',
- neutral: 'bg-surface text-ink-secondary',
-}
-
-export function Badge({ variant, planValue, className, children }: BadgeProps) {
- const colorClasses =
- variant === 'plan' && planValue ? planClasses[planValue] : variantClasses[variant]
-
- return (
-
- {children}
-
- )
-}
diff --git a/src/shared/ui/Button.tsx b/src/shared/ui/Button.tsx
deleted file mode 100644
index 823f39e..0000000
--- a/src/shared/ui/Button.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * Primitive Button — Design System Expria (DESIGN_SYSTEM.md §4).
- *
- * Variants : primary / secondary / ghost / upgrade
- * Sizes : sm / md (défaut) / lg
- * États : loading (spinner + disabled auto), disabled
- *
- * Règle L : tokens Direction H exclusivement.
- * DESIGN_SYSTEM.md §8 : pas de nouvelle dépendance — lucide-react déjà présent.
- */
-
-import { Loader2 } from 'lucide-react'
-import { cn } from '@/shared/lib/utils'
-
-export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'upgrade'
-export type ButtonSize = 'sm' | 'md' | 'lg'
-
-export interface ButtonProps extends React.ButtonHTMLAttributes {
- variant?: ButtonVariant
- size?: ButtonSize
- icon?: React.ReactNode
- loading?: boolean
- className?: string
-}
-
-const variantClasses: Record = {
- primary: 'bg-brand text-white hover:bg-brand-hover active:bg-brand-active disabled:bg-brand/50',
- secondary:
- 'border border-border bg-surface text-ink-primary hover:bg-surface-hover hover:text-ink-primary disabled:text-ink-tertiary',
- ghost:
- 'bg-transparent text-ink-secondary hover:bg-surface-hover hover:text-ink-primary disabled:text-ink-tertiary',
- upgrade:
- 'bg-sidebar-bg text-white hover:bg-sidebar-bg/90 active:bg-sidebar-bg/85 disabled:bg-sidebar-bg/50',
-}
-
-const sizeClasses: Record = {
- sm: 'h-8 gap-1.5 rounded-md px-3 text-xs',
- md: 'h-9 gap-2 rounded-md px-4 text-sm',
- lg: 'h-11 gap-2 rounded-lg px-6 text-base',
-}
-
-export function Button({
- variant = 'primary',
- size = 'md',
- icon,
- loading = false,
- disabled,
- className,
- children,
- ...props
-}: ButtonProps) {
- const isDisabled = disabled || loading
-
- return (
-
- {loading ? (
-
- ) : (
- icon && {icon}
- )}
- {children}
-
- )
-}
diff --git a/src/shared/ui/Card.tsx b/src/shared/ui/Card.tsx
deleted file mode 100644
index 21980b6..0000000
--- a/src/shared/ui/Card.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * Primitive Card — Design System Expria (DESIGN_SYSTEM.md §4).
- *
- * Variants :
- * default — surface bordée, ombre légère
- * raised — élévation plus marquée (MetricCard hero, recommandations)
- * interactive — hover state + curseur pointer ; rendu en si onClick fourni
- *
- * Règle L : tokens Direction H exclusivement.
- */
-
-import { cn } from '@/shared/lib/utils'
-
-export type CardVariant = 'default' | 'raised' | 'interactive'
-
-interface CardBaseProps {
- variant?: CardVariant
- className?: string
- children: React.ReactNode
-}
-
-interface CardDivProps extends CardBaseProps {
- onClick?: undefined
-}
-
-interface CardButtonProps extends CardBaseProps {
- onClick: () => void
-}
-
-export type CardProps = CardDivProps | CardButtonProps
-
-const baseClasses = 'rounded-lg border border-border bg-surface'
-
-const variantClasses: Record = {
- default: 'shadow-card',
- raised: 'shadow-raised',
- interactive:
- 'shadow-card cursor-pointer transition-colors duration-150 hover:border-brand hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus',
-}
-
-export function Card({ variant = 'default', className, children, onClick }: CardProps) {
- const classes = cn(baseClasses, variantClasses[variant], className)
-
- if (onClick) {
- return (
-
- {children}
-
- )
- }
-
- return {children}
-}