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:
parent
407d1bd134
commit
b68f160bce
61 changed files with 1269 additions and 726 deletions
|
|
@ -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é)
|
||||||
|
|
|
||||||
|
|
@ -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` à 4–5% 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 (2–3% 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 | 150–200 ms, `ease-out`, respect de `prefers-reduced-motion` |
|
| Animations | 150–200 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), 4–5 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é.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'}.`}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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, ' ')}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
205
src/index.css
205
src/index.css
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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> = {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue