expria-frontend/docs/DESIGN_SYSTEM.md
Hermann_Kitio b68f160bce 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>
2026-04-24 23:09:15 +03:00

507 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# DESIGN_SYSTEM.md — Expria Frontend
> **Document de référence — Version 2.0 — 24 avril 2026**
> Source de vérité unique pour l'identité visuelle, les tokens de design et les primitives UI.
> Toute décision de DA doit être consignée ici avant d'être implémentée.
> **Remplace intégralement la v1.0 (Direction Boréal) du 17 avril 2026.**
---
## 1. Direction artistique — verrouillée
**Nom :** Charcoal
**Positionnement :** outil pro sérieux, premium sans scolaire, immersif sans austère.
**Référence mentale :** Linear, Notion Desktop, Primo TCF — sidebar sombre permanente, contenu aéré.
### Parti pris fondateurs
| Principe | Décision |
|---|---|
| Mode par défaut | **Dark** (charcoal chaud `#111111`) |
| Mode clair | Activé — fond gris froid `#F3F4F6`, cartes blanches |
| Détection thème | `prefers-color-scheme` au chargement, toggle manuel, persistance `localStorage` |
| Sidebar | **Navy `#0C1528` permanent** — identique dark et light. C'est l'ancre visuelle de la marque. |
| Fond principal (dark) | `#111111` avec deux halos bleus subtils (`radial-gradient` à 45% opacité) |
| Fond principal (light) | `#F3F4F6` avec deux halos bleus très discrets (23% opacité) |
| Bleu de marque | `#1B4FD8` **sacro-saint** — invariant entre les modes |
| Bleu texte accent | `#7da4f0` en dark, `#1B4FD8` en light (lisibilité adaptée au fond) |
| Surfaces (dark) | Semi-transparentes `rgba(255,255,255,0.035)` — jamais de gris opaque |
| Surfaces (light) | Blanc pur `#FFFFFF` avec ombre subtile `shadow-card` |
| Angles | Rayons généreux mais retenus : 8 / 12 / 16 px |
| Ombres (dark) | Aucune — la bordure 1px et la transparence suffisent |
| Ombres (light) | Minimales. `shadow-card` subtile sur les surfaces élevées |
| Animations | 150200 ms, `ease-out`, respect de `prefers-reduced-motion` |
| Icônes | `lucide-react` pour les icônes standard. SVG inline dans `shared/ui/icons/` pour les icônes custom |
| Typographie | Plus Jakarta Sans exclusivement (via Google Fonts, fallback système) |
| Approche responsive | **Desktop-first** pour l'app (usage quotidien sur ordinateur). Mobile-first uniquement pour le funnel d'acquisition (landing, pricing, inscription) |
### Ce qu'on refuse explicitement
- Gradients criards — le seul acceptable est le dégradé `accent → accent-dark` sur le CTA primaire.
- Glassmorphism ou `backdrop-filter` généralisé — réservé à la topbar et à la bottom nav mobile.
- Emojis dans les éléments interactifs ou les labels fonctionnels.
- Ombres lourdes, "drop shadows" style Material Design 2.
- Plus de 3 niveaux d'élévation visuelle (fond → surface → surface-raised → modal).
- Toute police autre que Plus Jakarta Sans.
- Les motifs SaaS génériques : illustrations 3D, dégradés violet-rose, glass blobs.
- Fond blanc pur (`#FFFFFF`) en tant que fond de page — toujours `--color-canvas`.
- Couleurs hexadécimales en dur dans les composants — toujours via token.
---
## 2. Tokens — `src/index.css`
Remplacer intégralement le contenu actuel. Tailwind 4 lit automatiquement les tokens déclarés dans `@theme`. Les deux thèmes sont actifs dès maintenant.
```css
@import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@theme {
/* ══════════════════════════════════════════════════════════════════════
INVARIANTS — identiques dark et light
══════════════════════════════════════════════════════════════════════ */
/* ── Sidebar (navy permanent) ── */
--color-sidebar-bg: #0C1528;
--color-sidebar-border: rgba(255, 255, 255, 0.07);
--color-sidebar-text: rgba(255, 255, 255, 0.6);
--color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
--color-sidebar-text-active: #ffffff;
--color-sidebar-nav-hover: rgba(255, 255, 255, 0.07);
--color-sidebar-nav-active: rgba(255, 255, 255, 0.1);
--color-sidebar-section-label: rgba(255, 255, 255, 0.3);
/* ── Brand ── */
--color-brand: #1B4FD8;
--color-brand-hover: #1744B8;
--color-brand-active: #13379C;
--color-brand-dark: #1740b0;
--color-brand-ink: #FFFFFF;
/* ── Semantic ── */
--color-warning: #f59e0b;
--color-warning-soft: rgba(245, 158, 11, 0.12);
--color-danger: #ef4444;
--color-danger-soft: rgba(239, 68, 68, 0.12);
/* ── Typography ── */
--font-sans: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 32px;
--text-display: 40px;
/* ── Rayons ── */
--radius-xs: 6px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-pill: 999px;
/* ── Focus ── */
--shadow-focus: 0 0 0 3px rgba(27, 79, 216, 0.18);
/* ══════════════════════════════════════════════════════════════════════
DARK MODE (default) — tokens de contenu
══════════════════════════════════════════════════════════════════════ */
--color-canvas: #111111;
--color-surface: rgba(255, 255, 255, 0.035);
--color-surface-hover: rgba(255, 255, 255, 0.055);
--color-surface-solid: #1e1e1e;
--color-surface-raised: #222222;
--color-border: rgba(255, 255, 255, 0.06);
--color-border-strong: rgba(255, 255, 255, 0.12);
--color-ink-primary: #e5e5e5;
--color-ink-secondary: rgba(255, 255, 255, 0.55);
--color-ink-tertiary: rgba(255, 255, 255, 0.3);
--color-ink-inverse: #111111;
--color-brand-soft: rgba(27, 79, 216, 0.1);
--color-brand-text: #7da4f0;
--color-success: #4ade80;
--color-success-soft: rgba(74, 222, 128, 0.12);
--color-topbar-bg: rgba(17, 17, 17, 0.88);
--color-gradient-a: rgba(27, 79, 216, 0.05);
--color-gradient-b: rgba(27, 79, 216, 0.03);
--shadow-card: none;
--shadow-raised: none;
}
/* ══════════════════════════════════════════════════════════════════════
LIGHT MODE — override .light sur <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 {
background: var(--color-canvas);
color: var(--color-ink-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-variant-numeric: tabular-nums;
}
*:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-xs);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0ms !important;
transition-duration: 0ms !important;
}
}
```
### Règles d'usage des tokens
1. **Aucune valeur hexadécimale en dur** dans les composants. Toute couleur passe par un token `var(--color-*)`.
2. **Nommage sémantique obligatoire.** On écrit `bg-[var(--color-surface)]`, pas `bg-white` ni `bg-gray-50`.
3. **Ne jamais utiliser** `bg-white`, `bg-gray-*`, `text-gray-*` — ces classes Tailwind cassent le dual-theme.
4. Si un cas d'usage exige une teinte hors charte, **le documenter ici avant de l'ajouter**. Pas de token orphelin.
5. La sidebar utilise ses propres tokens `--color-sidebar-*` — ils ne changent **jamais** entre les modes.
6. Le fond principal utilise toujours les deux `radial-gradient` subtils — jamais un aplat uni.
---
## 3. Gestion du thème — `src/shared/lib/theme.ts`
```typescript
export type Theme = 'dark' | 'light';
export function getInitialTheme(): Theme {
const stored = localStorage.getItem('expria-theme') as Theme | null;
if (stored === 'dark' || stored === 'light') return stored;
if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
return 'dark';
}
export function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle('light', theme === 'light');
}
export function persistTheme(theme: Theme): void {
localStorage.setItem('expria-theme', theme);
}
```
**Script anti-FOUC** — à insérer inline dans `<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 |
|---|---|---|---|---|---|
| Display (NCLC hero) | 40px | 800 | -0.02em | 1.0 | `text-display` |
| H1 page | 32px | 700 | -0.02em | 1.1 | `text-3xl` |
| H2 section | 24px | 700 | -0.015em | 1.2 | `text-2xl` |
| H3 card title | 20px | 700 | -0.01em | 1.3 | `text-xl` |
| Lead / intro | 17px | 500 | -0.005em | 1.5 | `text-lg` |
| Body | 14px | 400 | 0 | 1.6 | `text-base` |
| Body renforcé | 15px | 500 | 0 | 1.55 | `text-md` |
| Small / meta | 13px | 500 | 0 | 1.5 | `text-sm` |
| Eyebrow / label | 11px | 600 | 0.1em (uppercase) | 1.4 | `text-xs` |
**Règles :**
- Tout nombre métier (score 16/20, NCLC 7,5, compteur 3/5) est rendu en `font-variant-numeric: tabular-nums`.
- `Plus Jakarta Sans` chargée via Google Fonts CDN avec fallback système.
- Les chiffres français utilisent la **virgule** comme séparateur décimal (`7,5`, jamais `7.5`).
---
## 5. Primitives UI
À créer dans `src/shared/ui/` en FSD, une primitive par dossier (`button/`, `card/`, etc.) avec `index.ts` pour l'export.
### Inventaire
| Composant | Variants | Usage |
|---|---|---|
| `Button` | `primary` / `secondary` / `ghost` / `upgrade` | CTA, actions tertiaires |
| `Card` | `default` / `raised` / `interactive` | Cadre métriques, item simulation |
| `MetricCard` | `default` / `hero` | Bloc NCLC, compteur simulations |
| `ProgressBar` | `default` | Progression vers NCLC 9 |
| `Badge` | `plan` / `nclc` / `brand` / `success` / `warning` / `danger` | Plan, niveau, chips sémantiques |
| `Sidebar` | — | Nav desktop (≥ 1024px), navy permanent |
| `BottomNav` | — | Nav mobile (< 1024px), 45 items max |
| `ThemeToggle` | | Bouton soleil/lune dans le footer sidebar |
| `PageHeader` | | Greeting + plan badge |
| `SectionHeader` | | Titre de section + action optionnelle |
### Patterns de référence — copier, ne pas réinterpréter
**Sidebar NavItem actif :**
```tsx
<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
- Chaque primitive **accepte `className`** en plus de ses props typées, pour overrides ponctuels.
- Chaque primitive **expose ses props via un type exporté** (`ButtonProps`, `CardProps`, etc.).
- Aucune primitive ne contient de logique métier ou d'appel API. Elles reçoivent tout par props.
- Les icônes sont importées de `lucide-react` et passées comme composant, jamais par nom de string.
---
## 6. Layout principal — `AppLayout`
```tsx
<div className="flex min-h-screen">
<Sidebar /> {/* fixed, w-[230px], bg sidebar navy */}
<main
className="flex-1 ml-[230px] min-h-screen p-9"
style={{
background: `
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)
`,
}}
>
<div className="max-w-[1100px] mx-auto">
{children}
</div>
</main>
</div>
```
### Breakpoints
| Breakpoint | Comportement |
|---|---|
| `< 1024px` | Sidebar masquée, `BottomNav` fixe en bas, padding horizontal 20px |
| `≥ 1024px` | Sidebar 230px + contenu centré 1100px max, padding 36px |
| `≥ 1440px` | Contenu centré 1100px max (pas d'élargissement) |
### Densité verticale
- Padding vertical section : 24px mobile, 32px desktop.
- Gap inter-cards : 12px mobile, 16px desktop.
- Marge sous `PageHeader` : 20px mobile, 28px desktop.
---
## 7. Données mock
Avant branchement API, fournir les données via `src/shared/api/mock/dashboard.ts`. Données crédibles, françaises, alignées sur l'audience réelle.
```typescript
export const mockDashboard = {
user: {
firstName: 'Yacine',
plan: 'decouverte' as const,
planLabel: 'Plan Découverte',
},
metrics: {
nclcEstimated: 7.5,
nclcTarget: 9,
simulationsUsed: 2,
simulationsQuota: 5,
lastScore: { value: 16, max: 20, type: 'ecrit' as const },
},
recentSimulations: [
{ id: 's-001', type: 'ecrit', relativeDate: 'il y a 2 jours', score: 16, max: 20, nclc: 8 },
{ id: 's-002', type: 'oral', relativeDate: 'il y a 5 jours', score: 14, max: 20, nclc: 7 },
{ id: 's-003', type: 'ecrit', relativeDate: 'il y a 1 semaine', score: 15, max: 20, nclc: 7 },
],
nextStep: {
title: 'Cible une simulation orale cette semaine',
body: 'Ton écrit est solide (NCLC 8). L\'oral reste à consolider pour sécuriser ton NCLC 9.',
action: { label: 'Démarrer Expression Orale', to: '/simulation/orale' },
},
} as const;
```
**Règles contenu :**
- Aucun "Lorem ipsum", aucune date absolue relatif uniquement.
- Les prénoms mocks reflètent l'audience : Yacine, Aminata, Kenza, Bilal, Fatou, Kévin.
- Les scores suivent une progression crédible (pas de 20/20 ni de 5/20).
---
## 8. Accessibilité — plancher
- Contraste minimum **WCAG AA** sur tous les couples texte/fond (vérifié dark ET light).
- Tous les éléments interactifs ont un `:focus-visible` avec `--shadow-focus` (halo bleu 3px).
- Les icônes décoratives portent `aria-hidden="true"`.
- Les icônes fonctionnelles (sans label visible) portent `aria-label`.
- Les landmarks sémantiques : `<header>`, `<nav>`, `<main>`, `<section>`.
- Le `BottomNav` mobile respecte la hauteur minimale tap target : **44×44 px** par item.
- Le `ThemeToggle` a un `aria-label` dynamique : "Passer en mode clair" / "Passer en mode sombre".
---
## 9. Dépendances externes
| Dépendance | Statut | Justification |
|---|---|---|
| `lucide-react` | Autorisée | Icônes cohérentes, tree-shakeable, aucun CSS importé |
| `clsx` + `tailwind-merge` | Autorisées | Utilitaire `cn()` pour merge de classes |
| `shadcn/ui` | Interdit | Overrides Tailwind trop complexes pour le volume actuel |
| `radix-ui` | 🔒 Reporté | Utilisable si besoin justifié par ADR (Dialog, Popover) |
---
## 10. Règles impératives pour Claude Code
1. **Ne jamais utiliser de couleurs en dur** toujours `var(--color-*)`.
2. **Ne jamais utiliser `bg-white`, `bg-gray-*`, `text-gray-*`** utiliser les tokens sémantiques.
3. **La sidebar est toujours navy** ses tokens ne changent jamais entre dark et light.
4. **Le fond principal utilise deux `radial-gradient` subtils** jamais un aplat uni.
5. **Typographie : Plus Jakarta Sans uniquement** jamais Inter, Roboto, ou system seul.
6. **Les cartes** utilisent `var(--color-surface)` + `var(--color-border)` en dark c'est semi-transparent, en light c'est blanc avec shadow.
7. **Les hover states** utilisent `var(--color-surface-hover)` jamais de `rgba` en dur.
8. **Copier les patterns de la section 5** ne pas réinterpréter, ne pas "améliorer".
9. **Tester visuellement en dark ET en light** avant de valider un composant.
---
## 11. Journal des décisions DA
| Date | Décision | Contexte |
|---|---|---|
| 2026-04-17 | Direction A (Boréal) validée comme base | 5 directions explorées, A choisie |
| 2026-04-17 | Fond `#F4F2EC`, light-only, dark reporté Sprint 2+ | Première itération |
| 2026-04-24 | **Direction Charcoal adoptée — remplace Boréal** | Analyse concurrentielle Primo TCF, 4 directions testées (Deep Navy, Royal Blue, Gradient Mesh, Charcoal), Charcoal retenu avec touch de Gradient Mesh |
| 2026-04-24 | Sidebar navy `#0C1528` permanent dark+light | Cohérence Slack/Discord/Linear, ancre visuelle de marque |
| 2026-04-24 | Dark mode activé par défaut (`#111111`) | Usage quotidien desktop, cible intérieur, cohérent avec le positionnement premium |
| 2026-04-24 | Light mode activé avec fond `#F3F4F6` | Sidebar navy maintenue, topbar claire, cartes blanches avec shadow |
| 2026-04-24 | `prefers-color-scheme` respecté au chargement | Fallback dark si pas de préférence système |
| 2026-04-24 | Desktop-first pour l'app | Analytics V1 : 60% desktop après 1 semaine d'usage. Mobile = acquisition (Facebook/WhatsApp), desktop = usage quotidien |
| 2026-04-24 | Plus Jakarta Sans via Google Fonts CDN | Chargement explicite, pas de fallback-only |
| 2026-04-24 | `lucide-react` autorisée | Remplace les SVG inline manuels |
| 2026-04-24 | Tokens dual-theme actifs dès maintenant | Plus de dark reporté les deux modes sont livrés ensemble |
---
## 12. Hors périmètre actuel
Éléments explicitement **reportés** :
- Thème haut-contraste (WCAG AAA).
- Internationalisation (i18n) monolingue FR.
- Animations avancées (scroll-linked, shared element transitions).
- Illustrations personnalisées / iconographie signature.
- Self-hosting de la font Plus Jakarta Sans.
- Troisième thème (ex: "mode examen" épuré).
Chacun de ces points mérite un ADR dédié quand il sera abordé.