feat(design-system): reskin Charcoal — tokens dark-default + sidebar navy permanent

- Remplacement intégral 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+light)
- Script anti-FOUC inline dans index.html
- Layout : radial-gradient sur <main>, sidebar 230px, max-w-[1100px]
- Renommage tokens Boréal→Charcoal sur ~45 composants
- Inversion dark: → baseline + light: sur primitives shadcn
- Fix logo blanc forcé dans sidebar
- ADR 006 mis à jour

Typecheck: OK · Tests: 122/122 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-24 23:07:38 +03:00
parent 407d1bd134
commit b68f160bce
61 changed files with 1269 additions and 726 deletions

View file

@ -29,13 +29,40 @@ Chaque entrée suit ce format :
--- ---
## [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 `<main>`, 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 ## [Unreleased] — 2026-04-23 — Clean FTD-23 + FTD-24
### Fixed ### 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-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. - **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 ### Notes
- Tests frontend : 122/122 verts (+7 vs baseline 115). - Tests frontend : 122/122 verts (+7 vs baseline 115).
- TECH_DEBT.md → v1.19. 10 FTD actives (cap 15). - TECH_DEBT.md → v1.19. 10 FTD actives (cap 15).
@ -44,10 +71,12 @@ Chaque entrée suit ce format :
## [Unreleased] — 2026-04-23 — FTD-28 — Semgrep CI + CI verte ## [Unreleased] — 2026-04-23 — FTD-28 — Semgrep CI + CI verte
### Added ### Added
- Semgrep scan (`--severity=ERROR`) dans les CI frontend et backend (FTD-28). - Semgrep scan (`--severity=ERROR`) dans les CI frontend et backend (FTD-28).
- Variables d'env factices dans CI frontend pour les tests. - Variables d'env factices dans CI frontend pour les tests.
### Fixed ### Fixed
- 4 erreurs ESLint corrigées : split SimulationFlowProvider (react-refresh), hook conditionnel MonProfilPreparation, ref render useTimer, setState effect AppLayout. - 4 erreurs ESLint corrigées : split SimulationFlowProvider (react-refresh), hook conditionnel MonProfilPreparation, ref render useTimer, setState effect AppLayout.
- Prettier format sur 7 fichiers. - Prettier format sur 7 fichiers.
- CI frontend verte pour la première fois depuis le 18 avril. - CI frontend verte pour la première fois depuis le 18 avril.
@ -57,6 +86,7 @@ Chaque entrée suit ce format :
## [Unreleased] — 2026-04-23 — FTD-27 — CI backend ## [Unreleased] — 2026-04-23 — FTD-27 — CI backend
### Added ### Added
- `expria-backend/.github/workflows/ci.yml` — CI GitHub Actions (test + audit, Node 22). CI verte au premier run. - `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). - FTD-27 fermée dans TECH_DEBT.md (v1.17).
@ -65,6 +95,7 @@ Chaque entrée suit ce format :
## [Unreleased] — 2026-04-23 — FTD-29 — Dependabot config ## [Unreleased] — 2026-04-23 — FTD-29 — Dependabot config
### Added ### Added
- `.github/dependabot.yml` créé dans les 2 dépôts (npm, weekly, limit 10 PRs). - `.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). - FTD-29 fermée dans TECH_DEBT.md (v1.16).
@ -73,6 +104,7 @@ Chaque entrée suit ce format :
## [Unreleased] — 2026-04-23 — Réorg sécurité TECH_DEBT v1.15 ## [Unreleased] — 2026-04-23 — Réorg sécurité TECH_DEBT v1.15
### Changed ### Changed
- `TECH_DEBT.md` v1.14 → v1.15 — réorganisation sécurité. - `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). - 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). - Ajoutées : FTD-27 🔴 (CI backend), FTD-28 🔴 (Semgrep CI), FTD-29 🟡 (Dependabot config).
@ -83,6 +115,7 @@ Chaque entrée suit ce format :
## [Unreleased] — 2026-04-23 — Triage FTD v1.14 ## [Unreleased] — 2026-04-23 — Triage FTD v1.14
### Changed ### Changed
- `TECH_DEBT.md` v1.13 → v1.14 — triage dette technique : 17 → 15 FTD actives (cap respecté). - `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). - 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). - Ajoutées : FTD-25 🟢 (ARCHITECTURE.md §3 désaligné), FTD-26 🟡 (cohabitation shared/ui vs shared/components/ui).
@ -92,14 +125,15 @@ Chaque entrée suit ce format :
## [Unreleased] ## [Unreleased]
### Added ### Added
- Documentation initiale du projet (ARCHITECTURE, ONBOARDING, SECURITY, etc.) - Documentation initiale du projet (ARCHITECTURE, ONBOARDING, SECURITY, etc.)
- 5 ADRs pour les décisions architecturales majeures - 5 ADRs pour les décisions architecturales majeures
- Code source de `src/entities/user/access.ts` et `lib.ts` avec tests - Code source de `src/entities/user/access.ts` et `lib.ts` avec tests
## [Unreleased] — 2026-04-22 — Sprint 3.5 — Clean post-Sprint 3 ## [Unreleased] — 2026-04-22 — Sprint 3.5 — Clean post-Sprint 3
### Changed ### 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-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-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`. - **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`.
@ -107,13 +141,14 @@ Chaque entrée suit ce format :
- `TECH_DEBT.md` → v1.11. 15 FTD actives (cap de 15 respecté). - `TECH_DEBT.md` → v1.11. 15 FTD actives (cap de 15 respecté).
### Notes ### 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. - 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é). - 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) ## [Unreleased] — 2026-04-22 — Sprint 3.6c — Analyse patterns (Backend + Frontend)
### Added (backend) ### Added (backend)
- `GET /users/patterns` — analyse des patterns récurrents pour utilisateur Premium. - `GET /users/patterns` — analyse des patterns récurrents pour utilisateur Premium.
- Auth : `authMiddleware` + `planMiddleware('pattern_analysis')` (403 `PLAN_INSUFFICIENT` si Free/Standard). - Auth : `authMiddleware` + `planMiddleware('pattern_analysis')` (403 `PLAN_INSUFFICIENT` si Free/Standard).
- < 5 productions corrigées `200 { ready: false, minimum: 5, current: N }`. - < 5 productions corrigées `200 { ready: false, minimum: 5, current: N }`.
@ -126,12 +161,13 @@ Chaque entrée suit ce format :
- 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). - 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) ### Added (frontend)
- Page `/progression` — route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`. - Page `/progression` — route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`.
- `ProgressionPage` — orchestre `usePlan` + `usePatterns`, gate plan via `hasAccess('pattern_analysis')`. - `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 ». - `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. - `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). - `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). - **`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`. - `NotReadyState` — barre de progression N/5 + CTA `Démarrer une simulation`.
- `BlurredProgression` — aperçu flouté pour Free/Standard + bouton upgrade Premium. - `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). - 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).
@ -141,15 +177,16 @@ Chaque entrée suit ce format :
- 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). - 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 ### Notes
- **Formule indice** arbitraire (60/20/20) — à affiner après observation prod si besoin. - **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). - **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. - **`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. - **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) ## [Unreleased] — 2026-04-22 — Sprint 3.7 — Historique (Backend + Frontend)
### Added (backend) ### Added (backend)
- `GET /simulations` — liste paginée des productions de l'utilisateur connecté. - `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). - Query params : `page` (défaut 1, entier ≥ 1), `limit` (défaut 20, entier entre 1 et 50).
- Tri : `created_at DESC` côté Supabase. - Tri : `created_at DESC` côté Supabase.
@ -161,6 +198,7 @@ Chaque entrée suit ce format :
- 12 nouveaux tests sur la route `GET /simulations` (186 tests backend verts, +12 vs baseline 174). - 12 nouveaux tests sur la route `GET /simulations` (186 tests backend verts, +12 vs baseline 174).
### Added (frontend) ### Added (frontend)
- Page `/historique` (route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`). - Page `/historique` (route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`).
- `HistoriquePage` — orchestre `usePlan` + `useSimulationsList`, state local de pagination, gating plan Free via `hasAccess('dashboard')`. - `HistoriquePage` — orchestre `usePlan` + `useSimulationsList`, state local de pagination, gating plan Free via `hasAccess('dashboard')`.
- `SimulationsList` — composant liste avec : - `SimulationsList` — composant liste avec :
@ -178,15 +216,16 @@ Chaque entrée suit ce format :
- 18 nouveaux tests frontend (7 `date.test.ts` + 11 `SimulationsList.test.tsx`). - 18 nouveaux tests frontend (7 `date.test.ts` + 11 `SimulationsList.test.tsx`).
### Notes ### 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`. - 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. - `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. - 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). - Tests frontend : **102/102 verts** (+18 vs baseline 84).
## [Unreleased] — 2026-04-22 — Sprint 3.6b — Qualité correction — Frontend ## [Unreleased] — 2026-04-22 — Sprint 3.6b — Qualité correction — Frontend
### Added ### Added
- `NclcCibleSelector` (segmented control NCLC 9 / NCLC 10) dans `SimulationForm` — valeur propagée au payload `POST /corrections/ee` via `SimulationFlowProvider.submitText(texte, nclcCible)`. - `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/` : - 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. - `ScoreHero` — score /20, jauge avec marqueur du seuil NCLC cible, écart vs objectif (« X points avant NCLC 9 »), badges NCLC atteint / cible.
@ -202,6 +241,7 @@ Chaque entrée suit ce format :
- FTD-24 🟡 dans `TECH_DEBT.md` — polling automatique pour exercices/modèle `pending` (refresh manuel en MVP). - FTD-24 🟡 dans `TECH_DEBT.md` — polling automatique pour exercices/modèle `pending` (refresh manuel en MVP).
### Changed ### 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/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/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/production/types.ts``SimulationState` étendu avec `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` ; `SimulationRapport` aligné sur `CorrectionRapport` backend.
@ -210,30 +250,34 @@ Chaque entrée suit ce format :
- `floutage.test.ts` réécrit (17 tests — matrice de visibilité + helpers lib). - `floutage.test.ts` réécrit (17 tests — matrice de visibilité + helpers lib).
### Fixed ### 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. - **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 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`. - **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`. - **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 ### Added
- Bouton « Nouvelle simulation » en bas de `RapportPage` qui `reset()` + `navigate('/simulation/ee')`. - 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. - `reset()` explicite dans le bouton « ← Retour » de `SujetsPage` avant la navigation, pour empêcher tout re-déclenchement de la garde sticky.
### Changed ### 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. - 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). - `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 : `<Link>` remplacé par `<button>` qui `reset()` avant navigate. - `RapportPage` breadcrumb : `<Link>` remplacé par `<button>` qui `reset()` avant navigate.
### Notes ### Notes
- **Option β retenue** : frontend aligné sur la structure backend réelle du Sprint 3.6a. Aucun aller-retour backend. - **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 ». - `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'`. - 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). - Tests : **84/84 verts** (+8 vs baseline 76).
## [Unreleased] — 2026-04-22 — Sprint 3.6a — Qualité correction — Backend ## [Unreleased] — 2026-04-22 — Sprint 3.6a — Qualité correction — Backend
### Added (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`. - `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}`). - 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`. - Post-traitement production modèle : `wordCountTCF`, `stripModelAnnotations`, `truncateToMaxWords`.
@ -243,42 +287,47 @@ Chaque entrée suit ce format :
- `docs/TECH_DEBT.md` TD-15 🟡 : jobs fire-and-forget peuvent rester `pending` si redémarrage process. - `docs/TECH_DEBT.md` TD-15 🟡 : jobs fire-and-forget peuvent rester `pending` si redémarrage process.
### Changed (backend) ### 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`. - `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). - `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. - `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é). - `deepseek.test.ts` réécrit — 25 tests (ancien pipeline supprimé, nouveaux tests sur correctEE/generateProductionModele/generateExercices/helpers + EO inchangé).
### Notes ### 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. - **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. - 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. - 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). - Tests backend : 173/173 verts (+18 vs baseline de 155).
## [Unreleased] — 2026-04-22 — Planification Sprint 3.6a/3.6b/3.6c ## [Unreleased] — 2026-04-22 — Planification Sprint 3.6a/3.6b/3.6c
### Added ### 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. - 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. - `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` ## 2026-04-21 — FTD-21 — Persistance session `/simulation/ee`
### Added ### Added
- `useAutosave(simulationId, contenu, enabled)` : autosave debounce 30 s + flush sur `beforeunload`, dedup par dernier contenu sauvegardé (6 tests). - `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). - `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`. - Types `SimulationState`, `SimulationRapport` + API `getSimulationState`, `autosaveContenu`, `updateSujet` dans `entities/production`.
- Indicateur "Sauvegardé à HH:MM" sous la textarea `SimulationForm` (text-xs, `aria-live="polite"`). - Indicateur "Sauvegardé à HH:MM" sous la textarea `SimulationForm` (text-xs, `aria-live="polite"`).
### Changed ### 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.". - `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'`. - `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). - `changeSubject` persiste le changement côté backend via `PATCH /simulations/:id/sujet` (best-effort, silencieux si échec).
### Security ### Security
- localStorage ne stocke que `simulation_id` (UUID non-sensible) — conforme SECURITY.md §2.6. - localStorage ne stocke que `simulation_id` (UUID non-sensible) — conforme SECURITY.md §2.6.
### Notes ### Notes
- FTD-21 reste ouvert pour `/simulation/eo` (Sprint 4) et `/examen` (Sprint 7). - FTD-21 reste ouvert pour `/simulation/eo` (Sprint 4) et `/examen` (Sprint 7).
--- ---
@ -286,6 +335,7 @@ Chaque entrée suit ce format :
## 2026-04-21 — Tâche G5 — Suggestions d'idées DeepSeek ## 2026-04-21 — Tâche G5 — Suggestions d'idées DeepSeek
### Ajouté ### Ajouté
- **Backend**`POST /sujets/idees` : génère 5 suggestions - **Backend**`POST /sujets/idees` : génère 5 suggestions
d'idées via DeepSeek pour aider l'étudiant à prolonger sa d'idées via DeepSeek pour aider l'étudiant à prolonger sa
rédaction (prompt coach TCF Canada, temperature 0.5, rédaction (prompt coach TCF Canada, temperature 0.5,
@ -310,6 +360,7 @@ Chaque entrée suit ce format :
`planData.plan` depuis `SimulationPage`) `planData.plan` depuis `SimulationPage`)
### Règles d'accès ### Règles d'accès
- Règle D respectée : `hasAccess(plan, 'tips')` obligatoire - Règle D respectée : `hasAccess(plan, 'tips')` obligatoire
- Plan Free : bouton visible mais désactivé avec tooltip - Plan Free : bouton visible mais désactivé avec tooltip
"Disponible en Standard" (tips=false pour Free) "Disponible en Standard" (tips=false pour Free)
@ -318,6 +369,7 @@ Chaque entrée suit ce format :
`idees.isLoading` `idees.isLoading`
### Tests ### Tests
- Backend — Typecheck : 0 erreur, Vitest : 144/144 passés - Backend — Typecheck : 0 erreur, Vitest : 144/144 passés
(+5 tests POST /sujets/idees) (+5 tests POST /sujets/idees)
- Frontend — Typecheck : 0 erreur, Vitest : 67/67 passés - Frontend — Typecheck : 0 erreur, Vitest : 67/67 passés
@ -325,10 +377,10 @@ Chaque entrée suit ce format :
à 30+ mots, modal affiche 5 idées) et Free (bouton à 30+ mots, modal affiche 5 idées) et Free (bouton
verrouillé avec tooltip) verrouillé avec tooltip)
## 2026-04-21 — Tâche G4 + Refonte page /sujets + Fix quota simulations ## 2026-04-21 — Tâche G4 + Refonte page /sujets + Fix quota simulations
### Ajouté ### Ajouté
- **Tâche G4** — choix du sujet avec dropdown intégré et bouton - **Tâche G4** — choix du sujet avec dropdown intégré et bouton
aléatoire dans SimulationForm (hook `useSujets`, composant aléatoire dans SimulationForm (hook `useSujets`, composant
`SujetSelector`, `getSujets()` sur `GET /sujets?mode=&tache=`) `SujetSelector`, `getSujets()` sur `GET /sujets?mode=&tache=`)
@ -344,6 +396,7 @@ Chaque entrée suit ce format :
jusqu'au Sprint EO) jusqu'au Sprint EO)
### Modifié ### Modifié
- `useSimulation` refacto en consommateur de - `useSimulation` refacto en consommateur de
`SimulationFlowProvider` (source de vérité déplacée hors du hook) `SimulationFlowProvider` (source de vérité déplacée hors du hook)
- `SujetDisplay` redevient présentationnel (dropdown retiré) - `SujetDisplay` redevient présentationnel (dropdown retiré)
@ -351,6 +404,7 @@ Chaque entrée suit ce format :
Expression Écrite (affiche uniquement EE T1/T2/T3) Expression Écrite (affiche uniquement EE T1/T2/T3)
### Corrigé ### Corrigé
- **Quota simulations (backend — commit `ecb478e`, expria-backend)** : - **Quota simulations (backend — commit `ecb478e`, expria-backend)** :
incrément `simulations_used` déplacé de incrément `simulations_used` déplacé de
`simulationController.create()` vers `correctionController.correctEE/EO` `simulationController.create()` vers `correctionController.correctEE/EO`
@ -358,21 +412,23 @@ Chaque entrée suit ce format :
plus le quota utilisateur. plus le quota utilisateur.
### Supprimé ### Supprimé
- `SujetSelector.tsx` — orphelin après refonte `/sujets` - `SujetSelector.tsx` — orphelin après refonte `/sujets`
- Helper `selectSujet` de `useSimulation` — orphelin - Helper `selectSujet` de `useSimulation` — orphelin
- FTD-22 tracée résolue partiellement (step `'choosing-subject'` - FTD-22 tracée résolue partiellement (step `'choosing-subject'`
+ `goToSubjectPicker` conservés intentionnellement) - `goToSubjectPicker` conservés intentionnellement)
### Tests ### Tests
- Typecheck : 0 erreur - Typecheck : 0 erreur
- Vitest : 67/67 passés - Vitest : 67/67 passés
- Test manuel : flux complet EE T1 avec choix de sujet - Test manuel : flux complet EE T1 avec choix de sujet
(carte + aléatoire + changement de sujet) validé (carte + aléatoire + changement de sujet) validé
## 2026-04-21 — Tâches G2+G3 — Clavier + Minuteur ## 2026-04-21 — Tâches G2+G3 — Clavier + Minuteur
### Ajouté ### Ajouté
- Composant SpecialCharsKeyboard — 30 caractères spéciaux - Composant SpecialCharsKeyboard — 30 caractères spéciaux
français en flex-wrap, sticky au scroll français en flex-wrap, sticky au scroll
- Bloc "Temps restant" sticky avec TimerDisplay MM:SS - Bloc "Temps restant" sticky avec TimerDisplay MM:SS
@ -382,25 +438,27 @@ Chaque entrée suit ce format :
- Hook useTimer avec 7 tests unitaires - Hook useTimer avec 7 tests unitaires
- Config par tâche dans simulationConfig.ts - Config par tâche dans simulationConfig.ts
(EE T1: 10min/60-120 mots, T2: 20min/120-150, (EE T1: 10min/60-120 mots, T2: 20min/120-150,
T3: 30min/120-180) T3: 30min/120-180)
- Auto-submit à l'expiration si ≥ 30 mots - Auto-submit à l'expiration si ≥ 30 mots
- Bouton "Soumettre ma production" (était "Envoyer") - Bouton "Soumettre ma production" (était "Envoyer")
- Textarea auto-resize sans scroll interne - Textarea auto-resize sans scroll interne
### Changed ### Changed
- Compteur de caractères remplacé par WordCountBar - Compteur de caractères remplacé par WordCountBar
- Bouton soumission bloqué si < 30 mots - Bouton soumission bloqué si < 30 mots
### Tests ### Tests
- Typecheck : 0 erreur - Typecheck : 0 erreur
- Vitest : 66/66 passés (+7 tests useTimer) - Vitest : 66/66 passés (+7 tests useTimer)
- Test manuel : minuteur + clavier validés sur mobile - Test manuel : minuteur + clavier validés sur mobile
et desktop et desktop
## 2026-04-21 — Tâche G1 — Affichage de la consigne ## 2026-04-21 — Tâche G1 — Affichage de la consigne
### Ajouté ### Ajouté
- Interface SujetData dans entities/production/types.ts - Interface SujetData dans entities/production/types.ts
- Production enrichie avec sujet: SujetData | null - Production enrichie avec sujet: SujetData | null
- Composant SujetDisplay — affiche consigne, rôle, contexte, doc1, doc2 selon le sujet retourné - Composant SujetDisplay — affiche consigne, rôle, contexte, doc1, doc2 selon le sujet retourné
@ -409,40 +467,46 @@ Chaque entrée suit ce format :
- FTD-21 tracée (persistance session simulation) - FTD-21 tracée (persistance session simulation)
### Tests ### Tests
- Typecheck : 0 erreur - Typecheck : 0 erreur
- Vitest : 59/59 passés - Vitest : 59/59 passés
- Test manuel : consigne affichée sur /simulation/ee - Test manuel : consigne affichée sur /simulation/ee
## 2026-04-20 — Audit frontend ↔ backend — alignement types Report ## 2026-04-20 — Audit frontend ↔ backend — alignement types Report
### Modifié ### Modifié
- `src/entities/report/types.ts``Critere.note``Critere.score`, `Report.exercices: Exercice[]``Report.exercices: string[]`, JSDoc ajusté - `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 - `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é ### Supprimé
- Interface `Exercice { titre, contenu }` de `entities/report/types.ts` — remplacée par `string[]` pour coller au contrat backend - Interface `Exercice { titre, contenu }` de `entities/report/types.ts` — remplacée par `string[]` pour coller au contrat backend
### Contexte (backend associé, expria-backend) ### Contexte (backend associé, expria-backend)
Quatre commits côté backend finalisent l'alignement du contrat `Report` : 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)`: 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(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)`: 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) - `feat(simulations)`: sujet aléatoire (table `sujets`) retourné avec chaque production créée (EO_T2_LIVE exclu, non bloquant si aucun sujet actif)
### Tests ### Tests
- Typecheck : 0 erreur - Typecheck : 0 erreur
- Vitest : 59/59 passés - Vitest : 59/59 passés
### À faire (hors scope — session frontend dédiée ultérieurement) ### À faire (hors scope — session frontend dédiée ultérieurement)
- Ajouter `sujet: SujetData | null` dans `entities/production/types.ts` - Ajouter `sujet: SujetData | null` dans `entities/production/types.ts`
- Consommer le sujet retourné dans `SimulationPage` (affichage consigne + docs) - 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) - 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 ## 2026-04-20 — Sprint 0.5 bis — AppLayout + primitives UI + refonte visuelle
### Ajouté ### Ajouté
- `src/app/AppLayout.tsx` — layout applicatif desktop/mobile (sidebar fixe 240px, drawer mobile, BottomNav) - `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/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/MobileHeader.tsx` — header mobile sticky (Logo, ThemeToggle, bouton menu hamburger)
@ -452,15 +516,18 @@ Quatre commits côté backend finalisent l'alignement du contrat `Report` :
- `src/shared/ui/Badge.tsx` — primitive Badge (variants: plan/nclc/neutral ; couleur selon `planValue` pour variant plan) - `src/shared/ui/Badge.tsx` — primitive Badge (variants: plan/nclc/neutral ; couleur selon `planValue` pour variant plan)
### Modifié ### Modifié
- `src/app/router.tsx` — layout routes via `PrivateLayout` (`ProtectedRoute` + `AppLayout` + `Outlet`) ; `ComingSoon` inline ; redirect `/simulation``/simulation/ee` - `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/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 `<main>` ; `Button` migré vers `@/shared/ui/Button` `variant="secondary"` - `src/features/simulations/pages/SimulationPage.tsx` — suppression header interne (Logo + ThemeToggle) ; root `<main>` ; `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/` - `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 ### Documentation
- `docs/TECH_DEBT.md` v1.6 — ajout FTD-18 (SimulationForm migration Button), FTD-19 (token `--shadow-focus` manquant) - `docs/TECH_DEBT.md` v1.6 — ajout FTD-18 (SimulationForm migration Button), FTD-19 (token `--shadow-focus` manquant)
### Tests ### Tests
- Typecheck : 0 erreur - Typecheck : 0 erreur
- Vitest : 59/59 passés - Vitest : 59/59 passés
- Tests manuels : à valider par Hermann - Tests manuels : à valider par Hermann
@ -470,6 +537,7 @@ Quatre commits côté backend finalisent l'alignement du contrat `Report` :
## 2026-04-19 — Sprint 1 / Étape 6 — Maintenance mode + outillage sécurité ## 2026-04-19 — Sprint 1 / Étape 6 — Maintenance mode + outillage sécurité
### Ajouté ### Ajouté
- Page de maintenance statique (`src/app/MaintenancePage.tsx`) — logo + message, tokens Direction H, zéro dépendance - 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 - 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`) - Variable `VITE_MAINTENANCE_MODE` dans `env.ts` (optionnelle, défaut `false`)
@ -478,10 +546,12 @@ Quatre commits côté backend finalisent l'alignement du contrat `Report` :
- MCP server Semgrep enregistré dans Claude Code - MCP server Semgrep enregistré dans Claude Code
### Documentation ### Documentation
- `ARCHITECTURE.md` §7 — ajout `VITE_MAINTENANCE_MODE` dans la liste des variables - `ARCHITECTURE.md` §7 — ajout `VITE_MAINTENANCE_MODE` dans la liste des variables
- `TECH_DEBT.md` — FTD-16 résolu (maintenance mode implémenté) - `TECH_DEBT.md` — FTD-16 résolu (maintenance mode implémenté)
### Tests ### Tests
- Typecheck : 0 erreur - Typecheck : 0 erreur
- Vitest : 37/37 passés - Vitest : 37/37 passés
- Test manuel : maintenance mode vérifié (page affichée, aucun appel réseau, routing bloqué) - Test manuel : maintenance mode vérifié (page affichée, aucun appel réseau, routing bloqué)

View file

@ -1,91 +1,94 @@
# DESIGN_SYSTEM.md — Expria Frontend # DESIGN_SYSTEM.md — Expria Frontend
> **Document de référence — Version 1.0 — Sprint 1** > **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. > 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. > 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 ## 1. Direction artistique — verrouillée
**Nom :** Boréal **Nom :** Charcoal
**Positionnement :** institutionnel chaleureux, premium sans flashy, sérieux sans austère. **Positionnement :** outil pro sérieux, premium sans scolaire, immersif sans austère.
**Référence mentale :** Stripe Dashboard, Linear, Notion Desktop — mais réchauffé d'un cran. **Référence mentale :** Linear, Notion Desktop, Primo TCF — sidebar sombre permanente, contenu aéré.
### Parti pris fondateurs ### Parti pris fondateurs
| Principe | Décision | | Principe | Décision |
|---|---| |---|---|
| Mode canonique Sprint 1 | **Clair uniquement** (light chaud) | | Mode par défaut | **Dark** (charcoal chaud `#111111`) |
| Mode sombre | Prévu Sprint 2+ (tokens écrits dual-theme-ready dès J1) | | Mode clair | Activé — fond gris froid `#F3F4F6`, cartes blanches |
| Fond principal | `#F4F2EC` (off-white calibré, ni froid ni saturé) | | Détection thème | `prefers-color-scheme` au chargement, toggle manuel, persistance `localStorage` |
| Surfaces élevées | Blanc pur `#FFFFFF` pour contraste subtil avec le fond | | Sidebar | **Navy `#0C1528` permanent** — identique dark et light. C'est l'ancre visuelle de la marque. |
| Bleu de marque | `#1B4FD8` **sacro-saint** en mode clair — aucune variation | | Fond principal (dark) | `#111111` avec deux halos bleus subtils (`radial-gradient` à 45% opacité) |
| Bleu mode sombre | `#7C9BFF` **prévu** pour Sprint 2+ (pattern Apple system colors) | | Fond principal (light) | `#F3F4F6` avec deux halos bleus très discrets (23% opacité) |
| Accent chaleureux | Aucun en Sprint 1 — le bleu porte toute l'intentionnalité | | 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 | | Angles | Rayons généreux mais retenus : 8 / 12 / 16 px |
| Ombres | Minimales. 1 ombre-card unique, très subtile. Hairlines 1px privilégiées. | | Ombres (dark) | Aucune — la bordure 1px et la transparence suffisent |
| Ombres (light) | Minimales. `shadow-card` subtile sur les surfaces élevées |
| Animations | 150200 ms, `ease-out`, respect de `prefers-reduced-motion` | | Animations | 150200 ms, `ease-out`, respect de `prefers-reduced-motion` |
| Icônes | SVG inline dans `shared/ui/icons/` — aucune dépendance externe | | Icônes | `lucide-react` pour les icônes standard. SVG inline dans `shared/ui/icons/` pour les icônes custom |
| Typographie | Plus Jakarta Sans (via `font-family`, fallback système) | | 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 ### Ce qu'on refuse explicitement
- Gradients criards (le seul acceptable : aucun). - 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 bottom nav mobile si besoin. - 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. - Emojis dans les éléments interactifs ou les labels fonctionnels.
- Ombres lourdes, "drop shadows" style Material Design 2. - Ombres lourdes, "drop shadows" style Material Design 2.
- Plus de 2 niveaux d'élévation visuelle (fond → card → modal). - Plus de 3 niveaux d'élévation visuelle (fond → surface → surface-raised → modal).
- Toute police de display fantaisiste, serif décorative ou condensée. - Toute police autre que Plus Jakarta Sans.
- Les motifs SaaS génériques : illustrations 3D, dégradés violet-rose, glass blobs. - 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` ## 2. Tokens — `src/index.css`
Remplacer intégralement le contenu actuel (`@import 'tailwindcss';`) par le bloc ci-dessous. Tailwind 4 lit automatiquement les tokens déclarés dans `@theme`. 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 ```css
@import 'tailwindcss'; @import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@theme { @theme {
/* ----- Brand ------------------------------------------------------- */ /* ══════════════════════════════════════════════════════════════════════
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: #1B4FD8;
--color-brand-hover: #1744B8; --color-brand-hover: #1744B8;
--color-brand-active: #13379C; --color-brand-active: #13379C;
--color-brand-soft: #E7EDFC; --color-brand-dark: #1740b0;
--color-brand-ink: #FFFFFF; --color-brand-ink: #FFFFFF;
/* ----- Surfaces (light — Sprint 1) --------------------------------- */ /* ── Semantic ── */
--color-bg: #F4F2EC; --color-warning: #f59e0b;
--color-surface: #FBFAF6; --color-warning-soft: rgba(245, 158, 11, 0.12);
--color-surface-raised: #FFFFFF; --color-danger: #ef4444;
--color-surface-sunken: #EEECE4; --color-danger-soft: rgba(239, 68, 68, 0.12);
/* ----- Ink (texte) ------------------------------------------------- */ /* ── Typography ── */
--color-ink-primary: #0F1220;
--color-ink-secondary: #4A4F5E;
--color-ink-tertiary: #8A8F9E;
--color-ink-inverse: #FBFAF6;
/* ----- Borders & dividers ------------------------------------------ */
--color-border: #E3E0D6;
--color-border-strong: #C9C5B7;
--color-border-focus: #1B4FD8;
/* ----- Feedback ---------------------------------------------------- */
--color-success: #1F7A4C;
--color-success-soft: #E3F2EA;
--color-warning: #B8741A;
--color-warning-soft: #F7EEDF;
--color-danger: #B8322D;
--color-danger-soft: #F7E1DF;
/* ----- Typographie ------------------------------------------------- */
--font-sans: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif; --font-sans: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
/* Échelle : mobile-first, les tailles desktop se gèrent via utilities Tailwind */
--text-xs: 11px; --text-xs: 11px;
--text-sm: 13px; --text-sm: 13px;
--text-base: 14px; --text-base: 14px;
@ -96,7 +99,7 @@ Remplacer intégralement le contenu actuel (`@import 'tailwindcss';`) par le blo
--text-3xl: 32px; --text-3xl: 32px;
--text-display: 40px; --text-display: 40px;
/* ----- Rayons ------------------------------------------------------ */ /* ── Rayons ── */
--radius-xs: 6px; --radius-xs: 6px;
--radius-sm: 8px; --radius-sm: 8px;
--radius-md: 12px; --radius-md: 12px;
@ -104,18 +107,76 @@ Remplacer intégralement le contenu actuel (`@import 'tailwindcss';`) par le blo
--radius-xl: 20px; --radius-xl: 20px;
--radius-pill: 999px; --radius-pill: 999px;
/* ----- Ombres ------------------------------------------------------ */ /* ── Focus ── */
--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);
--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18); --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;
} }
/* --------------------------------------------------------------------- */ /* ══════════════════════════════════════════════════════════════════════
/* Globals — reset minimal, fond chaud par défaut */ LIGHT MODE — override .light sur <body>
/* --------------------------------------------------------------------- */ ══════════════════════════════════════════════════════════════════════ */
.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 { html, body {
background: var(--color-bg); background: var(--color-canvas);
color: var(--color-ink-primary); color: var(--color-ink-primary);
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@ -135,119 +196,201 @@ html, body {
transition-duration: 0ms !important; transition-duration: 0ms !important;
} }
} }
/* --------------------------------------------------------------------- */
/* TODO Sprint 2+ — Dark theme (Cadence recalibré) */
/* --------------------------------------------------------------------- */
/*
@theme {
--color-bg-dark: #0F1320;
--color-surface-dark: #171B2B;
--color-surface-raised-dark: #1E2338;
--color-ink-primary-dark: #E6E4DB;
--color-ink-secondary-dark: #9CA0AC;
--color-brand-dark: #7C9BFF;
--color-brand-hover-dark: #9AB3FF;
--color-border-dark: #2A3048;
}
*/
``` ```
### Règles d'usage des tokens ### Règles d'usage des tokens
1. **Aucune valeur hexadécimale en dur** dans les composants. Toute couleur passe par un token. 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-surface`, pas `bg-gray-50`. 2. **Nommage sémantique obligatoire.** On écrit `bg-[var(--color-surface)]`, pas `bg-white` ni `bg-gray-50`.
3. Si un cas d'usage exige une teinte hors charte, **le documenter ici avant de l'ajouter**. Pas de token orphelin. 3. **Ne jamais utiliser** `bg-white`, `bg-gray-*`, `text-gray-*` — ces classes Tailwind cassent le dual-theme.
4. Les tokens marqués `*-dark` ne sont **pas utilisés en Sprint 1**. Leur présence en commentaire est intentionnelle pour faciliter la reprise Sprint 2+. 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. Typographie ## 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 `<head>` de `index.html` :
```html
<script>
(function(){
var t = localStorage.getItem('expria-theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
if (t === 'light') document.documentElement.classList.add('light');
})();
</script>
```
---
## 4. Typographie
| Usage | Taille | Poids | Tracking | Ligne | Token | | Usage | Taille | Poids | Tracking | Ligne | Token |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Display (NCLC hero) | 40px | 700 | -0.02em | 1.0 | `text-display` | | Display (NCLC hero) | 40px | 800 | -0.02em | 1.0 | `text-display` |
| H1 page | 32px | 700 | -0.02em | 1.1 | `text-3xl` | | H1 page | 32px | 700 | -0.02em | 1.1 | `text-3xl` |
| H2 section | 24px | 700 | -0.015em | 1.2 | `text-2xl` | | H2 section | 24px | 700 | -0.015em | 1.2 | `text-2xl` |
| H3 card title | 20px | 700 | -0.01em | 1.3 | `text-xl` | | H3 card title | 20px | 700 | -0.01em | 1.3 | `text-xl` |
| Lead / intro | 17px | 500 | -0.005em | 1.5 | `text-lg` | | Lead / intro | 17px | 500 | -0.005em | 1.5 | `text-lg` |
| Body | 14px | 400 | 0 | 1.6 | `text-base` | | Body | 14px | 400 | 0 | 1.6 | `text-base` |
| Body renforcé | 15px | 500 | 0 | 1.55 | `text-md` | | Body renforcé | 15px | 500 | 0 | 1.55 | `text-md` |
| Small / meta | 13px | 400 | 0 | 1.5 | `text-sm` | | Small / meta | 13px | 500 | 0 | 1.5 | `text-sm` |
| Eyebrow / label | 11px | 600 | 0.1em (uppercase) | 1.4 | `text-xs` | | Eyebrow / label | 11px | 600 | 0.1em (uppercase) | 1.4 | `text-xs` |
**Règles :** **Règles :**
- Tout nombre métier (score 16/20, NCLC 7,5, compteur 3/5) est rendu en `font-variant-numeric: tabular-nums`. Hérité par le body, mais à re-spécifier explicitement sur les tables et listes. - Tout nombre métier (score 16/20, NCLC 7,5, compteur 3/5) est rendu en `font-variant-numeric: tabular-nums`.
- `Plus Jakarta Sans` est déclarée en `font-family` avec fallback système. **Aucune webfont chargée** tant qu'on n'a pas validé la stratégie self-hosting (décision reportée). - `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`). - Les chiffres français utilisent la **virgule** comme séparateur décimal (`7,5`, jamais `7.5`).
--- ---
## 4. Primitives UI — Sprint 1 ## 5. Primitives UI
À créer dans `src/shared/ui/` en FSD, une primitive par dossier (`button/`, `card/`, etc.) avec `index.ts` pour l'export. À créer dans `src/shared/ui/` en FSD, une primitive par dossier (`button/`, `card/`, etc.) avec `index.ts` pour l'export.
### Inventaire minimal ### Inventaire
| Composant | Variants | Usage dashboard | | Composant | Variants | Usage |
|---|---|---| |---|---|---|
| `Button` | `primary` / `secondary` / `ghost` / `upgrade` | CTA "Nouvelle simulation", "Passer au plan Standard", actions tertiaires | | `Button` | `primary` / `secondary` / `ghost` / `upgrade` | CTA, actions tertiaires |
| `Card` | `default` / `raised` / `interactive` | Cadre métriques, item simulation, recommandation | | `Card` | `default` / `raised` / `interactive` | Cadre métriques, item simulation |
| `MetricCard` | `default` / `hero` (pour le NCLC) | Bloc NCLC, compteur simulations, dernier score | | `MetricCard` | `default` / `hero` | Bloc NCLC, compteur simulations |
| `ProgressBar` | `default` | Progression vers NCLC 9 | | `ProgressBar` | `default` | Progression vers NCLC 9 |
| `Badge` | `plan` / `nclc` / `neutral` | Plan actuel dans header, niveau NCLC par simulation | | `Badge` | `plan` / `nclc` / `brand` / `success` / `warning` / `danger` | Plan, niveau, chips sémantiques |
| `Sidebar` | — | Nav desktop (≥ 1024px) | | `Sidebar` | — | Nav desktop (≥ 1024px), navy permanent |
| `BottomNav` | — | Nav mobile (< 1024px), 4 items max | | `BottomNav` | — | Nav mobile (< 1024px), 45 items max |
| `PageHeader` | — | Greeting + plan pill | | `ThemeToggle` | — | Bouton soleil/lune dans le footer sidebar |
| `PageHeader` | — | Greeting + plan badge |
| `SectionHeader` | — | Titre de section + action optionnelle | | `SectionHeader` | — | Titre de section + action optionnelle |
### Patterns de référence — copier, ne pas réinterpréter
**Sidebar NavItem actif :**
```tsx
<Link
className={cn(
'relative flex items-center gap-2.5 px-2.5 py-2 rounded-lg',
'text-[13px] font-medium transition-colors',
isActive
? 'bg-[var(--color-sidebar-nav-active)] text-white font-semibold'
: 'text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-nav-hover)] hover:text-[var(--color-sidebar-text-hover)]'
)}
>
{isActive && (
<span className="absolute left-0 top-[20%] bottom-[20%] w-[3px]
rounded-r bg-[var(--color-brand)]" />
)}
<Icon className={cn('w-4 h-4 shrink-0', isActive ? 'opacity-100' : 'opacity-60')} />
{label}
</Link>
```
**Card :**
```tsx
<div className={cn(
'rounded-[var(--radius-md)] border border-[var(--color-border)]',
'bg-[var(--color-surface)] p-[18px] transition-colors',
'shadow-[var(--shadow-card)]',
)}>
{children}
</div>
```
**Bouton CTA primaire :**
```tsx
<button className="w-full py-3.5 rounded-[var(--radius-md)]
bg-gradient-to-br from-[var(--color-brand)] to-[var(--color-brand-dark)]
text-white font-bold text-sm
shadow-[0_4px_20px_rgba(27,79,216,0.15)]
hover:translate-y-[-1px] hover:shadow-[0_6px_28px_rgba(27,79,216,0.25)]
transition-all">
Nouvelle simulation
</button>
```
**Bouton secondaire :**
```tsx
<button className="w-full py-2.5 rounded-[var(--radius-sm)]
border border-[var(--color-border)] bg-transparent
text-[var(--color-ink-secondary)] text-[13px] font-semibold
hover:border-[var(--color-brand)] hover:text-[var(--color-brand-text)]
hover:bg-[var(--color-brand-soft)]
transition-all">
Voir mon profil →
</button>
```
**Badge sémantique :**
```tsx
<span className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full',
'text-[11px] font-semibold border',
variant === 'brand' && 'bg-[var(--color-brand-soft)] text-[var(--color-brand-text)] border-[rgba(27,79,216,0.22)]',
variant === 'success' && 'bg-[var(--color-success-soft)] text-[var(--color-success)] border-[rgba(74,222,128,0.22)]',
)}>
{children}
</span>
```
### Règles d'implémentation ### Règles d'implémentation
- Chaque primitive **accepte `className`** en plus de ses props typées, pour overrides ponctuels. - 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.). - 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. - Aucune primitive ne contient de logique métier ou d'appel API. Elles reçoivent tout par props.
- Les icônes sont passées par une prop `icon` acceptant un `ReactNode`, jamais par nom de string. - Les icônes sont importées de `lucide-react` et passées comme composant, jamais par nom de string.
--- ---
## 5. Layout dashboard — spécification ## 6. Layout principal — `AppLayout`
Les primitives ci-dessus s'assemblent dans `src/features/dashboard/` et `src/pages/dashboard/`. ```tsx
<div className="flex min-h-screen">
### Structure sémantique <Sidebar /> {/* fixed, w-[230px], bg sidebar navy */}
<main
``` className="flex-1 ml-[230px] min-h-screen p-9"
<body> style={{
<Sidebar /> (≥ 1024px) background: `
<main> radial-gradient(ellipse at 35% 0%, var(--color-gradient-a), transparent 55%),
<PageHeader /> (greeting + plan) radial-gradient(ellipse at 80% 100%, var(--color-gradient-b), transparent 50%),
<section> var(--color-canvas)
<MetricCard hero /> (NCLC estimé + progression) `,
<MetricCard /> (simulations restantes) }}
<MetricCard /> (dernier score) >
</section> <div className="max-w-[1100px] mx-auto">
<Button primary /> (Nouvelle simulation) {children}
<Button upgrade /> (Passer au plan Standard) </div>
<section>
<SectionHeader /> (3 dernières simulations)
<Card interactive /> × 3
</section>
<section>
<SectionHeader /> (Prochaine étape recommandée)
<Card raised />
</section>
</main> </main>
<BottomNav /> (< 1024px) </div>
</body>
``` ```
### Breakpoints ### Breakpoints
| Breakpoint | Comportement | | Breakpoint | Comportement |
|---|---| |---|---|
| `< 1024px` | Mono-colonne, `BottomNav` fixe en bas, padding horizontal 20px | | `< 1024px` | Sidebar masquée, `BottomNav` fixe en bas, padding horizontal 20px |
| `≥ 1024px` | Sidebar 240px + contenu centré 860px max, padding horizontal 32px | | `≥ 1024px` | Sidebar 230px + contenu centré 1100px max, padding 36px |
| `≥ 1440px` | Contenu centré 920px max (pas d'élargissement excessif) | | `≥ 1440px` | Contenu centré 1100px max (pas d'élargissement) |
### Densité verticale ### Densité verticale
@ -257,7 +400,7 @@ Les primitives ci-dessus s'assemblent dans `src/features/dashboard/` et `src/pag
--- ---
## 6. Données mock — Sprint 1 ## 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. 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.
@ -276,9 +419,9 @@ export const mockDashboard = {
lastScore: { value: 16, max: 20, type: 'ecrit' as const }, lastScore: { value: 16, max: 20, type: 'ecrit' as const },
}, },
recentSimulations: [ recentSimulations: [
{ id: 's-001', type: 'ecrit', relativeDate: 'il y a 2 jours', score: 16, max: 20, nclc: 8 }, { 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-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 }, { id: 's-003', type: 'ecrit', relativeDate: 'il y a 1 semaine', score: 15, max: 20, nclc: 7 },
], ],
nextStep: { nextStep: {
title: 'Cible une simulation orale cette semaine', title: 'Cible une simulation orale cette semaine',
@ -289,55 +432,76 @@ export const mockDashboard = {
``` ```
**Règles contenu :** **Règles contenu :**
- Aucun "Lorem ipsum", aucune date absolue — relatif uniquement (`il y a X jours`). - Aucun "Lorem ipsum", aucune date absolue — relatif uniquement.
- Les prénoms mocks reflètent l'audience : Yacine, Aminata, Kenza, Bilal, Fatou, Kévin (Canada). - 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). - Les scores suivent une progression crédible (pas de 20/20 ni de 5/20).
--- ---
## 7. Accessibilité — plancher Sprint 1 ## 8. Accessibilité — plancher
- Contraste minimum **WCAG AA** sur tous les couples texte/fond (vérifié pour la palette ci-dessus). - 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). - Tous les éléments interactifs ont un `:focus-visible` avec `--shadow-focus` (halo bleu 3px).
- Les icônes purement décoratives portent `aria-hidden="true"`. - Les icônes décoratives portent `aria-hidden="true"`.
- Les icônes fonctionnelles (sans label visible) portent `aria-label`. - Les icônes fonctionnelles (sans label visible) portent `aria-label`.
- Les landmarks sémantiques sont utilisés : `<header>`, `<nav>`, `<main>`, `<section>`. - Les landmarks sémantiques : `<header>`, `<nav>`, `<main>`, `<section>`.
- Le `BottomNav` mobile respecte la hauteur minimale tap target : **44×44 px** par item. - 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".
--- ---
## 8. Dépendances externes — Sprint 1 ## 9. Dépendances externes
**Aucune nouvelle dépendance UI n'est ajoutée en Sprint 1.** On n'installe pas `shadcn/ui`, pas `lucide-react`, pas `radix-ui`. Les primitives sont écrites à la main pour deux raisons : | Dépendance | Statut | Justification |
|---|---|---|
1. On veut maîtriser **exactement** chaque composant (pas d'override Tailwind contre Radix). | `lucide-react` | ✅ Autorisée | Icônes cohérentes, tree-shakeable, aucun CSS importé |
2. Volume fonctionnel Sprint 1 minimal (~9 primitives) : pas d'économie à externaliser. | `clsx` + `tailwind-merge` | ✅ Autorisées | Utilitaire `cn()` pour merge de classes |
| `shadcn/ui` | ⛔ Interdit | Overrides Tailwind trop complexes pour le volume actuel |
La porte reste ouverte Sprint 2+ pour `radix-ui` sur les primitives complexes (Dialog, Popover, Dropdown) si besoin justifié dans un ADR. | `radix-ui` | 🔒 Reporté | Utilisable si besoin justifié par ADR (Dialog, Popover) |
--- ---
## 9. Journal des décisions DA ## 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 | | Date | Décision | Contexte |
|---|---|---| |---|---|---|
| 2026-04-17 | Direction A (Boréal) validée comme canonique | 5 directions explorées (A/B/E/F/G), A et B retenues, A choisie comme base | | 2026-04-17 | Direction A (Boréal) validée comme base | 5 directions explorées, A choisie |
| 2026-04-17 | Fond `#F4F2EC` (V1 calibré) retenu | Test côte-à-côte contre `#F5F3ED` et `#F2EFE6` | | 2026-04-17 | Fond `#F4F2EC`, light-only, dark reporté Sprint 2+ | Première itération |
| 2026-04-17 | Dark mode (Cadence) reporté Sprint 2+ | Tokens écrits dual-theme-ready dès J1 pour éviter réécriture | | 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-17 | Bleu `#1B4FD8` sacro-saint en light, `#7C9BFF` prévu pour dark | Pattern Apple system colors | | 2026-04-24 | Sidebar navy `#0C1528` permanent dark+light | Cohérence Slack/Discord/Linear, ancre visuelle de marque |
| 2026-04-17 | Pas de shadcn/ui en Sprint 1 | Volume faible, maîtrise totale souhaitée | | 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 |
--- ---
## 10. Hors périmètre Sprint 1 ## 12. Hors périmètre actuel
Éléments explicitement **reportés** : Éléments explicitement **reportés** :
- Dark mode applicatif.
- Thème haut-contraste (WCAG AAA). - Thème haut-contraste (WCAG AAA).
- Internationalisation (i18n) — Sprint 1 monolingue FR. - Internationalisation (i18n) — monolingue FR.
- Animations avancées (scroll-linked, shared element transitions). - Animations avancées (scroll-linked, shared element transitions).
- Illustrations personnalisées / iconographie signature. - Illustrations personnalisées / iconographie signature.
- Self-hosting de la font Plus Jakarta Sans (on reste en fallback système Sprint 1). - 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é. Chacun de ces points mérite un ADR dédié quand il sera abordé.

View file

@ -1,43 +1,51 @@
# ROADMAP.md — Expria Frontend # ROADMAP.md — Expria Frontend
> Source de vérité de l'ordre d'implémentation des sprints. > Source de vérité de l'ordre d'implémentation des sprints.
> Ne pas modifier sans validation de Hermann. > Ne pas modifier sans validation de Hermann.
--- ---
## Sprint 0 — Fondations ✅ ## Sprint 0 — Fondations ✅
1. Scaffold Vite + TypeScript + Tailwind + shadcn/ui 1. Scaffold Vite + TypeScript + Tailwind + shadcn/ui
2. Structure de dossiers complète 2. Structure de dossiers complète
3. docs/ copiés depuis backend + adaptations 3. docs/ copiés depuis backend + adaptations
4. ONBOARDING.md rédigé 4. ONBOARDING.md rédigé
## Sprint 0.5 — Design System ✅ ## Sprint 0.5 — Design System ✅
- Direction artistique Boréal validée - Direction artistique Boréal validée
- Tokens CSS dans index.css - Tokens CSS dans index.css
- DESIGN_SYSTEM.md rédigé - DESIGN_SYSTEM.md rédigé
## Sprint 1 — Auth + API layer ✅ ## Sprint 1 — Auth + API layer ✅
5. auth-client.ts 5. auth-client.ts
6. api-client.ts 6. api-client.ts
7. query-client.ts 7. query-client.ts
8. entities/user/* 8. entities/user/\*
9. features/auth (Login, Register, ProtectedRoute) 9. features/auth (Login, Register, ProtectedRoute)
## Sprint 2 — Dashboard conditionnel ✅ ## Sprint 2 — Dashboard conditionnel ✅
10. usePlan hook 10. usePlan hook
11. shared/components/PaywallModal 11. shared/components/PaywallModal
12. features/dashboard (Free / Standard / Premium) 12. features/dashboard (Free / Standard / Premium)
## Sprint 3 — Simulations EE ✅ ## Sprint 3 — Simulations EE ✅
13. entities/production/* + entities/report/*
13. entities/production/_ + entities/report/_
14. features/simulations (EE T1/T2/T3) 14. features/simulations (EE T1/T2/T3)
15. Affichage rapport avec floutage conditionnel 15. Affichage rapport avec floutage conditionnel
## Sprint 3.5 — Clean ## Sprint 3.5 — Clean
- Factorisation des fichiers modifiés Sprint 3 - Factorisation des fichiers modifiés Sprint 3
- Tests manuels Groupe B + C rejoués - Tests manuels Groupe B + C rejoués
- Commit refactor(simulation-ee) - Commit refactor(simulation-ee)
## Sprint 3.6a — Qualité correction — Backend ✅ ## 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 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 - 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) - Génération parallèle correction + exercices + modèle (await correction, fire-and-forget sur les deux autres)
@ -47,6 +55,7 @@
- Tests : 173 tests verts (+18 vs baseline) - Tests : 173 tests verts (+18 vs baseline)
## Sprint 3.6b — Qualité correction — Frontend ✅ ## Sprint 3.6b — Qualité correction — Frontend ✅
- Sélecteur NCLC cible dans SimulationForm (9 ou 10, défaut 9) — NclcCibleSelector - 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 - 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 - ExerciceInteractive : badge difficulté, zone texte, bouton Indice (une fois), bouton Voir la correction (activé après saisie), explication
@ -56,6 +65,7 @@
- Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive) - Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive)
## Sprint 3.7 — Historique ✅ ## 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. - 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`. - 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. - Gating plan : Free → aperçu flouté + CTA « Passer en Standard » (`hasAccess(plan, 'dashboard')`) ; Standard + Premium → liste complète.
@ -65,6 +75,7 @@
- 102 tests frontend verts (+18 vs baseline 84). - 102 tests frontend verts (+18 vs baseline 84).
## Sprint 3.6c — Analyse patterns (Premium) ✅ ## 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 : `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 : 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 : indice de préparation 0→100 — formule 60 % score moyen + 20 % régularité + 20 % tendance, messages figés (`<40`, `40-70`, `>70`).
@ -76,39 +87,55 @@
- Frontend : hook `usePatterns` (staleTime 60 s, cache partagé entre page et dashboard, `enabled` conditionné par feature). - Frontend : hook `usePatterns` (staleTime 60 s, cache partagé entre page et dashboard, `enabled` conditionné par feature).
- Frontend : 115 tests verts (+13 vs baseline 102). - 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 4 — Simulations EO (audio) ## Sprint 4 — Simulations EO (audio)
16. MediaRecorder + upload audio EO T1/T3 16. MediaRecorder + upload audio EO T1/T3
## Sprint 4.5 — Clean ## Sprint 4.5 — Clean
- Factorisation des fichiers modifiés Sprint 4 - Factorisation des fichiers modifiés Sprint 4
- Tests manuels Groupe B + D rejoués - Tests manuels Groupe B + D rejoués
- Commit refactor(simulation-eo) - Commit refactor(simulation-eo)
## Sprint 5 — Billing ## Sprint 5 — Billing
17. features/billing (Stripe Checkout + prorata) 17. features/billing (Stripe Checkout + prorata)
## Sprint 5.5 — Clean ## Sprint 5.5 — Clean
- Factorisation des fichiers modifiés Sprint 5 - Factorisation des fichiers modifiés Sprint 5
- Tests manuels Groupe E rejoués - Tests manuels Groupe E rejoués
- Commit refactor(billing) - Commit refactor(billing)
## Sprint 6 — T2 Live ## Sprint 6 — T2 Live
18. features/t2-live (ws-client + audio worklet + state machine) 18. features/t2-live (ws-client + audio worklet + state machine)
## Sprint 6.5 — Clean ## Sprint 6.5 — Clean
- Factorisation des fichiers modifiés Sprint 6 - Factorisation des fichiers modifiés Sprint 6
- Tests manuels Groupe D rejoués - Tests manuels Groupe D rejoués
- Commit refactor(t2-live) - Commit refactor(t2-live)
## Sprint 7 — Mode Examen ## Sprint 7 — Mode Examen
19. Timer inarrêtable + readOnly à T=0 19. Timer inarrêtable + readOnly à T=0
## Sprint 7.5 — Clean ## Sprint 7.5 — Clean
- Factorisation des fichiers modifiés Sprint 7 - Factorisation des fichiers modifiés Sprint 7
- Tests manuels Groupe D rejoués - Tests manuels Groupe D rejoués
- Commit refactor(exam-mode) - Commit refactor(exam-mode)
## Sprint 8 — Pré-lancement ## Sprint 8 — Pré-lancement
20. MAINTENANCE_MODE implémenté ✅ (2026-04-19) 20. MAINTENANCE_MODE implémenté ✅ (2026-04-19)
21. Sentry configuré 21. Sentry configuré
22. /ultrareview avant bascule 22. /ultrareview avant bascule

View file

@ -107,85 +107,116 @@ Garder React 19, Vite 8, TypeScript 6 mais downgrader Tailwind 4 → 3 pour "com
Pas de `tailwind.config.ts`. La configuration se fait exclusivement dans `src/index.css`. Pas de `tailwind.config.ts`. La configuration se fait exclusivement dans `src/index.css`.
#### Dark mode #### Mode thème (mis à jour Sprint DA Charcoal — 2026-04-24)
Dark mode class-based (`.dark` sur `<html>`) — toggle manuel via ThemeProvider React (Sprint 0.5 étape 2). Configuré via : **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 `<html>` active le mode clair en override. Configuré via :
```css ```css
@variant dark (&:where(.dark, .dark *)); @custom-variant light (&:where(.light, .light *));
``` ```
Si cette syntaxe est rejetée par une future version de Tailwind 4, le fallback est `@custom-variant dark (...)`. 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 (palette Direction H — validée Sprint 0.5) #### Tokens @theme (DA Charcoal — validée Sprint DA Charcoal 2026-04-24)
```css ```css
@import 'tailwindcss'; @import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@variant dark (&:where(.dark, .dark *)); @custom-variant light (&:where(.light, .light *));
@theme { @theme {
/* Typographie */ /* ── Invariants (identiques dark + light) ── */
--font-sans: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
system-ui, sans-serif;
/* Fonds — bg-canvas = page, bg-surface = cards */ /* Sidebar navy permanent */
--color-canvas: #EEF2F8; --color-sidebar-bg: #0C1528;
--color-canvas-2: #E6EBF4; --color-sidebar-border: rgba(255, 255, 255, 0.07);
--color-surface: #FFFFFF; --color-sidebar-text: rgba(255, 255, 255, 0.6);
--color-surface-hover: #F8FAFD; --color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
--color-sidebar-text-active: #FFFFFF;
/* Hairlines */ --color-sidebar-nav-hover: rgba(255, 255, 255, 0.07);
--color-line: #DDE3ED; --color-sidebar-nav-active: rgba(255, 255, 255, 0.1);
--color-line-strong: #C7D0E0; --color-sidebar-section-label: rgba(255, 255, 255, 0.3);
/* Encres */
--color-ink-1: #0F172A; /* titres */
--color-ink-2: #1E293B; /* corps */
--color-ink-3: #475569;
--color-ink-4: #64748B;
--color-ink-5: #94A3B8; /* désactivé, hints */
/* Brand */ /* Brand */
--color-expria: #1B4FD8; --color-brand: #1B4FD8;
--color-expria-hover: #1741B8; --color-brand-hover: #1744B8;
--color-expria-50: #EEF3FF; --color-brand-active: #13379C;
--color-expria-100: #DCE6FF; --color-brand-dark: #1740B0;
--color-expria-200: #B8CDFF; --color-brand-ink: #FFFFFF;
--color-deep: #0B1F5C;
--color-deep-2: #142B6E;
/* Sémantiques */ /* Semantic (invariants) */
--color-success: #0E9F6E; --color-success-bg: #E6F6F0; --color-warning: #F59E0B;
--color-warning: #C77A00; --color-warning-bg: #FEF3E2; --color-warning-soft: rgba(245, 158, 11, 0.12);
--color-danger: #C53030; --color-danger-bg: #FDECEC; --color-danger: #EF4444;
--color-danger-soft: rgba(239, 68, 68, 0.12);
/* Rayons */ /* Typographie */
--radius-sm: 6px; --radius-md: 10px; --font-sans: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--radius-lg: 14px; --radius-xl: 18px; --radius-full: 999px; --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
/* Ombres */ /* Rayons (override Tailwind) */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); --radius-xs: 6px; --radius-sm: 8px; --radius-md: 12px;
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04); --radius-lg: 16px; --radius-xl: 20px; --radius-pill: 999px;
--shadow-lg: 0 12px 28px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04);
/* 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;
} }
/* Dark mode overrides */ /* Light mode — override sur <html class="light"> */
.dark { .light {
--color-canvas: #0D1220; --color-canvas-2: #121A2D; --color-canvas: #F3F4F6;
--color-surface: #182238; --color-surface-hover: #1E2A42; --color-surface: #FFFFFF;
--color-line: #27324B; --color-line-strong: #364363; --color-surface-hover: #F8F9FB;
--color-ink-1: #F1F4FA; --color-ink-2: #DDE3EF; --color-surface-solid: #FFFFFF;
--color-ink-3: #A8B2C7; --color-ink-4: #7A8499; --color-ink-5: #525C73; --color-surface-raised: #FFFFFF;
--color-expria: #5B7FFF; --color-expria-hover: #6F8EFF; --color-border: rgba(0, 0, 0, 0.07);
--color-expria-50: rgba(91, 127, 255, 0.12); --color-border-strong: rgba(0, 0, 0, 0.14);
--color-deep: #060B1A;
--color-success: #3DD68C; --color-success-bg: rgba(61, 214, 140, 0.12); --color-ink-primary: #0F0F1A;
--color-warning: #F5B849; --color-warning-bg: rgba(245, 184, 73, 0.12); --color-ink-secondary: rgba(0, 0, 0, 0.55);
--color-danger: #F06B6B; --color-danger-bg: rgba(240, 107, 107, 0.12); --color-ink-tertiary: rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --color-ink-inverse: #FFFFFF;
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5); --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);
} }
``` ```
@ -196,14 +227,23 @@ Les tokens `@theme` créent des classes utilitaires directement utilisables :
| Token | Classes Tailwind | | Token | Classes Tailwind |
|---|---| |---|---|
| `--color-canvas` | `bg-canvas`, `text-canvas`, `border-canvas` | | `--color-canvas` | `bg-canvas`, `text-canvas`, `border-canvas` |
| `--color-surface` | `bg-surface`, `text-surface`, `border-surface` | | `--color-surface` | `bg-surface`, `border-surface` |
| `--color-ink-1` | `text-ink-1`, `bg-ink-1` | | `--color-surface-hover` | `bg-surface-hover` |
| `--color-expria` | `bg-expria`, `text-expria`, `border-expria` | | `--color-sidebar-bg` | `bg-sidebar-bg` (navy permanent, identique dark+light) |
| `--color-success` | `text-success`, `bg-success` | | `--color-ink-primary` | `text-ink-primary` |
| `--radius-md` | `rounded-md` (override : 10px au lieu de 6px Tailwind) | | `--color-ink-secondary` | `text-ink-secondary` |
| `--shadow-sm` | `shadow-sm` (override valeurs Tailwind) | | `--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`) |
**Convention critique** : `bg-surface` = cards / modals / panels. `bg-canvas` = fond de page. Ne jamais inverser. **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 #### Typographie

