Portfolio

Portfolio

Portfolio statique bilingue EN/FR, design monochrome chaud, projets gérés par contenu et interactions animées.

Aperçu

Un portfolio personnel conçu et développé de zéro, en évitant volontairement l’esthétique « template IA » habituelle. Le site est entièrement statique, bilingue (EN par défaut avec miroir sur /fr/), et traite le contenu des projets comme une vraie source de données : chaque projet est un dossier markdown avec un fichier par langue, validé par un schéma Zod au moment du build. Hébergé gratuitement sur Vercel, sans backend — le formulaire de contact envoie directement à Web3Forms.

Le cahier des charges que je me suis imposé : minimaliste, animé, distinctif, et rapide à charger sur un mobile. Pas de dégradés bleu-violet, pas d’arrondis partout, pas d’allure de landing-page marketing. Fond charbon chaud, typographie crème, un seul accent terre cuite. Titres en serif d’affichage et corps en sans-serif discret. Des animations qu’on remarque une fois et qui ne dérangent jamais ensuite.

Architecture

Site Astro 6 entièrement statique, ni SSR ni edge functions. Le pipeline :

  1. Le contenu markdown vit dans src/content/projects/<slug>/{en.md, fr.md} et décrit chaque projet.
  2. La collection de contenu (src/content.config.ts) définit un schéma Zod qui valide chaque champ frontmatter au build — un champ manquant ou une valeur d’enum incorrecte casse le build.
  3. Un petit helper (src/lib/projects.ts) apparie en.md et fr.md par slug de dossier dans un objet PairedProject, l’entrée EN servant de source structurelle canonique et l’entrée FR ne fournissant que les textes traduits.
  4. Les pages sous src/pages/projects/[slug].astro et src/pages/fr/projets/[slug].astro utilisent getStaticPaths() pour énumérer tous les projets au build, produisant un fichier HTML par projet et par langue.
  5. Vercel redéploie automatiquement à chaque push sur main. Ajouter un projet = créer un dossier, commit, push.
Portfolio/
├── src/
│   ├── pages/                       # routes (EN sur /, FR sur /fr/)
│   ├── content/projects/<slug>/     # un dossier par projet
│   ├── content.config.ts            # schémas Zod
│   ├── lib/                         # helpers i18n, projets, motion
│   ├── components/                  # Hero, ProjectCard, Filter, …
│   ├── layouts/BaseLayout.astro
│   ├── scripts/                     # filtre client, reveal
│   └── styles/{tokens,reset,global}.css
├── public/{fonts, photo.svg, cv.pdf}
└── tests/{unit, e2e}/

Design system

Chaque décision visuelle vit dans src/styles/tokens.css sous forme de variables CSS :

  • Couleurs--bg #1c1b18 (charbon chaud), --fg #e8e3d8 (crème), --accent #d4a574 (terre cuite-ocre). Contraste 11,8:1 — WCAG AAA.
  • Typographie — Fraunces (serif variable, optical-size + italique) pour l’affichage, Inter pour le corps, JetBrains Mono pour le code et les micro-labels. Tout est auto-hébergé en .woff2 variable pour limiter le poids.
  • Échelle — modulaire au ratio 1,250 avec clamp() fluide sur les tailles display, h1, h2 pour bien se comporter sur tous les écrans.
  • Espacement — base 4px, exposée de --sp-1 à --sp-32.

Les bordures font 1px, les rayons sont petits (4-8px), rien n’est en forme de pilule. Le design tire vers « éditorial » plutôt que vers « dashboard d’application ».

Modèle de contenu bilingue

Le fichier markdown EN porte tous les champs structurels (date, catégorie, tech, image de couverture, …). Le fichier FR ne porte que title, descriptionShort et son corps markdown traduit. Le schéma rend la plupart des champs optionnels, donc le fichier EN est canonique et le FR n’écrase que ce qui est traduit.

src/lib/projects.ts expose groupByProject(entries) qui apparie les deux langues par slug de dossier et trie par date décroissante. Trois tests vitest couvrent l’appariement, le fallback EN-only et l’ordre de tri.

Les chaînes d’interface vivent dans src/content/i18n/{en,fr}.json sous forme de map clé/valeur plate, accédées via un petit helper t(key, locale) qui retombe sur l’EN si une clé FR manque. Les segments d’URL sont aussi traduits (/projects/fr/projets) via un helper translatePath(pathname, locale), pour que les balises hreflang et le sélecteur de langue arrivent toujours sur la bonne URL localisée.

