Portfolio terminal

Un portfolio pensé comme une session de terminal : palette de commandes, fonds ASCII sur canvas dont un trou noir en lancer de rayons, console cachée, admin SQLite et CSP à nonce par requête.

Présentation

Ce portfolio se parcourt comme une session de terminal : une invite whoami, un nom qui se décode depuis du bruit, une barre de statut façon tmux. Je l'ai construit avec Next.js 16 et React 19 en TypeScript strict, Tailwind CSS v4 et Motion. Il existe en français et en anglais, en thème clair et sombre ; la langue suit un cookie, puis l'en-tête Accept-Language. Projets, articles, messages, scores et statistiques sont stockés dans SQLite (better-sqlite3) et se gèrent depuis une interface d'administration. Le site reposait d'abord sur Supabase : quand le projet est resté en pause au-delà de son délai de restauration, j'ai tout migré vers cette base locale.

L'eau, la carte et le globe, cellule par cellule

Le hero propose trois fonds, choisis dans l'admin ou tirés au hasard (lib/hero.ts) : de l'eau, une carte du monde et un trou noir. Avec l'eau, un globe ou le trou noir peut s'afficher à côté du nom. Tout est dessiné en glyphes sur un canvas 2D.

L'eau (ascii-wave.tsx) superpose cinq ondes et une équation d'onde discrète que la souris perturbe. Elle coûtait d'abord environ 31 ms par image. J'ai précalculé sinus et cosinus de la phase de chaque cellule pour remplacer les sin() par des multiplications, fait avancer la simulation à pas fixe et regroupé les glyphes par opacité. Le coût mesuré est descendu à 5 ou 6 ms.

La carte et le globe partagent les côtes de world-land.ts, rastérisées une fois en un masque terre et mer de 1°. La carte utilise une projection de Miller. Le globe, qui tourne seul et pivote quand on le fait glisser, ramène chaque cellule de l'écran sur la sphère. Sur les deux, des attaques volent entre des points et des alertes s'allument à l'arrivée, comme sur une carte de menaces de SOC.

Le globe sur l'eau du hero, en thème clair : les terres sont ombrées selon l'éclairage, et des alertes s'allument en @ là où les attaques arrivent.

Pour garder le nom lisible, maskHeroText repère les cellules derrière le texte du hero, où le fond s'estompe. Toutes ces boucles s'arrêtent hors écran (runWhenVisible).

Gargantua, tracé rayon par rayon

Le trou noir reprend le Gargantua d'Interstellar, et chaque glyphe vient de rayons réellement tracés. Dans gargantua-optics.ts, traceRay intègre les géodésiques nulles de Schwarzschild sous forme cartésienne, x'' = -1.5 h² x / r⁵, par Runge-Kutta d'ordre 4, avec un pas qui raccourcit près du trou. C'est ce calcul qui fait passer la face arrière du disque au-dessus et au-dessous de l'ombre.

J'atténue l'émission du disque d'un facteur (1 - 1/r), car la lumière perd de l'énergie en sortant du puits gravitationnel. J'y ajoute un effet Doppler adouci, sinon un côté serait presque noir. Le rayon de l'ombre vient du paramètre d'impact critique de la sphère de photons, ce qui permet de la remplir en noir sur le thème clair.

Le trou noir derrière le nom, en thème clair : la face arrière du disque passe au-dessus et au-dessous de l'ombre, remplie en noir.

gargantua-canvas.ts ne trace les rayons qu'une fois par taille de canvas, quelques rangées par image dans un budget de 7 ms : le dessin apparaît ligne par ligne. Ensuite, chaque image fait tourner la texture du disque à vitesse képlérienne, sans retracer de rayon.

Une session qu'on pilote au clavier

La palette de commandes s'ouvre avec Ctrl K ou /. Elle mène à chaque section, page, projet et article, copie l'e-mail, change de thème ou de langue. La recherche ignore les accents et classe d'abord les libellés qui commencent par le mot tapé. Elle suit le motif ARIA combobox.

La palette ouverte avec Ctrl K en thème sombre : sections, pages et projets sont rangés par groupe, et la sélection s'affiche en vidéo inverse.

La palette ouvre aussi une console, la même que sur la page 404. Elle comprend help, ls, cd et whoami, et play lance Typing Rush : des mots techniques tombent de plus en plus vite, et le classement est stocké dans SQLite.

Dans la console, help liste les commandes et play lance Typing Rush : chaque mot qui atteint le bas avant d'être tapé coûte une vie.

Les animations au défilement se coupent dans l'admin et ne cachent jamais ce qui est déjà à l'écran.

Du nonce CSP à la regex de l'e-mail

La Content Security Policy est construite à chaque requête dans proxy.ts, avec un nonce neuf. En production, seuls les scripts qui portent ce nonce, et ceux qu'ils chargent ('strict-dynamic'), s'exécutent. next.config.ts ajoute HSTS, nosniff et une Permissions-Policy qui refuse caméra, micro, géolocalisation et paiement. Un security.txt (RFC 9116) est généré pour que sa date d'expiration reste valide.

Le contact, la connexion, les statistiques et les scores passent par un limiteur de débit par client, doublé d'un plafond global. Le client est identifié par le dernier saut de X-Forwarded-For, ajouté par le proxy, et non par le premier, qu'il contrôle. Un audit de code a révélé un ReDoS dans la validation de l'e-mail : l'ancienne expression revenait en arrière de façon quadratique.

// Before: [^\s@] also matches dots, so every dot is a possible split
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
 
// After: linear-time, domain labels cannot contain dots
export const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)*\.[^\s@.]{2,}$/;

L'adresse est aussi plafonnée à 254 caractères avant le test.

Ce qu'il me reste à corriger

Le dépôt n'a aucun test automatisé : seuls le lint et le build protègent des régressions. Le limiteur de débit vit en mémoire et repart de zéro à chaque redémarrage. Le classement fait confiance au score envoyé par le navigateur, simplement borné. La CSP autorise encore les styles inline. L'eau m'a surtout appris à mesurer avant d'optimiser : en relevant son coût, j'ai aussi vu que mon limiteur à 60 images par seconde sautait une image sur deux sur un écran à 60 Hz.