View file

@ -8,9 +8,16 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<script>
;(function () {
var t = localStorage.getItem('expria-theme')
if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
if (t === 'light') document.documentElement.classList.add('light')
})()
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -38,10 +38,16 @@ export function AppLayout({ children }: AppLayoutProps) {
setIsMobileMenuOpen(false) setIsMobileMenuOpen(false)
}, [location.pathname]) }, [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 ( return (
<div className="min-h-screen bg-canvas"> <div className="min-h-screen">
{/* ── DESKTOP — Sidebar fixe 240px ───────────────────────────── */} {/* ── DESKTOP — Sidebar fixe 230px ───────────────────────────── */}
<aside className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:w-60 lg:flex-col"> <aside className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:w-[230px] lg:flex-col">
<Sidebar plan={plan} /> <Sidebar plan={plan} />
</aside> </aside>
@ -53,7 +59,7 @@ export function AppLayout({ children }: AppLayoutProps) {
aria-hidden="true" aria-hidden="true"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
className={cn( className={cn(
'fixed inset-0 z-40 bg-ink-1/30 transition-opacity duration-200 ease-out lg:hidden', 'fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ease-out lg:hidden',
isMobileMenuOpen ? 'opacity-100' : 'pointer-events-none opacity-0', isMobileMenuOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
)} )}
/> />
@ -61,7 +67,7 @@ export function AppLayout({ children }: AppLayoutProps) {
{/* ── MOBILE — Drawer panel ──────────────────────────────────── */} {/* ── MOBILE — Drawer panel ──────────────────────────────────── */}
<div <div
className={cn( className={cn(
'fixed inset-y-0 left-0 z-50 flex w-60 flex-col transition-transform duration-200 ease-out lg:hidden', 'fixed inset-y-0 left-0 z-50 flex w-[230px] flex-col transition-transform duration-200 ease-out lg:hidden',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full', isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full',
)} )}
aria-hidden={!isMobileMenuOpen} aria-hidden={!isMobileMenuOpen}
@ -69,9 +75,14 @@ export function AppLayout({ children }: AppLayoutProps) {
<Sidebar plan={plan} /> <Sidebar plan={plan} />
</div> </div>
{/* ── Zone de contenu ────────────────────────────────────────── */} {/* ── Zone de contenu principale ─────────────────────────────── */}
{/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */} {/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */}
<div className="pb-16 lg:pl-60 lg:pb-0">{children}</div> <main
className="min-h-screen pb-16 lg:pb-0 lg:pl-[230px]"
style={{ background: mainBackground }}
>
<div className="mx-auto max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">{children}</div>
</main>
{/* ── MOBILE — BottomNav fixe ────────────────────────────────── */} {/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}
<BottomNav /> <BottomNav />

View file

@ -5,7 +5,7 @@
* "Simuler" ouvre une bottom sheet (EE / EO / Examen blanc). * "Simuler" ouvre une bottom sheet (EE / EO / Examen blanc).
* Tap target 44×44px minimum (DESIGN_SYSTEM.md §7). * Tap target 44×44px minimum (DESIGN_SYSTEM.md §7).
* *
* Règle L : tokens Direction H exclusivement. * Règle L : tokens du design system exclusivement.
* Règle H : aucune logique métier navigation uniquement. * Règle H : aucune logique métier navigation uniquement.
*/ */
@ -32,6 +32,12 @@ export function BottomNav() {
navigate(to) 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 ( return (
<> <>
{/* Bottom sheet overlay */} {/* Bottom sheet overlay */}
@ -48,9 +54,9 @@ export function BottomNav() {
<div <div
role="dialog" role="dialog"
aria-label="Choisir une simulation" aria-label="Choisir une simulation"
className="fixed bottom-16 left-0 right-0 z-50 rounded-t-xl border-t border-line bg-surface px-2 py-2 shadow-md lg:hidden" className="fixed bottom-16 left-0 right-0 z-50 rounded-t-xl border-t border-border bg-surface px-2 py-2 shadow-raised lg:hidden"
> >
<p className="px-3 pb-2 pt-1 text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="px-3 pb-2 pt-1 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Simuler Simuler
</p> </p>
<ul role="list"> <ul role="list">
@ -59,7 +65,7 @@ export function BottomNav() {
<button <button
type="button" type="button"
onClick={() => handleSheetNavigate(item.to)} onClick={() => handleSheetNavigate(item.to)}
className="flex min-h-[44px] w-full items-center rounded-md px-3 text-sm text-ink-2 transition-colors duration-150 hover:bg-canvas hover:text-ink-1" 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} {item.label}
</button> </button>
@ -72,19 +78,16 @@ export function BottomNav() {
{/* Bottom nav bar */} {/* Bottom nav bar */}
<nav <nav
aria-label="Navigation mobile" aria-label="Navigation mobile"
className="fixed bottom-0 left-0 right-0 z-30 flex h-16 items-center border-t border-line bg-surface lg:hidden" className="fixed bottom-0 left-0 right-0 z-30 flex h-16 items-center border-t border-border bg-surface lg:hidden"
> >
{/* Accueil */} {/* Accueil */}
<Link <Link
to="/dashboard" to="/dashboard"
aria-label="Accueil" aria-label="Accueil"
className={cn( className={navItemClasses(isActive('/dashboard'))}
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
isActive('/dashboard') ? 'text-expria' : 'text-ink-4 hover:text-ink-2',
)}
> >
<Home <Home
className={cn('size-5', isActive('/dashboard') && 'text-expria')} className={cn('size-5', isActive('/dashboard') && 'text-brand-text')}
aria-hidden="true" aria-hidden="true"
/> />
Accueil Accueil
@ -96,13 +99,10 @@ export function BottomNav() {
aria-label="Simuler" aria-label="Simuler"
aria-expanded={isSheetOpen} aria-expanded={isSheetOpen}
onClick={() => setIsSheetOpen((v) => !v)} onClick={() => setIsSheetOpen((v) => !v)}
className={cn( className={navItemClasses(isActive('/simulation') || isSheetOpen)}
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
isActive('/simulation') || isSheetOpen ? 'text-expria' : 'text-ink-4 hover:text-ink-2',
)}
> >
<BookOpen <BookOpen
className={cn('size-5', (isActive('/simulation') || isSheetOpen) && 'text-expria')} className={cn('size-5', (isActive('/simulation') || isSheetOpen) && 'text-brand-text')}
aria-hidden="true" aria-hidden="true"
/> />
Simuler Simuler
@ -112,13 +112,10 @@ export function BottomNav() {
<Link <Link
to="/progression" to="/progression"
aria-label="Progression" aria-label="Progression"
className={cn( className={navItemClasses(isActive('/progression'))}
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
isActive('/progression') ? 'text-expria' : 'text-ink-4 hover:text-ink-2',
)}
> >
<TrendingUp <TrendingUp
className={cn('size-5', isActive('/progression') && 'text-expria')} className={cn('size-5', isActive('/progression') && 'text-brand-text')}
aria-hidden="true" aria-hidden="true"
/> />
Progression Progression
@ -128,13 +125,10 @@ export function BottomNav() {
<Link <Link
to="/parametres" to="/parametres"
aria-label="Compte" aria-label="Compte"
className={cn( className={navItemClasses(isActive('/parametres'))}
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
isActive('/parametres') ? 'text-expria' : 'text-ink-4 hover:text-ink-2',
)}
> >
<User <User
className={cn('size-5', isActive('/parametres') && 'text-expria')} className={cn('size-5', isActive('/parametres') && 'text-brand-text')}
aria-hidden="true" aria-hidden="true"
/> />
Compte Compte

View file

@ -5,16 +5,16 @@ export function MaintenancePage() {
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-canvas px-4 text-center"> <div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-canvas px-4 text-center">
<Logo size="md" /> <Logo size="md" />
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-2xl font-semibold text-ink-1">Maintenance en cours</h1> <h1 className="text-2xl font-semibold text-ink-primary">Maintenance en cours</h1>
<p className="text-sm text-ink-3"> <p className="text-sm text-ink-secondary">
Expria est temporairement indisponible. Revenez dans quelques instants. Expria est temporairement indisponible. Revenez dans quelques instants.
</p> </p>
</div> </div>
<p className="text-xs text-ink-4"> <p className="text-xs text-ink-tertiary">
Des questions ?{' '} Des questions ?{' '}
<a <a
href="mailto:support@expria.ca" href="mailto:support@expria.ca"
className="text-expria underline-offset-4 hover:underline" className="text-brand-text underline-offset-4 hover:underline"
> >
support@expria.ca support@expria.ca
</a> </a>

View file

@ -17,7 +17,7 @@ interface MobileHeaderProps {
export function MobileHeader({ onMenuOpen }: MobileHeaderProps) { export function MobileHeader({ onMenuOpen }: MobileHeaderProps) {
return ( return (
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-line bg-surface px-4 lg:hidden"> <header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-surface px-4 lg:hidden">
<Logo size="sm" /> <Logo size="sm" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ThemeToggle /> <ThemeToggle />
@ -25,7 +25,7 @@ export function MobileHeader({ onMenuOpen }: MobileHeaderProps) {
type="button" type="button"
aria-label="Ouvrir le menu de navigation" aria-label="Ouvrir le menu de navigation"
onClick={onMenuOpen} onClick={onMenuOpen}
className="flex size-9 items-center justify-center rounded-md text-ink-3 transition-colors duration-150 hover:bg-canvas hover:text-ink-1 focus-visible:outline-none focus-visible:shadow-focus" className="flex size-9 items-center justify-center rounded-md text-ink-secondary transition-colors duration-150 hover:bg-surface-hover hover:text-ink-primary focus-visible:outline-none focus-visible:shadow-focus"
> >
<Menu className="size-5" aria-hidden="true" /> <Menu className="size-5" aria-hidden="true" />
</button> </button>

View file

@ -1,9 +1,10 @@
/** /**
* Sidebar desktop navigation principale ( 1024px). * Sidebar desktop navigation principale ( 1024px).
* *
* DA Charcoal : navy permanent (#0C1528), identique dark et light.
* Règle D : le verrouillage des items passe par hasAccess(), * Règle D : le verrouillage des items passe par hasAccess(),
* jamais par if (plan === '...'). * jamais par if (plan === '...').
* Règle L : tokens Direction H exclusivement. * Règle L : tokens du design system exclusivement.
*/ */
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
@ -44,17 +45,33 @@ function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
aria-disabled={locked} aria-disabled={locked}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex items-center justify-between rounded-md px-3 py-2 text-sm transition-colors duration-150', 'relative flex items-center justify-between gap-2.5 rounded-lg px-2.5 py-2',
'text-[13px] font-medium transition-colors',
isActive && !locked isActive && !locked
? 'bg-expria-50 font-medium text-expria' ? 'bg-[var(--color-sidebar-nav-active)] font-semibold text-[var(--color-sidebar-text-active)]'
: locked : locked
? 'cursor-default text-ink-4 opacity-50' ? 'cursor-default text-[var(--color-sidebar-text)] opacity-40'
: 'text-ink-3 hover:bg-canvas hover:text-ink-1', : 'text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-nav-hover)] hover:text-[var(--color-sidebar-text-hover)]',
) )
} }
> >
<span>{item.label}</span> {({ isActive }) => (
{locked && <Lock className="size-3.5 shrink-0 text-ink-5" aria-hidden="true" />} <>
{isActive && !locked && (
<span
aria-hidden="true"
className="absolute bottom-[20%] left-0 top-[20%] w-[3px] rounded-r bg-[var(--color-brand)]"
/>
)}
<span>{item.label}</span>
{locked && (
<Lock
className="size-3.5 shrink-0 text-[var(--color-sidebar-text)] opacity-60"
aria-hidden="true"
/>
)}
</>
)}
</NavLink> </NavLink>
) )
} }
@ -72,7 +89,7 @@ function SidebarSection({
}) { }) {
return ( return (
<div className={className}> <div className={className}>
<p className="mb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-widest text-[var(--color-sidebar-section-label)]">
{label} {label}
</p> </p>
<ul role="list" className="space-y-0.5"> <ul role="list" className="space-y-0.5">
@ -92,10 +109,10 @@ interface SidebarProps {
export function Sidebar({ plan }: SidebarProps) { export function Sidebar({ plan }: SidebarProps) {
return ( return (
<div className="flex h-full w-full flex-col border-r border-line bg-surface"> <div className="flex h-full w-full flex-col border-r border-[var(--color-sidebar-border)] bg-[var(--color-sidebar-bg)]">
{/* Logo */} {/* Logo — forcé en blanc : la sidebar est navy dans les deux thèmes */}
<div className="flex h-14 shrink-0 items-center border-b border-line px-4"> <div className="flex h-14 shrink-0 items-center border-b border-[var(--color-sidebar-border)] px-4">
<Logo size="sm" /> <Logo size="sm" className="text-white" />
</div> </div>
{/* Navigation */} {/* Navigation */}
@ -108,9 +125,9 @@ export function Sidebar({ plan }: SidebarProps) {
</nav> </nav>
{/* Footer — ThemeToggle */} {/* Footer — ThemeToggle */}
<div className="shrink-0 border-t border-line px-4 py-3"> <div className="shrink-0 border-t border-[var(--color-sidebar-border)] px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-ink-5">Thème</span> <span className="text-xs text-[var(--color-sidebar-section-label)]">Thème</span>
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>

View file

@ -20,8 +20,8 @@ const DesignSystemPage = import.meta.env.DEV
function ComingSoon() { function ComingSoon() {
return ( return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-2 px-4 text-center"> <div className="flex min-h-[60vh] flex-col items-center justify-center gap-2 px-4 text-center">
<p className="text-sm font-medium text-ink-2">Page en cours de développement</p> <p className="text-sm font-medium text-ink-primary">Page en cours de développement</p>
<p className="text-xs text-ink-4">Disponible dans une prochaine version.</p> <p className="text-xs text-ink-secondary">Disponible dans une prochaine version.</p>
</div> </div>
) )
} }
@ -83,7 +83,7 @@ export function AppRouter() {
<Route <Route
path="/design-system" path="/design-system"
element={ element={
<Suspense fallback={<div className="p-6 text-ink-4">Loading</div>}> <Suspense fallback={<div className="p-6 text-ink-secondary">Loading</div>}>
<DesignSystemPage /> <DesignSystemPage />
</Suspense> </Suspense>
} }

View file

@ -24,7 +24,7 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div <div
className="flex min-h-screen items-center justify-center bg-canvas text-ink-4" className="flex min-h-screen items-center justify-center bg-canvas text-ink-secondary"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-label="Chargement de la session" aria-label="Chargement de la session"

View file

@ -46,7 +46,7 @@ export function LoginPage() {
if (isLoading || isAuthenticated) { if (isLoading || isAuthenticated) {
return ( return (
<div <div
className="flex min-h-screen items-center justify-center bg-canvas text-ink-4" className="flex min-h-screen items-center justify-center bg-canvas text-ink-secondary"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-label="Chargement de la session" aria-label="Chargement de la session"
@ -72,14 +72,14 @@ export function LoginPage() {
return ( return (
<main className="flex min-h-screen items-center justify-center bg-canvas px-4 py-8"> <main className="flex min-h-screen items-center justify-center bg-canvas px-4 py-8">
<section className="w-full max-w-sm rounded-lg border border-line bg-surface p-6 shadow-sm"> <section className="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow-card">
<h1 className="text-2xl font-semibold text-ink-1">Se connecter</h1> <h1 className="text-2xl font-semibold text-ink-primary">Se connecter</h1>
<p className="mt-1 text-sm text-ink-3">Accédez à votre espace Expria.</p> <p className="mt-1 text-sm text-ink-secondary">Accédez à votre espace Expria.</p>
{error && ( {error && (
<div <div
role="alert" role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger" className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
> >
{error} {error}
</div> </div>
@ -121,9 +121,9 @@ export function LoginPage() {
</Button> </Button>
</form> </form>
<p className="mt-6 text-center text-sm text-ink-3"> <p className="mt-6 text-center text-sm text-ink-secondary">
Pas encore de compte ?{' '} Pas encore de compte ?{' '}
<Link to="/register" className="text-expria underline-offset-4 hover:underline"> <Link to="/register" className="text-brand-text underline-offset-4 hover:underline">
Créer un compte Créer un compte
</Link> </Link>
</p> </p>

View file

@ -84,20 +84,20 @@ export function RegisterPage() {
return ( return (
<main className="flex min-h-screen items-center justify-center bg-canvas px-4 py-8"> <main className="flex min-h-screen items-center justify-center bg-canvas px-4 py-8">
<section className="w-full max-w-sm rounded-lg border border-line bg-surface p-6 shadow-sm"> <section className="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow-card">
<h1 className="text-2xl font-semibold text-ink-1">Créer un compte</h1> <h1 className="text-2xl font-semibold text-ink-primary">Créer un compte</h1>
<p className="mt-1 text-sm text-ink-3">Commencez votre préparation TCF Canada.</p> <p className="mt-1 text-sm text-ink-secondary">Commencez votre préparation TCF Canada.</p>
{successMessage ? ( {successMessage ? (
<> <>
<div <div
role="status" role="status"
className="mt-6 rounded-md border border-success/40 bg-success-bg px-3 py-3 text-sm text-success" className="mt-6 rounded-md border border-success/40 bg-success-soft px-3 py-3 text-sm text-success"
> >
{successMessage} {successMessage}
</div> </div>
<p className="mt-6 text-center text-sm text-ink-3"> <p className="mt-6 text-center text-sm text-ink-secondary">
<Link to="/login" className="text-expria underline-offset-4 hover:underline"> <Link to="/login" className="text-brand-text underline-offset-4 hover:underline">
Retour à la connexion Retour à la connexion
</Link> </Link>
</p> </p>
@ -107,7 +107,7 @@ export function RegisterPage() {
{formError && ( {formError && (
<div <div
role="alert" role="alert"
className="mt-4 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger" className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
> >
{formError} {formError}
</div> </div>
@ -185,9 +185,9 @@ export function RegisterPage() {
</Button> </Button>
</form> </form>
<p className="mt-6 text-center text-sm text-ink-3"> <p className="mt-6 text-center text-sm text-ink-secondary">
Déjà un compte ?{' '} Déjà un compte ?{' '}
<Link to="/login" className="text-expria underline-offset-4 hover:underline"> <Link to="/login" className="text-brand-text underline-offset-4 hover:underline">
Se connecter Se connecter
</Link> </Link>
</p> </p>

View file

@ -44,10 +44,10 @@ export function MonProfilPreparation({ plan }: Props) {
if (isLoading || isError || !data) { if (isLoading || isError || !data) {
return ( return (
<Card variant="default" className="p-4"> <Card variant="default" className="p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Mon profil de préparation Mon profil de préparation
</p> </p>
<p className="mt-2 text-sm text-ink-4"> <p className="mt-2 text-sm text-ink-secondary">
{isError ? 'Profil temporairement indisponible.' : 'Chargement…'} {isError ? 'Profil temporairement indisponible.' : 'Chargement…'}
</p> </p>
</Card> </Card>
@ -58,14 +58,14 @@ export function MonProfilPreparation({ plan }: Props) {
const remaining = Math.max(0, data.minimum - data.current) const remaining = Math.max(0, data.minimum - data.current)
return ( return (
<Card variant="default" className="space-y-2 p-4"> <Card variant="default" className="space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Mon profil de préparation Mon profil de préparation
</p> </p>
<p className="text-sm text-ink-2"> <p className="text-sm text-ink-primary">
Encore <span className="font-semibold tabular-nums">{remaining}</span>{' '} Encore <span className="font-semibold tabular-nums">{remaining}</span>{' '}
{remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil. {remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil.
</p> </p>
<p className="text-xs text-ink-4 tabular-nums"> <p className="text-xs text-ink-secondary tabular-nums">
{data.current}/{data.minimum} simulations corrigées {data.current}/{data.minimum} simulations corrigées
</p> </p>
</Card> </Card>
@ -80,27 +80,27 @@ export function MonProfilPreparation({ plan }: Props) {
<Card variant="raised" className="space-y-3 p-4"> <Card variant="raised" className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Indice de préparation Indice de préparation
</p> </p>
<p className="tabular-nums text-ink-1"> <p className="tabular-nums text-ink-primary">
<span className="text-3xl font-bold">{data.preparation_index.score}</span> <span className="text-3xl font-bold">{data.preparation_index.score}</span>
<span className="text-lg font-medium text-ink-4">/100</span> <span className="text-lg font-medium text-ink-secondary">/100</span>
</p> </p>
</div> </div>
<p className="max-w-[180px] text-right text-xs text-ink-3"> <p className="max-w-[180px] text-right text-xs text-ink-secondary">
{data.preparation_index.message} {data.preparation_index.message}
</p> </p>
</div> </div>
<div className="h-1.5 overflow-hidden rounded-full bg-canvas-2"> <div className="h-1.5 overflow-hidden rounded-full bg-surface">
<div <div
className={`h-full transition-all duration-300 ${color}`} className={`h-full transition-all duration-300 ${color}`}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<p className="text-sm text-ink-2"> <p className="text-sm text-ink-primary">
{patternsCount === 0 {patternsCount === 0
? 'Aucune erreur récurrente identifiée — continuez !' ? 'Aucune erreur récurrente identifiée — continuez !'
: `${patternsCount} ${patternsCount > 1 ? 'erreurs récurrentes identifiées' : 'erreur récurrente identifiée'}.`} : `${patternsCount} ${patternsCount > 1 ? 'erreurs récurrentes identifiées' : 'erreur récurrente identifiée'}.`}

View file

@ -9,9 +9,9 @@ import { Button } from '@/shared/components/ui/button'
export function PaywallBanner() { export function PaywallBanner() {
return ( return (
<div className="rounded-lg border border-expria-100 bg-expria-50 p-4 dark:border-expria/20"> <div className="rounded-lg border border-brand-100 bg-brand-soft p-4 dark:border-brand/20">
<p className="text-sm font-semibold text-ink-1">Passez à Standard pour débloquer :</p> <p className="text-sm font-semibold text-ink-primary">Passez à Standard pour débloquer :</p>
<ul className="mt-2 space-y-1 text-sm text-ink-3" role="list"> <ul className="mt-2 space-y-1 text-sm text-ink-secondary" role="list">
<li>Simulations illimitées</li> <li>Simulations illimitées</li>
<li>Rapport détaillé par critère</li> <li>Rapport détaillé par critère</li>
<li>Historique de vos productions</li> <li>Historique de vos productions</li>

View file

@ -36,13 +36,13 @@ function getDisplayName(
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
<div className="space-y-6" aria-busy="true" aria-label="Chargement du tableau de bord"> <div className="space-y-6" aria-busy="true" aria-label="Chargement du tableau de bord">
<div className="h-8 w-48 animate-pulse rounded-md bg-canvas-2" /> <div className="h-8 w-48 animate-pulse rounded-md bg-surface" />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="h-24 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-24 animate-pulse rounded-lg bg-surface" />
</div> </div>
<div className="h-9 animate-pulse rounded-md bg-canvas-2" /> <div className="h-9 animate-pulse rounded-md bg-surface" />
<div className="h-16 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-16 animate-pulse rounded-lg bg-surface" />
</div> </div>
) )
} }
@ -78,7 +78,7 @@ export function DashboardPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Salutation */} {/* Salutation */}
<section className="flex flex-wrap items-center gap-3"> <section className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-ink-1">Bonjour, {displayName}</h1> <h1 className="text-2xl font-semibold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue={data.plan}> <Badge variant="plan" planValue={data.plan}>
{PLAN_LABELS[data.plan]} {PLAN_LABELS[data.plan]}
</Badge> </Badge>
@ -89,15 +89,15 @@ export function DashboardPage() {
{/* Métriques */} {/* Métriques */}
<section className="grid grid-cols-2 gap-4" aria-label="Métriques de préparation"> <section className="grid grid-cols-2 gap-4" aria-label="Métriques de préparation">
<div className="rounded-lg border border-line bg-surface p-4"> <div className="rounded-lg border border-border bg-surface p-4">
<p className="text-xs text-ink-4">Simulations restantes</p> <p className="text-xs text-ink-secondary">Simulations restantes</p>
<p className="mt-1 text-2xl font-semibold text-ink-1"> <p className="mt-1 text-2xl font-semibold text-ink-primary">
{data.simulations_remaining === null ? 'Illimitées' : data.simulations_remaining} {data.simulations_remaining === null ? 'Illimitées' : data.simulations_remaining}
</p> </p>
</div> </div>
<div className="rounded-lg border border-line bg-surface p-4"> <div className="rounded-lg border border-border bg-surface p-4">
<p className="text-xs text-ink-4">Niveau NCLC estimé</p> <p className="text-xs text-ink-secondary">Niveau NCLC estimé</p>
<p className="mt-1 text-2xl font-semibold text-ink-1"></p> <p className="mt-1 text-2xl font-semibold text-ink-primary"></p>
</div> </div>
</section> </section>
@ -113,8 +113,8 @@ export function DashboardPage() {
{/* Dernières simulations */} {/* Dernières simulations */}
<section aria-label="Dernières simulations"> <section aria-label="Dernières simulations">
<h2 className="text-base font-semibold text-ink-1">Dernières simulations</h2> <h2 className="text-base font-semibold text-ink-primary">Dernières simulations</h2>
<p className="mt-2 text-sm text-ink-4">Aucune simulation pour l'instant.</p> <p className="mt-2 text-sm text-ink-secondary">Aucune simulation pour l'instant.</p>
</section> </section>
{/* Mon profil de préparation — Premium uniquement (gate via hasAccess) */} {/* Mon profil de préparation — Premium uniquement (gate via hasAccess) */}

View file

@ -23,42 +23,173 @@ import {
DialogTrigger, DialogTrigger,
} from '@/shared/components/ui/dialog' } from '@/shared/components/ui/dialog'
// ─── palette data ──────────────────────────────────────────────────────────── // ─── palette data — DA Charcoal ──────────────────────────────────────────────
const PALETTE: { token: string; var: string; light: string; dark: string }[] = [ interface PaletteEntry {
{ token: 'canvas', var: '--color-canvas', light: '#EEF2F8', dark: '#0D1220' }, token: string
{ token: 'canvas-2', var: '--color-canvas-2', light: '#E6EBF4', dark: '#121A2D' }, cssVar: string
{ token: 'surface', var: '--color-surface', light: '#FFFFFF', dark: '#182238' }, dark: string
{ token: 'surface-hover', var: '--color-surface-hover', light: '#F8FAFD', dark: '#1E2A42' }, light: string
{ token: 'line', var: '--color-line', light: '#DDE3ED', dark: '#27324B' }, group: 'Invariants' | 'Dark default' | 'Light override'
{ token: 'line-strong', var: '--color-line-strong', light: '#C7D0E0', dark: '#364363' }, }
{ token: 'ink-1', var: '--color-ink-1', light: '#0F172A', dark: '#F1F4FA' },
{ token: 'ink-2', var: '--color-ink-2', light: '#1E293B', dark: '#DDE3EF' }, const PALETTE: PaletteEntry[] = [
{ token: 'ink-3', var: '--color-ink-3', light: '#475569', dark: '#A8B2C7' }, // Invariants
{ token: 'ink-4', var: '--color-ink-4', light: '#64748B', dark: '#7A8499' },
{ token: 'ink-5', var: '--color-ink-5', light: '#94A3B8', dark: '#525C73' },
{ token: 'expria', var: '--color-expria', light: '#1B4FD8', dark: '#5B7FFF' },
{ token: 'expria-hover', var: '--color-expria-hover', light: '#1741B8', dark: '#6F8EFF' },
{ token: 'expria-50', var: '--color-expria-50', light: '#EEF3FF', dark: 'rgba(91,127,255,.12)' },
{ token: 'expria-100', var: '--color-expria-100', light: '#DCE6FF', dark: '—' },
{ token: 'expria-200', var: '--color-expria-200', light: '#B8CDFF', dark: '—' },
{ token: 'deep', var: '--color-deep', light: '#0B1F5C', dark: '#060B1A' },
{ token: 'success', var: '--color-success', light: '#0E9F6E', dark: '#3DD68C' },
{ {
token: 'success-bg', token: 'sidebar-bg',
var: '--color-success-bg', cssVar: '--color-sidebar-bg',
light: '#E6F6F0', dark: '#0C1528',
dark: 'rgba(61,214,140,.12)', light: '#0C1528',
group: 'Invariants',
}, },
{ token: 'warning', var: '--color-warning', light: '#C77A00', dark: '#F5B849' },
{ {
token: 'warning-bg', token: 'brand',
var: '--color-warning-bg', cssVar: '--color-brand',
light: '#FEF3E2', dark: '#1B4FD8',
dark: 'rgba(245,184,73,.12)', 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',
}, },
{ token: 'danger', var: '--color-danger', light: '#C53030', dark: '#F06B6B' },
{ token: 'danger-bg', var: '--color-danger-bg', light: '#FDECEC', dark: 'rgba(240,107,107,.12)' },
] ]
// ─── section wrapper ───────────────────────────────────────────────────────── // ─── section wrapper ─────────────────────────────────────────────────────────
@ -66,7 +197,9 @@ const PALETTE: { token: string; var: string; light: string; dark: string }[] = [
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-base font-semibold text-ink-3 uppercase tracking-wider">{title}</h2> <h2 className="text-base font-semibold uppercase tracking-wider text-ink-secondary">
{title}
</h2>
<Separator /> <Separator />
{children} {children}
</section> </section>
@ -80,12 +213,14 @@ export default function DesignSystemPage() {
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
return ( return (
<div className="min-h-screen bg-canvas px-6 py-10 space-y-14"> <div className="min-h-screen space-y-14 bg-canvas px-6 py-10">
{/* ── header ── */} {/* ── header ── */}
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-ink-1">Design System</h1> <h1 className="text-2xl font-bold text-ink-primary">Design System</h1>
<p className="text-sm text-ink-4 mt-0.5">Expria Direction H palette · Sprint 0.5</p> <p className="mt-0.5 text-sm text-ink-secondary">
Expria DA Charcoal · dark-default + light override
</p>
</div> </div>
<Button <Button
variant="outline" variant="outline"
@ -98,17 +233,17 @@ export default function DesignSystemPage() {
{/* ── palette ── */} {/* ── palette ── */}
<Section title="Palette"> <Section title="Palette">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{PALETTE.map(({ token, var: cssVar, light, dark }) => ( {PALETTE.map(({ token, cssVar, light, dark }) => (
<div key={token} className="flex flex-col gap-1.5"> <div key={token} className="flex flex-col gap-1.5">
<div <div
className="h-12 w-full rounded-md border border-line shadow-sm" className="h-12 w-full rounded-md border border-border shadow-card"
style={{ background: `var(${cssVar})` }} style={{ background: `var(${cssVar})` }}
/> />
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-xs font-mono font-medium text-ink-2">{token}</p> <p className="font-mono text-xs font-medium text-ink-primary">{token}</p>
<p className="text-xs font-mono text-ink-4 leading-tight"> {light}</p> <p className="font-mono text-xs leading-tight text-ink-secondary"> {dark}</p>
<p className="text-xs font-mono text-ink-4 leading-tight"> {dark}</p> <p className="font-mono text-xs leading-tight text-ink-secondary"> {light}</p>
</div> </div>
</div> </div>
))} ))}
@ -117,22 +252,22 @@ export default function DesignSystemPage() {
{/* ── typography ── */} {/* ── typography ── */}
<Section title="Typography"> <Section title="Typography">
<div className="space-y-3 bg-surface rounded-lg p-6 border border-line"> <div className="space-y-3 rounded-lg border border-border bg-surface p-6">
<p className="text-4xl font-bold text-ink-1">Display / 36px Bold</p> <p className="text-4xl font-bold text-ink-primary">Display / 40px Bold</p>
<p className="text-2xl font-semibold text-ink-1">Heading 1 / 24px Semibold</p> <p className="text-2xl font-semibold text-ink-primary">Heading 1 / 24px Semibold</p>
<p className="text-xl font-semibold text-ink-1">Heading 2 / 20px Semibold</p> <p className="text-xl font-semibold text-ink-primary">Heading 2 / 20px Semibold</p>
<p className="text-lg font-medium text-ink-2">Heading 3 / 18px Medium</p> <p className="text-lg font-medium text-ink-primary">Heading 3 / 17px Medium</p>
<p className="text-base text-ink-2">Body / 16px Regular Plus Jakarta Sans</p> <p className="text-base text-ink-primary">Body / 14px Regular Plus Jakarta Sans</p>
<p className="text-sm text-ink-3">Small / 14px Regular secondary copy</p> <p className="text-sm text-ink-secondary">Small / 13px Regular secondary copy</p>
<p className="text-xs text-ink-4">Caption / 12px Regular labels, metadata</p> <p className="text-xs text-ink-tertiary">Caption / 11px Regular labels, metadata</p>
<p className="text-xs font-mono text-ink-3">Mono / 12px token names, code</p> <p className="font-mono text-xs text-ink-secondary">Mono / 11px token names, code</p>
</div> </div>
</Section> </Section>
{/* ── buttons ── */} {/* ── buttons ── */}
<Section title="Button"> <Section title="Button">
<div className="space-y-4 bg-surface rounded-lg p-6 border border-line"> <div className="space-y-4 rounded-lg border border-border bg-surface p-6">
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap items-center gap-2">
<Button>Default</Button> <Button>Default</Button>
<Button variant="secondary">Secondary</Button> <Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button> <Button variant="outline">Outline</Button>
@ -140,13 +275,13 @@ export default function DesignSystemPage() {
<Button variant="destructive">Destructive</Button> <Button variant="destructive">Destructive</Button>
<Button variant="link">Link</Button> <Button variant="link">Link</Button>
</div> </div>
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap items-center gap-2">
<Button size="lg">Large</Button> <Button size="lg">Large</Button>
<Button>Default</Button> <Button>Default</Button>
<Button size="sm">Small</Button> <Button size="sm">Small</Button>
<Button size="icon">+</Button> <Button size="icon">+</Button>
</div> </div>
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap items-center gap-2">
<Button disabled>Disabled</Button> <Button disabled>Disabled</Button>
<Button variant="outline" disabled> <Button variant="outline" disabled>
Outline disabled Outline disabled
@ -157,7 +292,7 @@ export default function DesignSystemPage() {
{/* ── badges ── */} {/* ── badges ── */}
<Section title="Badge"> <Section title="Badge">
<div className="flex flex-wrap gap-2 bg-surface rounded-lg p-6 border border-line"> <div className="flex flex-wrap gap-2 rounded-lg border border-border bg-surface p-6">
<Badge>Default</Badge> <Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge> <Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge> <Badge variant="outline">Outline</Badge>
@ -167,7 +302,7 @@ export default function DesignSystemPage() {
{/* ── inputs / forms ── */} {/* ── inputs / forms ── */}
<Section title="Input · Label · Progress · Separator"> <Section title="Input · Label · Progress · Separator">
<div className="space-y-5 bg-surface rounded-lg p-6 border border-line max-w-md"> <div className="max-w-md space-y-5 rounded-lg border border-border bg-surface p-6">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="ds-email">Email</Label> <Label htmlFor="ds-email">Email</Label>
<Input id="ds-email" type="email" placeholder="you@expria.io" /> <Input id="ds-email" type="email" placeholder="you@expria.io" />
@ -185,33 +320,33 @@ export default function DesignSystemPage() {
<Progress value={65} /> <Progress value={65} />
</div> </div>
<Separator /> <Separator />
<p className="text-sm text-ink-4">Content below separator</p> <p className="text-sm text-ink-secondary">Content below separator</p>
</div> </div>
</Section> </Section>
{/* ── avatar ── */} {/* ── avatar ── */}
<Section title="Avatar"> <Section title="Avatar">
<div className="flex flex-wrap items-end gap-6 bg-surface rounded-lg p-6 border border-line"> <div className="flex flex-wrap items-end gap-6 rounded-lg border border-border bg-surface p-6">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Avatar size="sm"> <Avatar size="sm">
<AvatarImage src="" /> <AvatarImage src="" />
<AvatarFallback>HK</AvatarFallback> <AvatarFallback>HK</AvatarFallback>
</Avatar> </Avatar>
<span className="text-xs text-ink-4">sm</span> <span className="text-xs text-ink-secondary">sm</span>
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Avatar> <Avatar>
<AvatarImage src="" /> <AvatarImage src="" />
<AvatarFallback>HK</AvatarFallback> <AvatarFallback>HK</AvatarFallback>
</Avatar> </Avatar>
<span className="text-xs text-ink-4">default</span> <span className="text-xs text-ink-secondary">default</span>
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Avatar size="lg"> <Avatar size="lg">
<AvatarImage src="" /> <AvatarImage src="" />
<AvatarFallback>HK</AvatarFallback> <AvatarFallback>HK</AvatarFallback>
</Avatar> </Avatar>
<span className="text-xs text-ink-4">lg</span> <span className="text-xs text-ink-secondary">lg</span>
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<AvatarGroup> <AvatarGroup>
@ -222,14 +357,14 @@ export default function DesignSystemPage() {
))} ))}
<AvatarGroupCount>+5</AvatarGroupCount> <AvatarGroupCount>+5</AvatarGroupCount>
</AvatarGroup> </AvatarGroup>
<span className="text-xs text-ink-4">group</span> <span className="text-xs text-ink-secondary">group</span>
</div> </div>
</div> </div>
</Section> </Section>
{/* ── dialog ── */} {/* ── dialog ── */}
<Section title="Dialog"> <Section title="Dialog">
<div className="bg-surface rounded-lg p-6 border border-line"> <div className="rounded-lg border border-border bg-surface p-6">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">Open dialog</Button> <Button variant="outline">Open dialog</Button>
@ -238,8 +373,8 @@ export default function DesignSystemPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Example dialog</DialogTitle> <DialogTitle>Example dialog</DialogTitle>
<DialogDescription> <DialogDescription>
This dialog uses Direction H tokens bg-surface, border-line, text-ink-4. Toggle This dialog uses DA Charcoal tokens bg-surface-solid, border-border,
the theme to see it adapt. text-ink-secondary. Toggle the theme to see it adapt.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter showCloseButton> <DialogFooter showCloseButton>

View file

@ -25,31 +25,33 @@ export function SimulationListItem({ item }: Props) {
return ( return (
<Link <Link
to={`/rapport/${item.id}`} to={`/rapport/${item.id}`}
className="block rounded-lg border border-line bg-surface p-4 shadow-sm transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus" className="block rounded-lg border border-border bg-surface p-4 shadow-card transition-colors duration-150 hover:border-brand hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-ink-1">{formatTache(item.tache)}</span> <span className="text-sm font-semibold text-ink-primary">
{formatTache(item.tache)}
</span>
{isExam && <Badge variant="nclc">Examen</Badge>} {isExam && <Badge variant="nclc">Examen</Badge>}
{!hasScore && <Badge variant="neutral">En cours</Badge>} {!hasScore && <Badge variant="neutral">En cours</Badge>}
</div> </div>
<p className="text-xs text-ink-4">{formatRelativeDate(item.created_at)}</p> <p className="text-xs text-ink-secondary">{formatRelativeDate(item.created_at)}</p>
</div> </div>
{hasScore ? ( {hasScore ? (
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
<p className="tabular-nums text-ink-1"> <p className="tabular-nums text-ink-primary">
<span className="text-xl font-bold">{item.score}</span> <span className="text-xl font-bold">{item.score}</span>
<span className="text-sm font-medium text-ink-4">/20</span> <span className="text-sm font-medium text-ink-secondary">/20</span>
</p> </p>
<p className="text-xs text-ink-4 tabular-nums"> <p className="text-xs text-ink-secondary tabular-nums">
NCLC {item.nclc} NCLC {item.nclc}
{item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''} {item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''}
</p> </p>
</div> </div>
) : ( ) : (
<div className="shrink-0 text-right text-xs text-ink-4">Score à venir</div> <div className="shrink-0 text-right text-xs text-ink-secondary">Score à venir</div>
)} )}
</div> </div>
</Link> </Link>

View file

@ -35,15 +35,15 @@ const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) { function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
return ( return (
<div className="relative min-h-[240px] overflow-hidden rounded-lg border border-line bg-canvas-2"> <div className="relative min-h-[240px] overflow-hidden rounded-lg border border-border bg-surface">
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true"> <div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_WIDTHS.map((w, i) => ( {PLACEHOLDER_WIDTHS.map((w, i) => (
<div key={i} className={`h-16 rounded bg-ink-4 ${w}`} /> <div key={i} className={`h-16 rounded bg-surface-hover ${w}`} />
))} ))}
</div> </div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
<Lock className="size-5 text-ink-4" aria-hidden="true" /> <Lock className="size-5 text-ink-secondary" aria-hidden="true" />
<p className="text-sm font-medium text-ink-2">Historique disponible en Standard</p> <p className="text-sm font-medium text-ink-primary">Historique disponible en Standard</p>
<Button variant="upgrade" size="sm" onClick={onUpgrade}> <Button variant="upgrade" size="sm" onClick={onUpgrade}>
Passer en Standard Passer en Standard
</Button> </Button>
@ -56,7 +56,7 @@ function ListSkeleton() {
return ( return (
<div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique…"> <div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique…">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-lg bg-canvas-2" /> <div key={i} className="h-20 animate-pulse rounded-lg bg-surface" />
))} ))}
</div> </div>
) )
@ -65,8 +65,8 @@ function ListSkeleton() {
function EmptyState() { function EmptyState() {
return ( return (
<Card variant="default" className="space-y-3 p-6 text-center"> <Card variant="default" className="space-y-3 p-6 text-center">
<p className="text-sm text-ink-2">Aucune simulation pour le moment.</p> <p className="text-sm text-ink-primary">Aucune simulation pour le moment.</p>
<p className="text-xs text-ink-4"> <p className="text-xs text-ink-secondary">
Lancez votre première simulation pour commencer à construire votre historique. Lancez votre première simulation pour commencer à construire votre historique.
</p> </p>
<div className="flex justify-center"> <div className="flex justify-center">
@ -134,7 +134,7 @@ export function SimulationsList({
<Button variant="secondary" size="sm" onClick={onPrev} disabled={isFirst}> <Button variant="secondary" size="sm" onClick={onPrev} disabled={isFirst}>
Précédent Précédent
</Button> </Button>
<p className="text-xs text-ink-4 tabular-nums" aria-live="polite"> <p className="text-xs text-ink-secondary tabular-nums" aria-live="polite">
Page {page} sur {totalPages} {data.pagination.total} simulations Page {page} sur {totalPages} {data.pagination.total} simulations
</p> </p>
<Button variant="secondary" size="sm" onClick={onNext} disabled={isLast}> <Button variant="secondary" size="sm" onClick={onNext} disabled={isLast}>

View file

@ -29,16 +29,16 @@ export function HistoriquePage() {
return ( return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6"> <main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-lg font-semibold text-ink-1">Historique</h1> <h1 className="text-lg font-semibold text-ink-primary">Historique</h1>
<p className="text-sm text-ink-3"> <p className="text-sm text-ink-secondary">
Retrouvez toutes vos simulations passées et leur progression. Retrouvez toutes vos simulations passées et leur progression.
</p> </p>
</header> </header>
{isPlanLoading || !planData ? ( {isPlanLoading || !planData ? (
<div className="space-y-3" aria-busy="true"> <div className="space-y-3" aria-busy="true">
<div className="h-20 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-20 animate-pulse rounded-lg bg-surface" />
<div className="h-20 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-20 animate-pulse rounded-lg bg-surface" />
</div> </div>
) : ( ) : (
<SimulationsList <SimulationsList

View file

@ -19,19 +19,19 @@ const PLACEHOLDER_HEIGHTS = ['h-24', 'h-16', 'h-16', 'h-20'] as const
export function BlurredProgression({ onUpgrade }: Props) { export function BlurredProgression({ onUpgrade }: Props) {
return ( return (
<div className="relative min-h-[320px] overflow-hidden rounded-lg border border-line bg-canvas-2"> <div className="relative min-h-[320px] overflow-hidden rounded-lg border border-border bg-surface">
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true"> <div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_HEIGHTS.map((h, i) => ( {PLACEHOLDER_HEIGHTS.map((h, i) => (
<div key={i} className={`${h} rounded bg-ink-4`} /> <div key={i} className={`${h} rounded bg-surface-hover`} />
))} ))}
</div> </div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4 text-center"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4 text-center">
<Lock className="size-6 text-ink-4" aria-hidden="true" /> <Lock className="size-6 text-ink-secondary" aria-hidden="true" />
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-semibold text-ink-2"> <p className="text-sm font-semibold text-ink-primary">
Profil de préparation Exclusivité Premium Profil de préparation Exclusivité Premium
</p> </p>
<p className="max-w-sm text-xs text-ink-4"> <p className="max-w-sm text-xs text-ink-secondary">
Analysez vos erreurs récurrentes, recevez des exercices ciblés long terme, et suivez Analysez vos erreurs récurrentes, recevez des exercices ciblés long terme, et suivez
votre indice de préparation au TCF Canada. votre indice de préparation au TCF Canada.
</p> </p>

View file

@ -23,10 +23,10 @@ export function NotReadyState({ current, minimum }: Props) {
return ( return (
<Card variant="raised" className="space-y-4 p-6 text-center"> <Card variant="raised" className="space-y-4 p-6 text-center">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-lg font-semibold text-ink-1">Profil de préparation</h2> <h2 className="text-lg font-semibold text-ink-primary">Profil de préparation</h2>
<p className="text-sm leading-relaxed text-ink-3"> <p className="text-sm leading-relaxed text-ink-secondary">
Vous avez réalisé{' '} Vous avez réalisé{' '}
<span className="font-semibold text-ink-1 tabular-nums"> <span className="font-semibold text-ink-primary tabular-nums">
{current}/{minimum} {current}/{minimum}
</span>{' '} </span>{' '}
simulations corrigées.{' '} simulations corrigées.{' '}
@ -37,17 +37,14 @@ export function NotReadyState({ current, minimum }: Props) {
</div> </div>
<div <div
className="relative h-2 overflow-hidden rounded-full bg-canvas-2" className="relative h-2 overflow-hidden rounded-full bg-surface"
role="progressbar" role="progressbar"
aria-valuenow={current} aria-valuenow={current}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={minimum} aria-valuemax={minimum}
aria-label={`Progression : ${current} sur ${minimum}`} aria-label={`Progression : ${current} sur ${minimum}`}
> >
<div <div className="h-full bg-brand transition-all duration-300" style={{ width: `${pct}%` }} />
className="h-full bg-expria transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">

View file

@ -35,10 +35,12 @@ export function PatternExerciceCard({ exercice }: Props) {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="neutral">{critereLabel}</Badge> <Badge variant="neutral">{critereLabel}</Badge>
<span className="text-xs font-medium text-ink-4">{exercice.code.replace(/_/g, ' ')}</span> <span className="text-xs font-medium text-ink-secondary">
{exercice.code.replace(/_/g, ' ')}
</span>
</div> </div>
{exercice.diagnostic && ( {exercice.diagnostic && (
<p className="text-sm leading-relaxed text-ink-2"> <p className="text-sm leading-relaxed text-ink-primary">
<ReactMarkdown <ReactMarkdown
disallowedElements={['script', 'iframe']} disallowedElements={['script', 'iframe']}
components={{ p: ({ children }) => <span>{children}</span> }} components={{ p: ({ children }) => <span>{children}</span> }}
@ -50,36 +52,38 @@ export function PatternExerciceCard({ exercice }: Props) {
</div> </div>
{exercice.exercice.consigne && ( {exercice.exercice.consigne && (
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3"> <div className="space-y-1.5 rounded-md border border-border bg-surface p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Consigne</p> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
<p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.consigne}</p> Consigne
</p>
<p className="text-sm leading-relaxed text-ink-primary">{exercice.exercice.consigne}</p>
</div> </div>
)} )}
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1.5 rounded-md border border-danger/30 bg-danger-bg p-3"> <div className="space-y-1.5 rounded-md border border-danger/30 bg-danger-soft p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-danger"> <p className="text-[11px] font-semibold uppercase tracking-widest text-danger">
Incorrect Incorrect
</p> </p>
<p className="text-sm leading-relaxed text-ink-1 line-through decoration-danger decoration-1"> <p className="text-sm leading-relaxed text-ink-primary line-through decoration-danger decoration-1">
{exercice.exercice.exemple} {exercice.exercice.exemple}
</p> </p>
</div> </div>
<div className="space-y-1.5 rounded-md border border-success/30 bg-success-bg p-3"> <div className="space-y-1.5 rounded-md border border-success/30 bg-success-soft p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-success"> <p className="text-[11px] font-semibold uppercase tracking-widest text-success">
Correct Correct
</p> </p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.correction}</p> <p className="text-sm leading-relaxed text-ink-primary">{exercice.exercice.correction}</p>
</div> </div>
</div> </div>
<div className="flex gap-3 rounded-md border border-warning/30 bg-warning-bg p-3"> <div className="flex gap-3 rounded-md border border-warning/30 bg-warning-soft p-3">
<Lightbulb className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden="true" /> <Lightbulb className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden="true" />
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning"> <p className="text-[11px] font-semibold uppercase tracking-widest text-warning">
Astuce de relecture Astuce de relecture
</p> </p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.exercice.astuce}</p> <p className="text-sm leading-relaxed text-ink-primary">{exercice.exercice.astuce}</p>
</div> </div>
</div> </div>
</Card> </Card>

View file

@ -24,7 +24,7 @@ export function PatternsList({ patterns }: Props) {
if (patterns.length === 0) { if (patterns.length === 0) {
return ( return (
<Card variant="default" className="p-4"> <Card variant="default" className="p-4">
<p className="text-sm text-ink-3"> <p className="text-sm text-ink-secondary">
Aucune erreur récurrente détectée sur vos 5 dernières productions. Continuez ainsi ! Aucune erreur récurrente détectée sur vos 5 dernières productions. Continuez ainsi !
</p> </p>
</Card> </Card>
@ -37,10 +37,10 @@ export function PatternsList({ patterns }: Props) {
<li key={`${p.critere}-${p.code}-${p.description ?? ''}`}> <li key={`${p.critere}-${p.code}-${p.description ?? ''}`}>
<Card variant="default" className="flex items-start justify-between gap-3 p-4"> <Card variant="default" className="flex items-start justify-between gap-3 p-4">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<p className="text-sm font-semibold text-ink-1"> <p className="text-sm font-semibold text-ink-primary">
{p.description ?? humanizeCode(p.code)} {p.description ?? humanizeCode(p.code)}
</p> </p>
<p className="text-xs text-ink-4">{CRITERE_LABELS[p.critere]}</p> <p className="text-xs text-ink-secondary">{CRITERE_LABELS[p.critere]}</p>
</div> </div>
<Badge variant="nclc" className="shrink-0 tabular-nums"> <Badge variant="nclc" className="shrink-0 tabular-nums">
{p.frequency}/5 {p.frequency}/5

View file

@ -29,19 +29,19 @@ export function PreparationIndexHero({ index }: Props) {
<Card variant="raised" className="space-y-4 p-6"> <Card variant="raised" className="space-y-4 p-6">
<div className="flex flex-wrap items-baseline justify-between gap-4"> <div className="flex flex-wrap items-baseline justify-between gap-4">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Indice de préparation Indice de préparation
</p> </p>
<p className="mt-1 tabular-nums text-ink-1"> <p className="mt-1 tabular-nums text-ink-primary">
<span className="text-5xl font-bold">{index.score}</span> <span className="text-5xl font-bold">{index.score}</span>
<span className="text-2xl font-medium text-ink-4">/100</span> <span className="text-2xl font-medium text-ink-secondary">/100</span>
</p> </p>
</div> </div>
<p className="max-w-xs text-sm leading-relaxed text-ink-2">{index.message}</p> <p className="max-w-xs text-sm leading-relaxed text-ink-primary">{index.message}</p>
</div> </div>
<div <div
className="relative h-2 overflow-hidden rounded-full bg-canvas-2" className="relative h-2 overflow-hidden rounded-full bg-surface"
role="progressbar" role="progressbar"
aria-valuenow={pct} aria-valuenow={pct}
aria-valuemin={0} aria-valuemin={0}
@ -53,7 +53,7 @@ export function PreparationIndexHero({ index }: Props) {
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<div className="flex justify-between text-xs text-ink-4 tabular-nums"> <div className="flex justify-between text-xs text-ink-secondary tabular-nums">
<span>0</span> <span>0</span>
<span>40</span> <span>40</span>
<span>70</span> <span>70</span>

View file

@ -31,13 +31,13 @@ export function ProgressionPremium({ data }: Props) {
<PreparationIndexHero index={data.preparation_index} /> <PreparationIndexHero index={data.preparation_index} />
<section aria-label="Erreurs récurrentes"> <section aria-label="Erreurs récurrentes">
<h2 className="mb-3 text-base font-semibold text-ink-1">Erreurs récurrentes</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">Erreurs récurrentes</h2>
<PatternsList patterns={data.patterns} /> <PatternsList patterns={data.patterns} />
</section> </section>
{data.exercises.length > 0 && ( {data.exercises.length > 0 && (
<section aria-label="Exercices long terme"> <section aria-label="Exercices long terme">
<h2 className="mb-3 text-base font-semibold text-ink-1">Exercices long terme</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">Exercices long terme</h2>
<div className="space-y-3"> <div className="space-y-3">
{data.exercises.map((ex, i) => ( {data.exercises.map((ex, i) => (
<PatternExerciceCard key={`${ex.code}-${i}`} exercice={ex} /> <PatternExerciceCard key={`${ex.code}-${i}`} exercice={ex} />
@ -47,7 +47,7 @@ export function ProgressionPremium({ data }: Props) {
)} )}
<Card variant="default" className="p-3"> <Card variant="default" className="p-3">
<p className="text-center text-xs text-ink-4"> <p className="text-center text-xs text-ink-secondary">
Analyse basée sur vos {data.analyzed_productions} dernières productions {' '} Analyse basée sur vos {data.analyzed_productions} dernières productions {' '}
{formatRelativeDate(data.last_analysis)} {formatRelativeDate(data.last_analysis)}
</p> </p>

View file

@ -21,9 +21,9 @@ import { ProgressionPremium } from '../components/ProgressionPremium'
function Skeleton() { function Skeleton() {
return ( return (
<div className="space-y-4" aria-busy="true" aria-label="Chargement de votre profil…"> <div className="space-y-4" aria-busy="true" aria-label="Chargement de votre profil…">
<div className="h-32 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-32 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-48 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-48 animate-pulse rounded-lg bg-surface" />
</div> </div>
) )
} }
@ -38,8 +38,8 @@ export function ProgressionPage() {
return ( return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6"> <main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-lg font-semibold text-ink-1">Profil de préparation</h1> <h1 className="text-lg font-semibold text-ink-primary">Profil de préparation</h1>
<p className="text-sm text-ink-3"> <p className="text-sm text-ink-secondary">
Repérez vos erreurs récurrentes et travaillez-les avec des exercices ciblés. Repérez vos erreurs récurrentes et travaillez-les avec des exercices ciblés.
</p> </p>
</header> </header>

View file

@ -50,8 +50,8 @@ export function IdeesSuggestions({ idees, isLoading, error, isOpen, onClose }: P
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-ink-1"> <DialogTitle className="flex items-center gap-2 text-ink-primary">
<Lightbulb className="size-5 text-expria" aria-hidden="true" /> <Lightbulb className="size-5 text-brand-text" aria-hidden="true" />
Suggestions d'idées Suggestions d'idées
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
@ -60,8 +60,8 @@ export function IdeesSuggestions({ idees, isLoading, error, isOpen, onClose }: P
</DialogHeader> </DialogHeader>
{isLoading && ( {isLoading && (
<div className="flex items-center gap-2 text-sm text-ink-3" aria-busy="true"> <div className="flex items-center gap-2 text-sm text-ink-secondary" aria-busy="true">
<Loader2 className="size-4 animate-spin text-expria" aria-hidden="true" /> <Loader2 className="size-4 animate-spin text-brand-text" aria-hidden="true" />
Génération des idées Génération des idées
</div> </div>
)} )}
@ -69,18 +69,18 @@ export function IdeesSuggestions({ idees, isLoading, error, isOpen, onClose }: P
{!isLoading && message && ( {!isLoading && message && (
<div <div
role="alert" role="alert"
className="rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger" className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
> >
{message} {message}
</div> </div>
)} )}
{!isLoading && !message && idees && idees.length > 0 && ( {!isLoading && !message && idees && idees.length > 0 && (
<ul className="space-y-2 text-sm text-ink-2"> <ul className="space-y-2 text-sm text-ink-primary">
{idees.map((idee, i) => ( {idees.map((idee, i) => (
<li key={i} className="flex gap-2"> <li key={i} className="flex gap-2">
<span <span
className="mt-[0.4em] size-1.5 shrink-0 rounded-full bg-expria" className="mt-[0.4em] size-1.5 shrink-0 rounded-full bg-brand"
aria-hidden="true" aria-hidden="true"
/> />
<span>{idee}</span> <span>{idee}</span>

View file

@ -30,11 +30,11 @@ export function NclcCibleSelector({ value, onChange, disabled = false }: Props)
aria-label="Niveau NCLC cible pour la correction" aria-label="Niveau NCLC cible pour la correction"
disabled={disabled} disabled={disabled}
> >
<legend className="text-sm font-medium text-ink-2">Objectif de correction</legend> <legend className="text-sm font-medium text-ink-primary">Objectif de correction</legend>
<div <div
role="radiogroup" role="radiogroup"
aria-label="Niveau NCLC cible" aria-label="Niveau NCLC cible"
className="inline-flex overflow-hidden rounded-md border border-line bg-surface" className="inline-flex overflow-hidden rounded-md border border-border bg-surface"
> >
{OPTIONS.map((opt) => { {OPTIONS.map((opt) => {
const active = opt.value === value const active = opt.value === value
@ -51,8 +51,8 @@ export function NclcCibleSelector({ value, onChange, disabled = false }: Props)
'focus-visible:outline-none focus-visible:shadow-focus', 'focus-visible:outline-none focus-visible:shadow-focus',
'disabled:cursor-not-allowed disabled:opacity-50', 'disabled:cursor-not-allowed disabled:opacity-50',
active active
? 'bg-expria text-white' ? 'bg-brand text-white'
: 'bg-surface text-ink-2 hover:bg-canvas-2 hover:text-ink-1', : 'bg-surface text-ink-primary hover:bg-surface-hover hover:text-ink-primary',
)} )}
title={opt.hint} title={opt.hint}
> >
@ -61,7 +61,7 @@ export function NclcCibleSelector({ value, onChange, disabled = false }: Props)
) )
})} })}
</div> </div>
<p className="text-xs text-ink-4">{OPTIONS.find((o) => o.value === value)?.hint}</p> <p className="text-xs text-ink-secondary">{OPTIONS.find((o) => o.value === value)?.hint}</p>
</fieldset> </fieldset>
) )
} }

View file

@ -35,7 +35,7 @@ const MIN_WORDS_IDEES = 30
const LS_SIMULATION_ID_KEY = 'expria_simulation_id' const LS_SIMULATION_ID_KEY = 'expria_simulation_id'
const secondaryActionBtn = const secondaryActionBtn =
'inline-flex items-center gap-1.5 rounded-md border border-line bg-surface px-3 py-1.5 text-sm text-ink-2 transition-colors hover:border-expria hover:text-expria focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50' '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({ const textSchema = z.object({
texte: z texte: z
@ -199,11 +199,11 @@ export function SimulationForm({
type="button" type="button"
onClick={onBack} onClick={onBack}
disabled={isSubmitting} disabled={isSubmitting}
className="text-sm text-ink-4 underline-offset-4 hover:text-ink-2 hover:underline disabled:pointer-events-none" className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline disabled:pointer-events-none"
> >
Retour Retour
</button> </button>
<h2 className="flex-1 text-lg font-semibold text-ink-1">{formatTache(tache)}</h2> <h2 className="flex-1 text-lg font-semibold text-ink-primary">{formatTache(tache)}</h2>
</div> </div>
<SujetDisplay sujet={sujet} /> <SujetDisplay sujet={sujet} />
@ -245,7 +245,7 @@ export function SimulationForm({
{apiError && ( {apiError && (
<div <div
role="alert" role="alert"
className="rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger" className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
> >
{apiError} {apiError}
</div> </div>
@ -254,7 +254,7 @@ export function SimulationForm({
{expiredBelowMin && ( {expiredBelowMin && (
<div <div
role="alert" role="alert"
className="rounded-md border border-warning/40 bg-warning-bg px-3 py-2 text-sm text-warning" className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-warning"
> >
Temps écoulé. Écrivez au moins {config.motsMin} mots pour soumettre. Temps écoulé. Écrivez au moins {config.motsMin} mots pour soumettre.
</div> </div>
@ -262,26 +262,30 @@ export function SimulationForm({
<form onSubmit={handleSubmit} className="space-y-3" noValidate> <form onSubmit={handleSubmit} className="space-y-3" noValidate>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label htmlFor="texte" className="text-sm font-medium text-ink-2"> <label htmlFor="texte" className="text-sm font-medium text-ink-primary">
Votre production Votre production
</label> </label>
<div className="sticky top-14 z-20 bg-canvas pb-1 lg:top-0"> <div className="sticky top-14 z-20 bg-canvas pb-1 lg:top-0">
<div <div
className={`mb-2 flex items-center gap-2 rounded-md border px-3 py-2 ${ className={`mb-2 flex items-center gap-2 rounded-md border px-3 py-2 ${
timer.isExpired || timer.secondesRestantes < 120 timer.isExpired || timer.secondesRestantes < 120
? 'border-danger bg-danger-bg' ? 'border-danger bg-danger-soft'
: 'border-line bg-surface' : 'border-border bg-surface'
}`} }`}
> >
<Clock <Clock
className={`size-4 ${ className={`size-4 ${
timer.isExpired || timer.secondesRestantes < 120 ? 'text-danger' : 'text-ink-3' timer.isExpired || timer.secondesRestantes < 120
? 'text-danger'
: 'text-ink-secondary'
}`} }`}
aria-hidden="true" aria-hidden="true"
/> />
<span <span
className={`text-xs font-medium uppercase tracking-wide ${ className={`text-xs font-medium uppercase tracking-wide ${
timer.isExpired || timer.secondesRestantes < 120 ? 'text-danger' : 'text-ink-3' timer.isExpired || timer.secondesRestantes < 120
? 'text-danger'
: 'text-ink-secondary'
}`} }`}
> >
Temps restant Temps restant
@ -305,13 +309,13 @@ export function SimulationForm({
placeholder="Rédigez votre texte ici…" placeholder="Rédigez votre texte ici…"
aria-invalid={!!fieldError} aria-invalid={!!fieldError}
aria-describedby={fieldError ? 'texte-error' : undefined} aria-describedby={fieldError ? 'texte-error' : undefined}
className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50" className="w-full resize-none overflow-y-hidden rounded-md border border-border bg-surface p-3 text-sm text-ink-primary placeholder:text-ink-tertiary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50"
/> />
<WordCountBar count={wordCount} config={config} /> <WordCountBar count={wordCount} config={config} />
<NclcCibleSelector value={nclcCible} onChange={setNclcCible} disabled={isSubmitting} /> <NclcCibleSelector value={nclcCible} onChange={setNclcCible} disabled={isSubmitting} />
{autosave.savedAt && !fieldError && ( {autosave.savedAt && !fieldError && (
<p className="text-xs text-ink-4" aria-live="polite"> <p className="text-xs text-ink-secondary" aria-live="polite">
Sauvegardé à{' '} Sauvegardé à{' '}
{autosave.savedAt.toLocaleTimeString('fr-FR', { {autosave.savedAt.toLocaleTimeString('fr-FR', {
hour: '2-digit', hour: '2-digit',
@ -338,7 +342,7 @@ export function SimulationForm({
</Button> </Button>
{isSubmitting && ( {isSubmitting && (
<p className="text-center text-xs text-ink-4"> <p className="text-center text-xs text-ink-secondary">
La correction peut prendre jusqu'à 30 secondes. La correction peut prendre jusqu'à 30 secondes.
</p> </p>
)} )}

View file

@ -55,7 +55,7 @@ export function SpecialCharsKeyboard({ onInsert, disabled = false }: Props) {
<div <div
role="toolbar" role="toolbar"
aria-label="Caractères spéciaux" aria-label="Caractères spéciaux"
className="flex flex-wrap gap-1.5 rounded-md border border-line bg-canvas-2 p-2" className="flex flex-wrap gap-1.5 rounded-md border border-border bg-surface p-2"
> >
{SPECIAL_CHARS.map((char) => ( {SPECIAL_CHARS.map((char) => (
<button <button
@ -65,7 +65,7 @@ export function SpecialCharsKeyboard({ onInsert, disabled = false }: Props) {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => onInsert(char)} onClick={() => onInsert(char)}
aria-label={`Insérer le caractère ${char}`} aria-label={`Insérer le caractère ${char}`}
className="size-8 shrink-0 rounded-md border border-line bg-surface text-sm font-medium text-ink-1 transition-colors hover:border-expria hover:bg-expria-50 hover:text-expria focus:border-expria focus:outline-none focus:shadow-focus disabled:cursor-not-allowed disabled:opacity-50" 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} {char}
</button> </button>

View file

@ -24,7 +24,7 @@ export function SujetCard({ sujet, onSelect }: Props) {
<Badge variant="neutral">{sujet.role}</Badge> <Badge variant="neutral">{sujet.role}</Badge>
</div> </div>
)} )}
<p className="line-clamp-3 text-sm leading-relaxed text-ink-1">{sujet.consigne}</p> <p className="line-clamp-3 text-sm leading-relaxed text-ink-primary">{sujet.consigne}</p>
</div> </div>
</Card> </Card>
) )

View file

@ -22,9 +22,11 @@ interface Props {
function DocumentBlock({ titre, texte }: { titre: string | null; texte: string | null }) { function DocumentBlock({ titre, texte }: { titre: string | null; texte: string | null }) {
if (!titre && !texte) return null if (!titre && !texte) return null
return ( return (
<article className="rounded-md border border-line bg-canvas-2 p-3"> <article className="rounded-md border border-border bg-surface p-3">
{titre && <h4 className="mb-2 text-sm font-semibold text-ink-1">{titre}</h4>} {titre && <h4 className="mb-2 text-sm font-semibold text-ink-primary">{titre}</h4>}
{texte && <p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-2">{texte}</p>} {texte && (
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-primary">{texte}</p>
)}
</article> </article>
) )
} }
@ -38,19 +40,21 @@ export function SujetDisplay({ sujet }: Props) {
{sujet.role && ( {sujet.role && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="neutral">Rôle</Badge> <Badge variant="neutral">Rôle</Badge>
<span className="text-sm text-ink-2">{sujet.role}</span> <span className="text-sm text-ink-primary">{sujet.role}</span>
</div> </div>
)} )}
{sujet.contexte && ( {sujet.contexte && (
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-3">{sujet.contexte}</p> <p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
{sujet.contexte}
</p>
)} )}
<div> <div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-4"> <h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Consigne Consigne
</h3> </h3>
<p className="whitespace-pre-wrap text-base leading-relaxed text-ink-1"> <p className="whitespace-pre-wrap text-base leading-relaxed text-ink-primary">
{sujet.consigne} {sujet.consigne}
</p> </p>
</div> </div>

View file

@ -61,14 +61,16 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold text-ink-1">Choisir une tâche</h2> <h2 className="text-lg font-semibold text-ink-primary">Choisir une tâche</h2>
<p className="mt-1 text-sm text-ink-3">Sélectionnez la tâche que vous souhaitez simuler.</p> <p className="mt-1 text-sm text-ink-secondary">
Sélectionnez la tâche que vous souhaitez simuler.
</p>
</div> </div>
{quotaBlocked && ( {quotaBlocked && (
<div <div
role="alert" role="alert"
className="rounded-lg border border-danger/30 bg-danger-bg px-4 py-3 text-sm text-danger" className="rounded-lg border border-danger/30 bg-danger-soft px-4 py-3 text-sm text-danger"
> >
Vous avez utilisé vos 5 simulations gratuites.{' '} Vous avez utilisé vos 5 simulations gratuites.{' '}
<a href="/pricing" className="underline underline-offset-4"> <a href="/pricing" className="underline underline-offset-4">
@ -87,14 +89,14 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
return ( return (
<Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60"> <Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60">
{card.tache === null && ( {card.tache === null && (
<Lock className="mb-2 size-4 text-ink-4" aria-hidden="true" /> <Lock className="mb-2 size-4 text-ink-secondary" aria-hidden="true" />
)} )}
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <span className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
{card.label} {card.label}
</span> </span>
<span className="mt-1 text-sm font-semibold text-ink-1">{card.sublabel}</span> <span className="mt-1 text-sm font-semibold text-ink-primary">{card.sublabel}</span>
{card.lockLabel && ( {card.lockLabel && (
<span className="mt-1.5 text-xs text-ink-4">{card.lockLabel}</span> <span className="mt-1.5 text-xs text-ink-secondary">{card.lockLabel}</span>
)} )}
</Card> </Card>
) )
@ -114,13 +116,13 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<Badge variant="neutral">{abbrev}</Badge> <Badge variant="neutral">{abbrev}</Badge>
{isLoading && ( {isLoading && (
<Loader2 className="size-3.5 animate-spin text-expria" aria-hidden="true" /> <Loader2 className="size-3.5 animate-spin text-brand-text" aria-hidden="true" />
)} )}
</div> </div>
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <span className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
{card.label} {card.label}
</span> </span>
<span className="mt-1 text-sm font-semibold text-ink-1">{card.sublabel}</span> <span className="mt-1 text-sm font-semibold text-ink-primary">{card.sublabel}</span>
</Card> </Card>
) )
})} })}

View file

@ -29,7 +29,7 @@ export function TimerDisplay({ secondesRestantes, isExpired }: Props) {
? 'text-danger font-bold' ? 'text-danger font-bold'
: isCritique : isCritique
? 'text-danger motion-safe:animate-pulse' ? 'text-danger motion-safe:animate-pulse'
: 'text-ink-2' : 'text-ink-primary'
return ( return (
<span <span

View file

@ -47,7 +47,7 @@ export function WordCountBar({ count, config }: Props) {
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={config.motsCibleMax} aria-valuemax={config.motsCibleMax}
aria-label={`Progression du nombre de mots : ${count} sur une cible de ${config.motsCibleMin} à ${config.motsCibleMax} mots`} aria-label={`Progression du nombre de mots : ${count} sur une cible de ${config.motsCibleMin} à ${config.motsCibleMax} mots`}
className="h-1.5 w-full overflow-hidden rounded-full bg-canvas-2" className="h-1.5 w-full overflow-hidden rounded-full bg-surface"
> >
<div <div
className={`h-full rounded-full transition-[width] duration-150 ease-out ${classes.bar}`} className={`h-full rounded-full transition-[width] duration-150 ease-out ${classes.bar}`}
@ -58,7 +58,7 @@ export function WordCountBar({ count, config }: Props) {
<span className={`font-medium tabular-nums ${classes.text}`}> <span className={`font-medium tabular-nums ${classes.text}`}>
{count.toLocaleString('fr-FR')} mot{count > 1 ? 's' : ''} {count.toLocaleString('fr-FR')} mot{count > 1 ? 's' : ''}
</span> </span>
<span className="text-ink-4 tabular-nums"> <span className="text-ink-secondary tabular-nums">
cible {config.motsCibleMin}{config.motsCibleMax} mots cible {config.motsCibleMin}{config.motsCibleMax} mots
</span> </span>
</div> </div>

View file

@ -18,19 +18,23 @@ interface Props {
export function ConseilNclcCallout({ conseil }: Props) { export function ConseilNclcCallout({ conseil }: Props) {
return ( return (
<section aria-label="Plan d'action NCLC"> <section aria-label="Plan d'action NCLC">
<h2 className="mb-3 text-base font-semibold text-ink-1">Plan d'action NCLC</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">Plan d'action NCLC</h2>
<Card variant="raised" className="space-y-3 p-4"> <Card variant="raised" className="space-y-3 p-4">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1"> <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Objectif</p> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
<p className="text-sm font-semibold text-ink-1">{conseil.nclc_cible}</p> Objectif
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Écart</p> </p>
<p className="text-sm text-ink-2">{conseil.ecart}</p> <p className="text-sm font-semibold text-ink-primary">{conseil.nclc_cible}</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Écart
</p>
<p className="text-sm text-ink-primary">{conseil.ecart}</p>
</div> </div>
<div className="space-y-1.5 rounded-md border border-expria/30 bg-expria-50 p-3"> <div className="space-y-1.5 rounded-md border border-brand/30 bg-brand-soft p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-expria"> <p className="text-[11px] font-semibold uppercase tracking-widest text-brand-text">
Action prioritaire Action prioritaire
</p> </p>
<div className="text-sm leading-relaxed text-ink-1"> <div className="text-sm leading-relaxed text-ink-primary">
<ReactMarkdown disallowedElements={['script', 'iframe']}> <ReactMarkdown disallowedElements={['script', 'iframe']}>
{conseil.action_prioritaire} {conseil.action_prioritaire}
</ReactMarkdown> </ReactMarkdown>

View file

@ -25,14 +25,14 @@ export function CritereCard({ critere, erreursCodes }: Props) {
return ( return (
<Card variant="default" className="space-y-3 p-4"> <Card variant="default" className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h3 className="text-sm font-semibold text-ink-1">{critere.nom}</h3> <h3 className="text-sm font-semibold text-ink-primary">{critere.nom}</h3>
<Badge variant="nclc" className="shrink-0 tabular-nums"> <Badge variant="nclc" className="shrink-0 tabular-nums">
{critere.score}/5 {critere.score}/5
</Badge> </Badge>
</div> </div>
{critere.commentaire && ( {critere.commentaire && (
<div className="text-sm leading-relaxed text-ink-2"> <div className="text-sm leading-relaxed text-ink-primary">
<ReactMarkdown disallowedElements={['script', 'iframe']}> <ReactMarkdown disallowedElements={['script', 'iframe']}>
{critere.commentaire} {critere.commentaire}
</ReactMarkdown> </ReactMarkdown>
@ -40,26 +40,26 @@ export function CritereCard({ critere, erreursCodes }: Props) {
)} )}
{critere.exemple && ( {critere.exemple && (
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3"> <div className="space-y-1.5 rounded-md border border-border bg-surface p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Exemple tiré de votre texte Exemple tiré de votre texte
</p> </p>
<p className="italic text-sm leading-relaxed text-ink-2">« {critere.exemple} »</p> <p className="italic text-sm leading-relaxed text-ink-primary">« {critere.exemple} »</p>
</div> </div>
)} )}
{critere.suggestion && ( {critere.suggestion && (
<div className="space-y-1.5 rounded-md border border-expria/30 bg-expria-50 p-3"> <div className="space-y-1.5 rounded-md border border-brand/30 bg-brand-soft p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-expria"> <p className="text-[11px] font-semibold uppercase tracking-widest text-brand-text">
Reformulation suggérée Reformulation suggérée
</p> </p>
<p className="text-sm leading-relaxed text-ink-1">{critere.suggestion}</p> <p className="text-sm leading-relaxed text-ink-primary">{critere.suggestion}</p>
</div> </div>
)} )}
{critere.astuce && ( {critere.astuce && (
<div className="flex gap-2 text-sm text-ink-3"> <div className="flex gap-2 text-sm text-ink-secondary">
<span className="shrink-0 text-expria" aria-hidden="true"> <span className="shrink-0 text-brand-text" aria-hidden="true">
💡 💡
</span> </span>
<span>{critere.astuce}</span> <span>{critere.astuce}</span>
@ -67,7 +67,7 @@ export function CritereCard({ critere, erreursCodes }: Props) {
)} )}
{erreursCodes.length > 0 && ( {erreursCodes.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-line pt-3"> <div className="flex flex-wrap gap-1.5 border-t border-border pt-3">
{erreursCodes.map((e) => ( {erreursCodes.map((e) => (
<Badge key={`${e.code}-${e.description ?? ''}`} variant="neutral"> <Badge key={`${e.code}-${e.description ?? ''}`} variant="neutral">
{e.description ?? e.code.replace(/_/g, ' ')} {e.description ?? e.code.replace(/_/g, ' ')}

View file

@ -17,9 +17,11 @@ interface Props {
export function DiagnosticCallout({ diagnostic }: Props) { export function DiagnosticCallout({ diagnostic }: Props) {
return ( return (
<section aria-label="Frein principal"> <section aria-label="Frein principal">
<h2 className="mb-3 text-base font-semibold text-ink-1">Ce qui freine votre progression</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">
Ce qui freine votre progression
</h2>
<Card variant="default" className="border-l-4 border-l-expria p-4"> <Card variant="default" className="border-l-4 border-l-expria p-4">
<div className="text-sm leading-relaxed text-ink-1"> <div className="text-sm leading-relaxed text-ink-primary">
<ReactMarkdown disallowedElements={['script', 'iframe']}>{diagnostic}</ReactMarkdown> <ReactMarkdown disallowedElements={['script', 'iframe']}>{diagnostic}</ReactMarkdown>
</div> </div>
</Card> </Card>

View file

@ -39,7 +39,7 @@ export function ExerciceInteractive({ exercice }: Props) {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="nclc">{DIFFICULTE_LABEL[exercice.difficulte]}</Badge> <Badge variant="nclc">{DIFFICULTE_LABEL[exercice.difficulte]}</Badge>
{exercice.theme && ( {exercice.theme && (
<span className="text-xs font-medium text-ink-4"> <span className="text-xs font-medium text-ink-secondary">
{exercice.theme.replace(/_/g, ' ')} {exercice.theme.replace(/_/g, ' ')}
</span> </span>
)} )}
@ -47,33 +47,35 @@ export function ExerciceInteractive({ exercice }: Props) {
</div> </div>
{exercice.diagnostic && ( {exercice.diagnostic && (
<p className="text-sm leading-relaxed text-ink-3">{exercice.diagnostic}</p> <p className="text-sm leading-relaxed text-ink-secondary">{exercice.diagnostic}</p>
)} )}
{exercice.consigne && ( {exercice.consigne && (
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3"> <div className="space-y-1.5 rounded-md border border-border bg-surface p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Consigne</p> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
<p className="text-sm leading-relaxed text-ink-1">{exercice.consigne}</p> Consigne
</p>
<p className="text-sm leading-relaxed text-ink-primary">{exercice.consigne}</p>
</div> </div>
)} )}
{exercice.extrait && ( {exercice.extrait && (
<div className="space-y-1.5 rounded-md border border-line bg-surface p-3"> <div className="space-y-1.5 rounded-md border border-border bg-surface p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Extrait à retravailler Extrait à retravailler
</p> </p>
<p className="italic text-sm leading-relaxed text-ink-2">« {exercice.extrait} »</p> <p className="italic text-sm leading-relaxed text-ink-primary">« {exercice.extrait} »</p>
</div> </div>
)} )}
<label className="block space-y-1.5"> <label className="block space-y-1.5">
<span className="text-sm font-medium text-ink-2">Votre réponse</span> <span className="text-sm font-medium text-ink-primary">Votre réponse</span>
<textarea <textarea
rows={3} rows={3}
value={tentative} value={tentative}
onChange={(e) => setTentative(e.target.value)} onChange={(e) => setTentative(e.target.value)}
placeholder="Écrivez votre tentative ici…" placeholder="Écrivez votre tentative ici…"
className="w-full resize-none rounded-md border border-line bg-surface p-2 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:shadow-focus" 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"
/> />
</label> </label>
@ -101,35 +103,35 @@ export function ExerciceInteractive({ exercice }: Props) {
{indiceRevealed && exercice.indice && ( {indiceRevealed && exercice.indice && (
<div <div
className="space-y-1 rounded-md border border-warning/30 bg-warning-bg p-3" className="space-y-1 rounded-md border border-warning/30 bg-warning-soft p-3"
aria-live="polite" aria-live="polite"
> >
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning">Indice</p> <p className="text-[11px] font-semibold uppercase tracking-widest text-warning">Indice</p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.indice}</p> <p className="text-sm leading-relaxed text-ink-primary">{exercice.indice}</p>
</div> </div>
)} )}
{correctionRevealed && ( {correctionRevealed && (
<div className="space-y-3" aria-live="polite"> <div className="space-y-3" aria-live="polite">
<div className="space-y-1 rounded-md border border-success/30 bg-success-bg p-3"> <div className="space-y-1 rounded-md border border-success/30 bg-success-soft p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-success"> <p className="text-[11px] font-semibold uppercase tracking-widest text-success">
Correction attendue Correction attendue
</p> </p>
<p className="text-sm leading-relaxed text-ink-1">{exercice.correction}</p> <p className="text-sm leading-relaxed text-ink-primary">{exercice.correction}</p>
</div> </div>
{exercice.explication && ( {exercice.explication && (
<div className="space-y-1 rounded-md border border-line bg-canvas-2 p-3"> <div className="space-y-1 rounded-md border border-border bg-surface p-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Explication Explication
</p> </p>
<div className="text-sm leading-relaxed text-ink-2"> <div className="text-sm leading-relaxed text-ink-primary">
<ReactMarkdown disallowedElements={['script', 'iframe']}> <ReactMarkdown disallowedElements={['script', 'iframe']}>
{exercice.explication} {exercice.explication}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</div> </div>
)} )}
<p className="text-xs text-ink-4"> <p className="text-xs text-ink-secondary">
Comparez avec votre réponse ci-dessus pour repérer les différences. Comparez avec votre réponse ci-dessus pour repérer les différences.
</p> </p>
</div> </div>

View file

@ -36,7 +36,7 @@ export function JobStatusFallback({
if (hasTimedOut) { if (hasTimedOut) {
return ( return (
<Card variant="default" className="space-y-3 p-4"> <Card variant="default" className="space-y-3 p-4">
<p className="text-sm text-ink-2" role="alert"> <p className="text-sm text-ink-primary" role="alert">
La génération prend plus de temps que prévu. La génération prend plus de temps que prévu.
</p> </p>
{onRetry && ( {onRetry && (
@ -50,8 +50,8 @@ export function JobStatusFallback({
return ( return (
<Card variant="default" className="flex items-center gap-3 p-4"> <Card variant="default" className="flex items-center gap-3 p-4">
<Loader2 className="size-4 animate-spin text-ink-4" aria-hidden="true" /> <Loader2 className="size-4 animate-spin text-ink-secondary" aria-hidden="true" />
<p className="text-sm text-ink-3" aria-live="polite"> <p className="text-sm text-ink-secondary" aria-live="polite">
{pendingLabel} {pendingLabel}
</p> </p>
</Card> </Card>

View file

@ -27,12 +27,12 @@ export function ProductionModeleSection({ modele }: Props) {
<div className="space-y-4"> <div className="space-y-4">
<Card variant="raised" className="space-y-3 p-4"> <Card variant="raised" className="space-y-3 p-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Version restructurée NCLC 9+ Version restructurée NCLC 9+
</p> </p>
<Badge variant="nclc">{modele.tcf_word_count ?? ''} mots</Badge> <Badge variant="nclc">{modele.tcf_word_count ?? ''} mots</Badge>
</div> </div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-1"> <p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-primary">
{modele.production_modele_propre} {modele.production_modele_propre}
</p> </p>
{modele.tcf_truncated && ( {modele.tcf_truncated && (
@ -44,14 +44,14 @@ export function ProductionModeleSection({ modele }: Props) {
{modele.notes_pedagogiques.length > 0 && ( {modele.notes_pedagogiques.length > 0 && (
<Card variant="default" className="space-y-3 p-4"> <Card variant="default" className="space-y-3 p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Passages clés Passages clés
</p> </p>
<ul className="space-y-3"> <ul className="space-y-3">
{modele.notes_pedagogiques.map((n, i) => ( {modele.notes_pedagogiques.map((n, i) => (
<li key={i} className="space-y-1.5 border-l-2 border-expria pl-3"> <li key={i} className="space-y-1.5 border-l-2 border-brand pl-3">
<p className="italic text-sm leading-relaxed text-ink-2">« {n.passage} »</p> <p className="italic text-sm leading-relaxed text-ink-primary">« {n.passage} »</p>
<p className="text-xs text-ink-3">{n.explication}</p> <p className="text-xs text-ink-secondary">{n.explication}</p>
</li> </li>
))} ))}
</ul> </ul>
@ -60,27 +60,27 @@ export function ProductionModeleSection({ modele }: Props) {
{modele.transformations.length > 0 && ( {modele.transformations.length > 0 && (
<Card variant="default" className="space-y-3 p-4"> <Card variant="default" className="space-y-3 p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Transformations appliquées Transformations appliquées
</p> </p>
<ul className="space-y-4"> <ul className="space-y-4">
{modele.transformations.map((t, i) => ( {modele.transformations.map((t, i) => (
<li key={i} className="space-y-2"> <li key={i} className="space-y-2">
<div className="rounded-md border border-line bg-canvas-2 p-2"> <div className="rounded-md border border-border bg-surface p-2">
<span className="text-[10px] font-semibold uppercase tracking-widest text-ink-5"> <span className="text-[10px] font-semibold uppercase tracking-widest text-ink-tertiary">
Original Original
</span> </span>
<p className="text-sm text-ink-3 line-through decoration-danger decoration-1"> <p className="text-sm text-ink-secondary line-through decoration-danger decoration-1">
{t.original} {t.original}
</p> </p>
</div> </div>
<div className="rounded-md border border-success/30 bg-success-bg p-2"> <div className="rounded-md border border-success/30 bg-success-soft p-2">
<span className="text-[10px] font-semibold uppercase tracking-widest text-success"> <span className="text-[10px] font-semibold uppercase tracking-widest text-success">
Amélioré Amélioré
</span> </span>
<p className="text-sm text-ink-1">{t.ameliore}</p> <p className="text-sm text-ink-primary">{t.ameliore}</p>
</div> </div>
<p className="text-xs text-ink-4">{t.explication}</p> <p className="text-xs text-ink-secondary">{t.explication}</p>
</li> </li>
))} ))}
</ul> </ul>
@ -89,7 +89,7 @@ export function ProductionModeleSection({ modele }: Props) {
{modele.message && ( {modele.message && (
<Card variant="default" className="border-l-4 border-l-expria p-4"> <Card variant="default" className="border-l-4 border-l-expria p-4">
<div className="text-sm leading-relaxed text-ink-1"> <div className="text-sm leading-relaxed text-ink-primary">
<ReactMarkdown disallowedElements={['script', 'iframe']}> <ReactMarkdown disallowedElements={['script', 'iframe']}>
{modele.message} {modele.message}
</ReactMarkdown> </ReactMarkdown>

View file

@ -23,7 +23,7 @@ const SECTIONS: { key: keyof Revelation; titre: string; ton: 'ink' | 'warning' |
] ]
const TON_CLASS: Record<'ink' | 'warning' | 'danger', string> = { const TON_CLASS: Record<'ink' | 'warning' | 'danger', string> = {
ink: 'text-ink-2', ink: 'text-ink-primary',
warning: 'text-warning', warning: 'text-warning',
danger: 'text-danger', danger: 'text-danger',
} }
@ -31,7 +31,7 @@ const TON_CLASS: Record<'ink' | 'warning' | 'danger', string> = {
export function RevelationCards({ revelation }: Props) { export function RevelationCards({ revelation }: Props) {
return ( return (
<section aria-label="Lecture du correcteur"> <section aria-label="Lecture du correcteur">
<h2 className="mb-3 text-base font-semibold text-ink-1">Lecture du correcteur</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">Lecture du correcteur</h2>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
{SECTIONS.map(({ key, titre, ton }) => ( {SECTIONS.map(({ key, titre, ton }) => (
<Card key={key} variant="default" className="p-4"> <Card key={key} variant="default" className="p-4">
@ -40,7 +40,7 @@ export function RevelationCards({ revelation }: Props) {
> >
{titre} {titre}
</p> </p>
<div className="text-sm leading-relaxed text-ink-2"> <div className="text-sm leading-relaxed text-ink-primary">
<ReactMarkdown disallowedElements={['script', 'iframe']}> <ReactMarkdown disallowedElements={['script', 'iframe']}>
{revelation[key]} {revelation[key]}
</ReactMarkdown> </ReactMarkdown>

View file

@ -30,14 +30,16 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
<Card variant="raised" className="space-y-4 p-6"> <Card variant="raised" className="space-y-4 p-6">
<div className="flex flex-wrap items-end gap-8"> <div className="flex flex-wrap items-end gap-8">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Score</p> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
<p className="mt-1 tabular-nums text-ink-1"> Score
</p>
<p className="mt-1 tabular-nums text-ink-primary">
<span className="text-5xl font-bold">{score}</span> <span className="text-5xl font-bold">{score}</span>
<span className="text-2xl font-medium text-ink-4">/20</span> <span className="text-2xl font-medium text-ink-secondary">/20</span>
</p> </p>
</div> </div>
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5"> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Niveau atteint Niveau atteint
</p> </p>
<Badge variant="nclc" className="mt-2"> <Badge variant="nclc" className="mt-2">
@ -45,7 +47,9 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
</Badge> </Badge>
</div> </div>
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">Objectif</p> <p className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
Objectif
</p>
<Badge variant="neutral" className="mt-2"> <Badge variant="neutral" className="mt-2">
NCLC {nclcCible} NCLC {nclcCible}
</Badge> </Badge>
@ -55,7 +59,7 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
{/* Jauge avec marqueur NCLC cible */} {/* Jauge avec marqueur NCLC cible */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<div <div
className="relative h-2 overflow-hidden rounded-full bg-canvas-2" className="relative h-2 overflow-hidden rounded-full bg-surface"
role="progressbar" role="progressbar"
aria-valuenow={score} aria-valuenow={score}
aria-valuemin={0} aria-valuemin={0}
@ -63,18 +67,18 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
aria-label={`Score ${score} sur 20`} aria-label={`Score ${score} sur 20`}
> >
<div <div
className={`h-full transition-all duration-300 ${atteint ? 'bg-success' : 'bg-expria'}`} className={`h-full transition-all duration-300 ${atteint ? 'bg-success' : 'bg-brand'}`}
style={{ width: `${percent}%` }} style={{ width: `${percent}%` }}
/> />
{/* Marqueur du seuil NCLC cible */} {/* Marqueur du seuil NCLC cible */}
<div <div
className="absolute top-0 h-full w-0.5 bg-ink-2" className="absolute top-0 h-full w-0.5 bg-ink-primary"
style={{ left: `${seuilPercent}%` }} style={{ left: `${seuilPercent}%` }}
aria-hidden="true" aria-hidden="true"
title={`Seuil NCLC ${nclcCible} : ${seuilCible}/20`} title={`Seuil NCLC ${nclcCible} : ${seuilCible}/20`}
/> />
</div> </div>
<div className="flex justify-between text-xs text-ink-4 tabular-nums"> <div className="flex justify-between text-xs text-ink-secondary tabular-nums">
<span>0</span> <span>0</span>
<span className="font-medium"> <span className="font-medium">
Seuil NCLC {nclcCible} : {seuilCible}/20 Seuil NCLC {nclcCible} : {seuilCible}/20
@ -85,11 +89,11 @@ export function ScoreHero({ score, nclc, nclcCible }: Props) {
{/* Encart d'écart */} {/* Encart d'écart */}
{atteint ? ( {atteint ? (
<p className="rounded-md border border-success/30 bg-success-bg px-3 py-2 text-sm text-success"> <p className="rounded-md border border-success/30 bg-success-soft px-3 py-2 text-sm text-success">
Objectif NCLC {nclcCible} atteint. Objectif NCLC {nclcCible} atteint.
</p> </p>
) : ( ) : (
<p className="rounded-md border border-warning/30 bg-warning-bg px-3 py-2 text-sm text-warning"> <p className="rounded-md border border-warning/30 bg-warning-soft px-3 py-2 text-sm text-warning">
{points === 1 ? '1 point avant NCLC ' : `${points} points avant NCLC `} {points === 1 ? '1 point avant NCLC ' : `${points} points avant NCLC `}
{nclcCible}+ {nclcCible}+
</p> </p>

View file

@ -59,15 +59,15 @@ function BlurredSection({
}) { }) {
if (visible) return <>{children}</> if (visible) return <>{children}</>
return ( return (
<div className="relative min-h-[120px] overflow-hidden rounded-lg border border-line bg-canvas-2"> <div className="relative min-h-[120px] overflow-hidden rounded-lg border border-border bg-surface">
<div className="space-y-2 p-4 opacity-25 blur-sm" aria-hidden="true"> <div className="space-y-2 p-4 opacity-25 blur-sm" aria-hidden="true">
{PLACEHOLDER_WIDTHS.map((w, i) => ( {PLACEHOLDER_WIDTHS.map((w, i) => (
<div key={i} className={`h-3 rounded bg-ink-4 ${w}`} /> <div key={i} className={`h-3 rounded bg-surface-hover ${w}`} />
))} ))}
</div> </div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
<Lock className="size-5 text-ink-4" aria-hidden="true" /> <Lock className="size-5 text-ink-secondary" aria-hidden="true" />
<p className="text-sm font-medium text-ink-2">Disponible en Standard</p> <p className="text-sm font-medium text-ink-primary">Disponible en Standard</p>
<Button variant="upgrade" size="sm" onClick={onUpgrade}> <Button variant="upgrade" size="sm" onClick={onUpgrade}>
Passer en Standard Passer en Standard
</Button> </Button>
@ -81,10 +81,10 @@ function BlurredSection({
function RapportSkeleton() { function RapportSkeleton() {
return ( return (
<div className="space-y-4" aria-busy="true" aria-label="Chargement du rapport…"> <div className="space-y-4" aria-busy="true" aria-label="Chargement du rapport…">
<div className="h-40 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-40 animate-pulse rounded-lg bg-surface" />
<div className="h-28 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-28 animate-pulse rounded-lg bg-surface" />
<div className="h-32 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-32 animate-pulse rounded-lg bg-surface" />
<div className="h-48 animate-pulse rounded-lg bg-canvas-2" /> <div className="h-48 animate-pulse rounded-lg bg-surface" />
</div> </div>
) )
} }
@ -96,7 +96,7 @@ function CriteresSection({ rapport }: { rapport: Report }) {
return ( return (
<section aria-label="Détail par critère"> <section aria-label="Détail par critère">
<h2 className="mb-3 text-base font-semibold text-ink-1">Détail par critère</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">Détail par critère</h2>
<div className="space-y-3"> <div className="space-y-3">
{rapport.criteres.map((c) => { {rapport.criteres.map((c) => {
const code = critereCodeFromNom(c.nom) const code = critereCodeFromNom(c.nom)
@ -120,7 +120,9 @@ function ExercicesSection({
if (rapport.exercices_status !== 'ready' || !rapport.exercices) { if (rapport.exercices_status !== 'ready' || !rapport.exercices) {
return ( return (
<section aria-label="Exercices personnalisés"> <section aria-label="Exercices personnalisés">
<h2 className="mb-3 text-base font-semibold text-ink-1">Mes exercices personnalisés</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">
Mes exercices personnalisés
</h2>
<JobStatusFallback <JobStatusFallback
status={rapport.exercices_status} status={rapport.exercices_status}
pendingLabel="Génération des exercices en cours…" pendingLabel="Génération des exercices en cours…"
@ -134,7 +136,7 @@ function ExercicesSection({
return ( return (
<section aria-label="Exercices personnalisés"> <section aria-label="Exercices personnalisés">
<h2 className="mb-3 text-base font-semibold text-ink-1">Mes exercices personnalisés</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">Mes exercices personnalisés</h2>
<div className="space-y-3"> <div className="space-y-3">
{rapport.exercices.map((ex, i) => ( {rapport.exercices.map((ex, i) => (
<ExerciceInteractive key={`${ex.theme}-${i}`} exercice={ex} /> <ExerciceInteractive key={`${ex.theme}-${i}`} exercice={ex} />
@ -156,7 +158,9 @@ function ModeleSection({
if (rapport.modele_status !== 'ready' || !rapport.modele) { if (rapport.modele_status !== 'ready' || !rapport.modele) {
return ( return (
<section aria-label="Production modèle"> <section aria-label="Production modèle">
<h2 className="mb-3 text-base font-semibold text-ink-1">Version restructurée NCLC 9+</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">
Version restructurée NCLC 9+
</h2>
<JobStatusFallback <JobStatusFallback
status={rapport.modele_status} status={rapport.modele_status}
pendingLabel="Production modèle en cours de génération…" pendingLabel="Production modèle en cours de génération…"
@ -170,7 +174,9 @@ function ModeleSection({
return ( return (
<section aria-label="Production modèle"> <section aria-label="Production modèle">
<h2 className="mb-3 text-base font-semibold text-ink-1">Version restructurée NCLC 9+</h2> <h2 className="mb-3 text-base font-semibold text-ink-primary">
Version restructurée NCLC 9+
</h2>
<ProductionModeleSection modele={rapport.modele} /> <ProductionModeleSection modele={rapport.modele} />
</section> </section>
) )
@ -209,16 +215,19 @@ export function RapportPage() {
return ( return (
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6"> <main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav aria-label="Fil d'Ariane" className="flex items-center gap-1.5 text-sm text-ink-4"> <nav
aria-label="Fil d'Ariane"
className="flex items-center gap-1.5 text-sm text-ink-secondary"
>
<button <button
type="button" type="button"
onClick={goToSimulations} onClick={goToSimulations}
className="transition-colors duration-150 hover:text-ink-2" className="transition-colors duration-150 hover:text-ink-primary"
> >
Simulations Simulations
</button> </button>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span aria-current="page" className="text-ink-2"> <span aria-current="page" className="text-ink-primary">
Rapport Rapport
</span> </span>
</nav> </nav>
@ -226,7 +235,7 @@ export function RapportPage() {
{(isLoading || isPlanLoading) && <RapportSkeleton />} {(isLoading || isPlanLoading) && <RapportSkeleton />}
{isInProgress && ( {isInProgress && (
<p className="text-center text-sm text-ink-4" aria-live="polite"> <p className="text-center text-sm text-ink-secondary" aria-live="polite">
Votre simulation est en cours. Votre simulation est en cours.
</p> </p>
)} )}

View file

@ -17,10 +17,10 @@ import { SimulationForm } from '../components/SimulationForm'
function SimulationSkeleton() { function SimulationSkeleton() {
return ( return (
<div className="space-y-4" aria-busy="true" aria-label="Chargement…"> <div className="space-y-4" aria-busy="true" aria-label="Chargement…">
<div className="h-6 w-40 animate-pulse rounded bg-canvas-2" /> <div className="h-6 w-40 animate-pulse rounded bg-surface" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-lg bg-canvas-2" /> <div key={i} className="h-24 animate-pulse rounded-lg bg-surface" />
))} ))}
</div> </div>
</div> </div>

View file

@ -26,7 +26,7 @@ function SujetsSkeleton() {
return ( return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded-lg bg-canvas-2" /> <div key={i} className="h-32 animate-pulse rounded-lg bg-surface" />
))} ))}
</div> </div>
) )
@ -83,17 +83,17 @@ export function SujetsPage() {
reset() reset()
navigate('/simulation/ee') navigate('/simulation/ee')
}} }}
className="text-sm text-ink-4 underline-offset-4 hover:text-ink-2 hover:underline" className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
> >
Retour Retour
</button> </button>
<h2 className="flex-1 text-lg font-semibold text-ink-1"> <h2 className="flex-1 text-lg font-semibold text-ink-primary">
Choisir un sujet {formatTache(production.tache)} Choisir un sujet {formatTache(production.tache)}
</h2> </h2>
</div> </div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-ink-3"> <p className="text-sm text-ink-secondary">
{isLoading {isLoading
? 'Chargement des sujets…' ? 'Chargement des sujets…'
: hasSujets : hasSujets
@ -114,7 +114,7 @@ export function SujetsPage() {
{isError && ( {isError && (
<div <div
role="alert" role="alert"
className="mb-4 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-sm text-danger" className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
> >
Impossible de charger les sujets.{' '} Impossible de charger les sujets.{' '}
<button type="button" onClick={() => refetch()} className="underline underline-offset-2"> <button type="button" onClick={() => refetch()} className="underline underline-offset-2">

View file

@ -1,107 +1,148 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
/* Dark mode : .dark class sur <html> — toggle React (ThemeProvider, étape 2) */ /* Dark = défaut. `.light` sur <html> active le mode clair (override sur --color-*). */
@variant dark (&:where(.dark, .dark *)); @custom-variant light (&:where(.light, .light *));
@theme { @theme {
/* ─── Typographie ───────────────────────────────────────────── */ /*
--font-sans: INVARIANTS identiques dark et light
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; */
/* ─── Fonds ─────────────────────────────────────────────────── */ /* ── Sidebar (navy permanent) ── */
/* bg-canvas = fond de page (jamais pur blanc) */ --color-sidebar-bg: #0c1528;
/* bg-surface = cards — ressortent sur le canvas */ --color-sidebar-border: rgba(255, 255, 255, 0.07);
--color-canvas: #eef2f8; --color-sidebar-text: rgba(255, 255, 255, 0.6);
--color-canvas-2: #e6ebf4; --color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
--color-surface: #ffffff; --color-sidebar-text-active: #ffffff;
--color-surface-hover: #f8fafd; --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);
/* ─── Hairlines ──────────────────────────────────────────────── */ /* ── Brand ── */
--color-line: #dde3ed; --color-brand: #1b4fd8;
--color-line-strong: #c7d0e0; --color-brand-hover: #1744b8;
--color-brand-active: #13379c;
--color-brand-dark: #1740b0;
--color-brand-ink: #ffffff;
/* ─── Encres ─────────────────────────────────────────────────── */ /* ── Semantic ── */
--color-ink-1: #0f172a; --color-warning: #f59e0b;
--color-ink-2: #1e293b; --color-warning-soft: rgba(245, 158, 11, 0.12);
--color-ink-3: #475569; --color-danger: #ef4444;
--color-ink-4: #64748b; --color-danger-soft: rgba(239, 68, 68, 0.12);
--color-ink-5: #94a3b8;
/* ─── Brand Expria ───────────────────────────────────────────── */ /* ── Typography ── */
--color-expria: #1b4fd8; --font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
--color-expria-hover: #1741b8; --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
--color-expria-50: #eef3ff;
--color-expria-100: #dce6ff;
--color-expria-200: #b8cdff;
--color-deep: #0b1f5c;
--color-deep-2: #142b6e;
/* ─── Sémantiques ────────────────────────────────────────────── */ --text-xs: 11px;
--color-success: #0e9f6e; --text-sm: 13px;
--color-success-bg: #e6f6f0; --text-base: 14px;
--color-warning: #c77a00; --text-md: 15px;
--color-warning-bg: #fef3e2; --text-lg: 17px;
--color-danger: #c53030; --text-xl: 20px;
--color-danger-bg: #fdecec; --text-2xl: 24px;
--text-3xl: 32px;
--text-display: 40px;
/* ─── Rayons (override des defaults Tailwind) ────────────────── */ /* ── Rayons ── */
--radius-sm: 6px; --radius-xs: 6px;
--radius-md: 10px; --radius-sm: 8px;
--radius-lg: 14px; --radius-md: 12px;
--radius-xl: 18px; --radius-lg: 16px;
--radius-full: 999px; --radius-xl: 20px;
--radius-pill: 999px;
/* ─── Ombres (light mode) ────────────────────────────────────── */ /* ── Focus ── */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
--shadow-lg: 0 12px 28px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04);
--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18); --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;
} }
/* ─── Dark mode — override des tokens couleur et ombres ──────────── */ /*
.dark { LIGHT MODE override .light sur <html>/<body>
/* Fonds */ */
--color-canvas: #0d1220;
--color-canvas-2: #121a2d;
--color-surface: #182238;
--color-surface-hover: #1e2a42;
/* Hairlines */ .light {
--color-line: #27324b; --color-canvas: #f3f4f6;
--color-line-strong: #364363; --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);
/* Encres */ --color-ink-primary: #0f0f1a;
--color-ink-1: #f1f4fa; --color-ink-secondary: rgba(0, 0, 0, 0.55);
--color-ink-2: #dde3ef; --color-ink-tertiary: rgba(0, 0, 0, 0.3);
--color-ink-3: #a8b2c7; --color-ink-inverse: #ffffff;
--color-ink-4: #7a8499;
--color-ink-5: #525c73;
/* Brand — remonté en luminance pour rester lisible sur fond sombre */ --color-brand-soft: rgba(27, 79, 216, 0.06);
--color-expria: #5b7fff; --color-brand-text: #1b4fd8;
--color-expria-hover: #6f8eff;
--color-expria-50: rgba(91, 127, 255, 0.12);
--color-deep: #060b1a;
/* Sémantiques */ --color-success: #16a34a;
--color-success: #3dd68c; --color-success-soft: rgba(22, 163, 74, 0.1);
--color-success-bg: rgba(61, 214, 140, 0.12);
--color-warning: #f5b849;
--color-warning-bg: rgba(245, 184, 73, 0.12);
--color-danger: #f06b6b;
--color-danger-bg: rgba(240, 107, 107, 0.12);
/* Ombres — jouer sur les surfaces, pas les ombres claires */ --color-topbar-bg: rgba(243, 244, 246, 0.88);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --color-gradient-a: rgba(27, 79, 216, 0.025);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); --color-gradient-b: rgba(27, 79, 216, 0.01);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
--shadow-focus: 0 0 0 3px rgba(91, 127, 255, 0.32); --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);
} }
/* ─── Rendu sub-pixel global (non couvert par Tailwind) ──────────── */ /* ── Globals ── */
html,
body { body {
background-color: var(--color-canvas); background: var(--color-canvas);
color: var(--color-ink-2); color: var(--color-ink-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -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;
}
} }

View file

@ -23,7 +23,7 @@ export function Logo({ size = 'md', variant = 'full', className }: LogoProps) {
return ( return (
<div <div
className={cn( className={cn(
'inline-flex items-center gap-2.5 font-bold tracking-tight text-ink-1', 'inline-flex items-center gap-2.5 font-bold tracking-tight text-ink-primary',
className, className,
)} )}
role={variant === 'icon' ? 'img' : undefined} role={variant === 'icon' ? 'img' : undefined}
@ -31,7 +31,7 @@ export function Logo({ size = 'md', variant = 'full', className }: LogoProps) {
> >
<span <span
className={cn( className={cn(
'flex shrink-0 items-center justify-center rounded-sm bg-expria font-bold tracking-tight text-white', 'flex shrink-0 items-center justify-center rounded-sm bg-brand font-bold tracking-tight text-white',
markStyles[size], markStyles[size],
)} )}
aria-hidden="true" aria-hidden="true"

View file

@ -41,7 +41,7 @@ function AvatarFallback({
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
'flex size-full items-center justify-center rounded-full bg-canvas-2 text-sm text-ink-4 group-data-[size=sm]/avatar:text-xs', 'flex size-full items-center justify-center rounded-full bg-surface-hover text-sm text-ink-secondary group-data-[size=sm]/avatar:text-xs',
className, className,
)} )}
{...props} {...props}
@ -54,7 +54,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
<span <span
data-slot="avatar-badge" data-slot="avatar-badge"
className={cn( className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-expria text-white ring-2 ring-canvas select-none', 'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-brand text-white ring-2 ring-canvas select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden', 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', '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', 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
@ -83,7 +83,7 @@ function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>)
<div <div
data-slot="avatar-group-count" data-slot="avatar-group-count"
className={cn( className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-canvas-2 text-sm text-ink-4 ring-2 ring-canvas group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', 'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-surface-hover text-sm text-ink-secondary ring-2 ring-canvas group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className, className,
)} )}
{...props} {...props}

View file

@ -5,17 +5,18 @@ import { Slot } from 'radix-ui'
import { cn } from '@/shared/lib/utils' import { cn } from '@/shared/lib/utils'
const badgeVariants = cva( 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-expria focus-visible:ring-[3px] focus-visible:ring-expria/30 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40 [&>svg]:pointer-events-none [&>svg]:size-3', '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: { variants: {
variant: { variant: {
default: 'bg-expria text-white [a&]:hover:bg-expria/90', default: 'bg-brand text-white [a&]:hover:bg-brand-hover',
secondary: 'bg-canvas-2 text-ink-1 [a&]:hover:bg-canvas-2/90', secondary: 'bg-surface-hover text-ink-primary [a&]:hover:bg-surface-hover/90',
destructive: destructive:
'bg-danger text-white focus-visible:ring-danger/20 dark:bg-danger/60 dark:focus-visible:ring-danger/40 [a&]:hover:bg-danger/90', '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-line text-ink-2 [a&]:hover:bg-canvas-2 [a&]:hover:text-ink-1', outline:
ghost: '[a&]:hover:bg-canvas-2 [a&]:hover:text-ink-1', 'border-border text-ink-primary [a&]:hover:bg-surface-hover [a&]:hover:text-ink-primary',
link: 'text-expria underline-offset-4 [a&]:hover:underline', ghost: '[a&]:hover:bg-surface-hover [a&]:hover:text-ink-primary',
link: 'text-brand-text underline-offset-4 [a&]:hover:underline',
}, },
}, },
defaultVariants: { defaultVariants: {

View file

@ -5,18 +5,18 @@ import { Slot } from 'radix-ui'
import { cn } from '@/shared/lib/utils' import { cn } from '@/shared/lib/utils'
const buttonVariants = cva( 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-expria focus-visible:ring-[3px] focus-visible:ring-expria/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "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: { variants: {
variant: { variant: {
default: 'bg-expria text-white hover:bg-expria/90', default: 'bg-brand text-white hover:bg-brand-hover',
destructive: destructive:
'bg-danger text-white hover:bg-danger/90 focus-visible:ring-danger/20 dark:bg-danger/60 dark:focus-visible:ring-danger/40', '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: outline:
'border bg-surface shadow-xs hover:bg-canvas-2 hover:text-ink-1 dark:border-line dark:bg-surface/30 dark:hover:bg-surface/50', '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-canvas-2 text-ink-1 hover:bg-canvas-2/80', secondary: 'bg-surface-hover text-ink-primary hover:bg-surface-hover/80',
ghost: 'hover:bg-canvas-2 hover:text-ink-1 dark:hover:bg-canvas-2/50', ghost: 'hover:bg-surface-hover hover:text-ink-primary',
link: 'text-expria underline-offset-4 hover:underline', link: 'text-brand-text underline-offset-4 hover:underline',
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2 has-[>svg]:px-3',

View file

@ -51,7 +51,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-line bg-surface p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg', 'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-border bg-surface-solid p-6 shadow-raised duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className, className,
)} )}
{...props} {...props}
@ -60,7 +60,7 @@ function DialogContent({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-canvas transition-opacity hover:opacity-100 focus:ring-2 focus:ring-expria focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-canvas-2 data-[state=open]:text-ink-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-canvas transition-opacity hover:opacity-100 focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-surface-hover data-[state=open]:text-ink-secondary [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
@ -122,7 +122,7 @@ function DialogDescription({
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn('text-sm text-ink-4', className)} className={cn('text-sm text-ink-secondary', className)}
{...props} {...props}
/> />
) )

View file

@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
'h-9 w-full min-w-0 rounded-md border border-line bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-expria selection:text-white file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-ink-2 placeholder:text-ink-4 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-surface/30', 'h-9 w-full min-w-0 rounded-md border border-border bg-surface/30 px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-brand selection:text-white file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-ink-primary placeholder:text-ink-tertiary disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm light:bg-transparent',
'focus-visible:border-expria focus-visible:ring-[3px] focus-visible:ring-expria/30', 'focus-visible:border-brand focus-visible:ring-[3px] focus-visible:ring-brand/30',
'aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40', 'aria-invalid:border-danger aria-invalid:ring-danger/40 light:aria-invalid:ring-danger/20',
className, className,
)} )}
{...props} {...props}

View file

@ -11,12 +11,12 @@ function Progress({
return ( return (
<ProgressPrimitive.Root <ProgressPrimitive.Root
data-slot="progress" data-slot="progress"
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-expria/20', className)} className={cn('relative h-2 w-full overflow-hidden rounded-full bg-brand/20', className)}
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
data-slot="progress-indicator" data-slot="progress-indicator"
className="h-full w-full flex-1 bg-expria transition-all" className="h-full w-full flex-1 bg-brand transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>

View file

@ -15,7 +15,7 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'shrink-0 bg-line data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className, className,
)} )}
{...props} {...props}

View file

@ -14,11 +14,12 @@ const STORAGE_KEY = 'expria-theme'
export function getInitialTheme(): Theme { export function getInitialTheme(): Theme {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') return stored if (stored === 'light' || stored === 'dark') return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light'
return 'dark'
} }
export function applyTheme(theme: Theme): void { export function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle('dark', theme === 'dark') document.documentElement.classList.toggle('light', theme === 'light')
} }
export function persistTheme(theme: Theme): void { export function persistTheme(theme: Theme): void {

View file

@ -23,15 +23,15 @@ export interface BadgeProps {
} }
const planClasses: Record<BadgePlanValue, string> = { const planClasses: Record<BadgePlanValue, string> = {
free: 'bg-canvas-2 text-ink-4', free: 'bg-surface text-ink-secondary',
standard: 'bg-expria-50 text-expria', standard: 'bg-brand-soft text-brand-text',
premium: 'bg-deep text-white', premium: 'bg-sidebar-bg text-white',
} }
const variantClasses: Record<BadgeVariant, string> = { const variantClasses: Record<BadgeVariant, string> = {
plan: '', // résolu dynamiquement via planValue plan: '', // résolu dynamiquement via planValue
nclc: 'bg-expria-50 text-expria', nclc: 'bg-brand-soft text-brand-text',
neutral: 'bg-canvas-2 text-ink-4', neutral: 'bg-surface text-ink-secondary',
} }
export function Badge({ variant, planValue, className, children }: BadgeProps) { export function Badge({ variant, planValue, className, children }: BadgeProps) {

View file

@ -24,12 +24,13 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
} }
const variantClasses: Record<ButtonVariant, string> = { const variantClasses: Record<ButtonVariant, string> = {
primary: primary: 'bg-brand text-white hover:bg-brand-hover active:bg-brand-active disabled:bg-brand/50',
'bg-expria text-white hover:bg-expria-hover active:bg-expria-hover disabled:bg-expria/50',
secondary: secondary:
'border border-line bg-surface text-ink-2 hover:bg-canvas hover:text-ink-1 disabled:text-ink-4', '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-3 hover:bg-canvas hover:text-ink-1 disabled:text-ink-5', ghost:
upgrade: 'bg-deep text-white hover:bg-deep-2 active:bg-deep-2 disabled:bg-deep/50', '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<ButtonSize, string> = { const sizeClasses: Record<ButtonSize, string> = {

View file

@ -29,13 +29,13 @@ interface CardButtonProps extends CardBaseProps {
export type CardProps = CardDivProps | CardButtonProps export type CardProps = CardDivProps | CardButtonProps
const baseClasses = 'rounded-lg border border-line bg-surface' const baseClasses = 'rounded-lg border border-border bg-surface'
const variantClasses: Record<CardVariant, string> = { const variantClasses: Record<CardVariant, string> = {
default: 'shadow-sm', default: 'shadow-card',
raised: 'shadow-md', raised: 'shadow-raised',
interactive: interactive:
'shadow-sm cursor-pointer transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus', '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) { export function Card({ variant = 'default', className, children, onClick }: CardProps) {