Filtrage, sans framework

La page index des projets (/projects, /fr/projets) permet un filtrage multi-sélection par tech, catégorie et type. L’implémentation :

  • L’état du filtre est encodé dans la query string (?tech=Go,K8s&type=personal) — les liens sont partageables.
  • Les métadonnées des projets sont embarquées dans des attributs data-* sur chaque élément .cell. Aucun fetch, aucun fichier manifest, aucun framework côté client.
  • Un seul script lit les chips de filtre, exécute une fonction pure matches(item, activeFilters) (testée par quatre cas vitest), bascule cell.hidden, anime les cartes visibles avec un fade-in échelonné via Motion One, puis remplace l’URL via history.replaceState.

L’UI complète du filtre fait environ 3 ko de JavaScript.

Animations

Les animations sont volontaires et discrètes :

  • Hero — révélation mot par mot au premier paint. Chaque mot est entouré d’un span qui découpe, l’élément intérieur commence translaté vers le bas, et Motion One les fait apparaître en cascade sur ~600 ms.
  • Reveals au scroll — chaque section porte data-reveal. Un petit script utilise inView() de Motion pour faire apparaître chaque section une fois lorsqu’elle entre dans la fenêtre.
  • Transitions de page<ClientRouter /> d’Astro (View Transitions API) donne des crossfades fluides entre les routes, dont un effet shared-element sur la couverture du projet entre la carte et la page de détail.
  • Curseur magnétique — un curseur point personnalisé sur les écrans à pointeur fin, qui se rapproche des éléments interactifs et grossit au hover. Mélange avec le fond via mix-blend-mode: difference. Disparaît sur écrans tactiles.

Tout respecte prefers-reduced-motion: reduce — les transforms disparaissent, les transitions d’opacité restent courtes et douces.

Formulaire de contact

Le formulaire envoie directement à Web3Forms depuis le navigateur via fetch(). Le formulaire inclut un honeypot que les vrais utilisateurs ne voient jamais — les bots le remplissent, Web3Forms rejette ces envois. Messages de succès et d’erreur localisés, région aria-live="polite" pour les lecteurs d’écran, et morphing inline entre le bouton d’envoi et un spinner. La clé d’accès publique Web3Forms est exposée via la convention PUBLIC_ des variables d’environnement Astro ; le spam est contenu côté Web3Forms par une liste blanche de domaines.

Tests

  • Vitest couvre les helpers purs : t() et résolution de langue (12 tests), translatePath() et projectsPath() (7 tests), appariement des projets (3 tests), matching de filtre (4 tests). 26 tests au total. Le module virtuel astro:content est stubbé dans un petit mock pour pouvoir tester les helpers hors d’Astro.
  • Playwright couvre en e2e : chargement de la home sans erreurs console en EN et FR, mise à jour de l’URL lors du filtrage, rendu correct des titres sur la page détail, et conservation de la route lors du changement de langue. 10 tests au total.
  • Astro check lance la validation TypeScript sur les fichiers .astro. Biome lint et formate.

Performance

Budget cible : LCP sous 1,5 s, CLS sous 0,05, payload JS sous 30 ko gzippé par route, Lighthouse ≥ 95 sur les quatre catégories. La stack aide — Astro n’envoie aucun JavaScript par défaut, et les scripts présents (animation du hero, filtre, reveal, curseur) ne sont chargés que sur les pages qui en ont besoin. Les images passent par astro:assets (sharp) pour des variantes responsive AVIF/WebP. Les polices sont sous-ensemblées en Latin + Latin-Ext et chargées avec font-display: swap.

Déploiement

Connecté à Vercel via GitHub. main part en production, chaque PR a son URL de preview. Le build est pnpm build, sortie dist/. Une variable d’environnement (PUBLIC_WEB3FORMS_KEY) et une entrée dans la liste blanche de domaines Web3Forms — c’est toute la configuration de déploiement.

Suite

  • Vraies photos (le placeholder actuel est une silhouette SVG).
  • Une petite section blog sur le même modèle de content collection.
  • Thème clair — les tokens sont déjà centralisés, il suffit de remplacer le bloc :root et d’ajouter un toggle.
  • Un layout case-study en MDX, avec transitions shared-element entre les blocs média.