Terminal portfolio

A portfolio built like a terminal session: a command palette, ASCII canvas backgrounds including a ray-traced black hole, a hidden console, a SQLite admin and a per-request nonce CSP.

Overview

This portfolio reads like a terminal session: a whoami prompt, a name that decodes out of noise, a tmux-style status bar. I built it with Next.js 16 and React 19 in strict TypeScript, Tailwind CSS v4 and Motion. It exists in French and English, in light and dark themes; the language follows a cookie, then the Accept-Language header. Projects, articles, messages, scores and statistics are stored in SQLite (better-sqlite3) and managed from an admin interface. The site first ran on Supabase: when the project stayed paused past its recovery window, I migrated everything to this local database.

Water, a map and a globe, cell by cell

The hero offers three backgrounds, picked in the admin or drawn at random (lib/hero.ts): water, a world map and a black hole. With the water, a globe or the black hole can appear beside the name. Everything is drawn as glyphs on a 2D canvas.

The water (ascii-wave.tsx) layers five waves and a discrete wave equation that the mouse disturbs. At first, it cost about 31 ms per frame. I precomputed the sine and cosine of each cell's phase to replace the sin() calls with multiplications, moved the simulation to a fixed step and grouped glyphs by opacity. The measured cost dropped to 5 or 6 ms.

The map and the globe share the coastlines in world-land.ts, rasterized once into a 1° land and sea mask. The map uses a Miller projection. The globe, which turns on its own and pivots when you drag it, maps each screen cell back onto the sphere. On both, attacks fly between points and alerts flare where they land, as on a SOC threat map.

The globe over the hero's water, on the light theme: land is shaded by the lighting, and alerts flare as @ where attacks land.

To keep the name readable, maskHeroText finds the cells behind the hero text, where the background fades. All these loops stop when off screen (runWhenVisible).

Gargantua, traced ray by ray

The black hole takes after Interstellar's Gargantua, and every glyph comes from rays that are actually traced. In gargantua-optics.ts, traceRay integrates Schwarzschild null geodesics in Cartesian form, x'' = -1.5 h² x / r⁵, with fourth-order Runge-Kutta and a step that shrinks near the hole. This computation is what makes the far side of the disk arch over and under the shadow.

I dim the disk's emission by a factor of (1 - 1/r), because light loses energy as it climbs out of the gravity well. I add a softened Doppler effect, otherwise one side would be nearly black. The shadow's radius comes from the photon sphere's critical impact parameter, which lets me fill it in black on the light theme.

The black hole behind the name, on the light theme: the far side of the disk arches over and under the shadow, which is filled in black.

gargantua-canvas.ts traces the rays only once per canvas size, a few rows per frame within a 7 ms budget: the picture appears row by row. After that, each frame turns the disk's texture at Keplerian speed, without tracing a single ray again.

A session driven from the keyboard

The command palette opens with Ctrl K or /. It leads to every section, page, project and article, copies the email address, switches theme or language. The search ignores accents and ranks first the labels that start with the typed word. It follows the ARIA combobox pattern.

The palette opened with Ctrl K on the dark theme: sections, pages and projects are sorted into groups, and the selection shows in reverse video.

The palette also opens a console, the same one as on the 404 page. It understands help, ls, cd and whoami, and play starts Typing Rush: technical words fall faster and faster, and the leaderboard is stored in SQLite.

In the console, help lists the commands and play starts Typing Rush: every word that reaches the bottom before it is typed costs a life.

Scroll animations can be turned off in the admin and never hide what is already on screen.

From the CSP nonce to the email regex

The Content Security Policy is built on every request in proxy.ts, with a fresh nonce. In production, only scripts that carry this nonce, and those they load ('strict-dynamic'), run. next.config.ts adds HSTS, nosniff and a Permissions-Policy that denies the camera, microphone, geolocation and payments. A security.txt (RFC 9116) is generated so that its expiry date stays valid.

The contact form, login, statistics and scores go through a per-client rate limiter, backed by a global ceiling. The client is identified by the last hop of X-Forwarded-For, added by the proxy, rather than the first one, which the client controls. A code audit revealed a ReDoS in the email validation: the old expression backtracked quadratically.

// 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,}$/;

The address is also capped at 254 characters before the test.

What I still have to fix

The repository has no automated tests: only the linter and the build guard against regressions. The rate limiter lives in memory and starts from zero on every restart. The leaderboard trusts the score sent by the browser, which is only bounded. The CSP still allows inline styles. Above all, the water taught me to measure before optimizing: while recording its cost, I also saw that my 60 fps limiter skipped every other frame on a 60 Hz display.