- 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>
20 KiB
DESIGN_SYSTEM.md — Expria Frontend
Document de référence — Version 2.0 — 24 avril 2026 Source de vérité unique pour l'identité visuelle, les tokens de design et les primitives UI. Toute décision de DA doit être consignée ici avant d'être implémentée. Remplace intégralement la v1.0 (Direction Boréal) du 17 avril 2026.
1. Direction artistique — verrouillée
Nom : Charcoal Positionnement : outil pro sérieux, premium sans scolaire, immersif sans austère. Référence mentale : Linear, Notion Desktop, Primo TCF — sidebar sombre permanente, contenu aéré.
Parti pris fondateurs
| Principe | Décision |
|---|---|
| Mode par défaut | Dark (charcoal chaud #111111) |
| Mode clair | Activé — fond gris froid #F3F4F6, cartes blanches |
| Détection thème | prefers-color-scheme au chargement, toggle manuel, persistance localStorage |
| Sidebar | Navy #0C1528 permanent — identique dark et light. C'est l'ancre visuelle de la marque. |
| Fond principal (dark) | #111111 avec deux halos bleus subtils (radial-gradient à 4–5% opacité) |
| Fond principal (light) | #F3F4F6 avec deux halos bleus très discrets (2–3% opacité) |
| Bleu de marque | #1B4FD8 sacro-saint — invariant entre les modes |
| Bleu texte accent | #7da4f0 en dark, #1B4FD8 en light (lisibilité adaptée au fond) |
| Surfaces (dark) | Semi-transparentes rgba(255,255,255,0.035) — jamais de gris opaque |
| Surfaces (light) | Blanc pur #FFFFFF avec ombre subtile shadow-card |
| Angles | Rayons généreux mais retenus : 8 / 12 / 16 px |
| Ombres (dark) | Aucune — la bordure 1px et la transparence suffisent |
| Ombres (light) | Minimales. shadow-card subtile sur les surfaces élevées |
| Animations | 150–200 ms, ease-out, respect de prefers-reduced-motion |
| Icônes | lucide-react pour les icônes standard. SVG inline dans shared/ui/icons/ pour les icônes custom |
| Typographie | Plus Jakarta Sans exclusivement (via Google Fonts, fallback système) |
| Approche responsive | Desktop-first pour l'app (usage quotidien sur ordinateur). Mobile-first uniquement pour le funnel d'acquisition (landing, pricing, inscription) |
Ce qu'on refuse explicitement
- Gradients criards — le seul acceptable est le dégradé
accent → accent-darksur le CTA primaire. - Glassmorphism ou
backdrop-filtergé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.
@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
- Aucune valeur hexadécimale en dur dans les composants. Toute couleur passe par un token
var(--color-*). - Nommage sémantique obligatoire. On écrit
bg-[var(--color-surface)], pasbg-whitenibg-gray-50. - Ne jamais utiliser
bg-white,bg-gray-*,text-gray-*— ces classes Tailwind cassent le dual-theme. - Si un cas d'usage exige une teinte hors charte, le documenter ici avant de l'ajouter. Pas de token orphelin.
- La sidebar utilise ses propres tokens
--color-sidebar-*— ils ne changent jamais entre les modes. - Le fond principal utilise toujours les deux
radial-gradientsubtils — jamais un aplat uni.
3. Gestion du thème — src/shared/lib/theme.ts
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 :
<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 Sanschargée via Google Fonts CDN avec fallback système.- Les chiffres français utilisent la virgule comme séparateur décimal (
7,5, jamais7.5).
5. Primitives UI
À créer dans src/shared/ui/ en FSD, une primitive par dossier (button/, card/, etc.) avec index.ts pour l'export.
Inventaire
| Composant | Variants | Usage |
|---|---|---|
Button |
primary / secondary / ghost / upgrade |
CTA, actions tertiaires |
Card |
default / raised / interactive |
Cadre métriques, item simulation |
MetricCard |
default / hero |
Bloc NCLC, compteur simulations |
ProgressBar |
default |
Progression vers NCLC 9 |
Badge |
plan / nclc / brand / success / warning / danger |
Plan, niveau, chips sémantiques |
Sidebar |
— | Nav desktop (≥ 1024px), navy permanent |
BottomNav |
— | Nav mobile (< 1024px), 4–5 items max |
ThemeToggle |
— | Bouton soleil/lune dans le footer sidebar |
PageHeader |
— | Greeting + plan badge |
SectionHeader |
— | Titre de section + action optionnelle |
Patterns de référence — copier, ne pas réinterpréter
Sidebar NavItem actif :
<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 :
<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 :
<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 :
<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 :
<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
classNameen 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-reactet passées comme composant, jamais par nom de string.
6. Layout principal — AppLayout
<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.
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-visibleavec--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
BottomNavmobile respecte la hauteur minimale tap target : 44×44 px par item. - Le
ThemeTogglea unaria-labeldynamique : "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
- Ne jamais utiliser de couleurs en dur — toujours
var(--color-*). - Ne jamais utiliser
bg-white,bg-gray-*,text-gray-*— utiliser les tokens sémantiques. - La sidebar est toujours navy — ses tokens ne changent jamais entre dark et light.
- Le fond principal utilise deux
radial-gradientsubtils — jamais un aplat uni. - Typographie : Plus Jakarta Sans uniquement — jamais Inter, Roboto, ou system seul.
- Les cartes utilisent
var(--color-surface)+var(--color-border)— en dark c'est semi-transparent, en light c'est blanc avec shadow. - Les hover states utilisent
var(--color-surface-hover)— jamais dergbaen dur. - Copier les patterns de la section 5 — ne pas réinterpréter, ne pas "améliorer".
- 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é.