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

20 KiB
Raw Blame History

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.

@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

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 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 :

<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 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

<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-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é.