Portfolio
Bilingual EN/FR static portfolio with warm-monochrome design, content-driven projects, and motion-rich interactions.
Overview
A personal portfolio designed and built from scratch, intentionally avoiding the generic AI-template aesthetic. The site is fully static, bilingual (EN default with /fr/ mirror), and treats project content as data: each project lives as a markdown folder with one file per locale, validated by a Zod schema at build time. Hosted free on Vercel with no backend — the contact form posts directly to Web3Forms.
The brief I gave myself: minimalist, animation-rich, distinct, and load-fast on a phone. No blue-purple gradients, no rounded-everything, no marketing-page aesthetic. Warm charcoal background, cream type, a single terracotta accent. Serif display headlines paired with a quiet sans-serif body. Motion that you notice once and then never get in the way of.
Architecture
Pure static Astro 6 site, no SSR, no edge functions. The pipeline:
- Markdown content in
src/content/projects/<slug>/{en.md, fr.md}describes every project. - The content collection (
src/content.config.ts) defines a Zod schema that validates every frontmatter field at build time — a missing field or a typo’d category enum value breaks the build. - A small helper (
src/lib/projects.ts) pairsen.mdwithfr.mdby folder slug into aPairedProjectobject, with the EN entry as the canonical structural source and the FR entry providing only translated text. - Pages under
src/pages/projects/[slug].astroandsrc/pages/fr/projets/[slug].astrousegetStaticPaths()to enumerate every project at build time, producing one HTML file per project per locale. - Vercel auto-deploys on push to
main. Adding a project = create a folder, commit, push.
Portfolio/
├── src/
│ ├── pages/ # routes (EN at /, FR at /fr/)
│ ├── content/projects/<slug>/ # one folder per project
│ ├── content.config.ts # Zod schemas
│ ├── lib/ # i18n, projects, motion helpers
│ ├── components/ # Hero, ProjectCard, Filter, …
│ ├── layouts/BaseLayout.astro
│ ├── scripts/ # client-side filter, reveal
│ └── styles/{tokens,reset,global}.css
├── public/{fonts, photo.svg, cv.pdf}
└── tests/{unit, e2e}/
Design system
Every visual decision lives in src/styles/tokens.css as CSS custom properties:
- Colours —
--bg #1c1b18(warm charcoal),--fg #e8e3d8(cream),--accent #d4a574(terracotta-ochre). Contrast ratio 11.8:1 — WCAG AAA. - Type — Fraunces (variable serif, optical-size + italic) for display, Inter for body, JetBrains Mono for code and micro-labels. All self-hosted as variable
.woff2to keep payload small. - Scale — modular 1.250 with fluid clamp() for the display and h1/h2 sizes so things behave on every screen.
- Spacing — 4px base unit, exposed as
--sp-1through--sp-32.
Borders are 1px, radii are tiny (4-8px), nothing is pill-shaped. The design pushes towards “editorial” rather than “app dashboard”.
Bilingual content model
The EN markdown file carries every structural field (date, category, tech, cover image, …). The FR file carries only title and descriptionShort plus its translated markdown body. The schema relaxes most fields to optional, so the EN file is canonical and the FR file overrides only what’s translated.
src/lib/projects.ts exposes groupByProject(entries) which pairs the two locales by folder slug and sorts by date desc. Three vitest unit tests cover the pairing, the EN-only fallback, and the sort order.
i18n strings live in src/content/i18n/{en,fr}.json as a flat key/value map, accessed via a tiny t(key, locale) helper that falls back to EN if a FR key is missing. Route segments are translated too (/projects ↔ /fr/projets) via a translatePath(pathname, locale) helper, so hreflang alternates and the language toggle always land on the correct localized URL.
Filtering, without a framework
The projects index page (/projects, /fr/projets) supports multi-select filtering by tech, category, and type. The implementation:
- The filter state is encoded in the URL query string (
?tech=Go,K8s&type=personal) so links are shareable. - Project metadata is embedded in
data-*attributes on each.cellelement. No fetch, no manifest file, no client framework. - A single client script reads filter chips, runs a pure
matches(item, activeFilters)function (unit-tested in vitest, four cases), togglescell.hidden, animates the visible cards with a staggered fade-in via Motion One, and replaces the URL withhistory.replaceState.
The whole filter UI ships ~3 kB of JavaScript.
Motion
Animation is intentional and small:
- Hero — a word-by-word mask reveal on first paint. Each word is wrapped in a clipping span, the inner element starts translated down, and Motion One staggers them in over ~600 ms.
- Scroll reveals — every section carries
data-reveal. A small script uses Motion’sinView()to fade and slide each in once as it enters the viewport. - Page transitions — Astro’s
<ClientRouter />(View Transitions API) gives smooth crossfades between routes, including a shared-element cue on the project cover from card to detail page. - Magnetic cursor — a custom dot cursor on pointer-fine devices that lerps toward interactive targets and scales up on hover. Mixes with the background via
mix-blend-mode: difference. Self-removes on touch devices.
Everything respects prefers-reduced-motion: reduce — transforms drop out, opacity transitions stay short and gentle.
Contact form
The contact form posts directly to Web3Forms from the browser using fetch(). The form includes a honeypot input that real users never see — bots fill it, Web3Forms rejects those submissions. Localized success and error messages, an aria-live="polite" status region for screen readers, and an inline morph between the send button and a spinner. The Web3Forms public access key is exposed via Astro’s PUBLIC_ env var convention; spam is contained at the Web3Forms dashboard via domain allowlist.
Testing
- Vitest unit tests cover the pure helpers:
t()and locale resolution (12 tests),translatePath()andprojectsPath()(7 tests), project pairing (3 tests), filter matching (4 tests). 26 tests total. Theastro:contentvirtual module is stubbed in a tiny mock so the project helpers can be exercised outside Astro. - Playwright end-to-end suite covers: home loads with no console errors in EN and FR, filter toggles update the URL, project detail pages render the correct title, language toggle preserves the route. 10 tests total.
- Astro check runs TypeScript validation across
.astrofiles. Biome lints and formats.
What I’d do next
- Light theme
- Complete missing projetcs
- New animations / elements