Design system

How this site is built

Tokens and component contracts, generated from the code. The same contracts feed llms.txt, system.md and the tests that fail when a page breaks them.

Tokens

Every colour, pixel value and duration lives in styles/tokens.css; a test fails if one appears anywhere else.

Colour

Light value, then dark value. The chips follow your system theme.

Contrast

Every colour pair the contracts rely on, checked in both themes by the tests.
Text / UIOnUsed forMinimumLightDark
--color-accent--color-bgLink: link text4.50:16.44:18.71:1
--color-focus--color-bgLink: focus ring; Theme toggle: focus ring3.00:14.83:19.11:1
--color-focus--color-surfaceLink: focus ring on surfaces3.00:14.48:18.39:1
--color-bg--color-fgButton: label on the inverted pill4.50:116.60:115.31:1
--color-bg--color-fgSocial links: logo on the inverted disc3.00:116.60:115.31:1
--color-fg--color-surfaceCard: title and summary; CTA banner: banner heading; Design system page: examples4.50:115.43:114.10:1
--color-muted--color-surfaceCard: eyebrow and impact labels; CTA banner: banner text; Muted: secondary text on surfaces4.50:16.36:16.98:1
--color-accent--color-surfaceCard contract: open pill; construction lines; Design system page: links in examples4.50:15.98:18.02:1
--color-accent-fg--color-accentCard contract: part labels; Skip link: label4.50:16.44:18.71:1
--color-accent--color-bgCard contract: construction lines on the cover3.00:16.44:18.71:1
--color-muted--color-bgCard contract: panel sources line; Page: secondary text; Writing: secondary text; Design system page: secondary text; Résumé: secondary text; Muted: secondary text4.50:16.84:17.58:1
--color-fg--color-raisedProse: callouts4.50:117.33:112.66:1
--color-accent--color-raisedProse: links in callouts4.50:16.72:17.20:1
--color-fg--color-bgPage: body text; Writing: body text; Design system page: body text; Résumé: body text4.50:116.60:115.31:1

Type scale

Space

Everything else

Type, layout, shape and motion. Durations drop to 0 under prefers-reduced-motion.
TokenValue
--font-sanssystem-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif
--font-monoui-monospace, "SF Mono", Menlo, Consolas, monospace
--leading-tight1.15
--leading-normal1.6
--leading-print1.45
--weight-normal400
--weight-medium550
--weight-bold700
--width-text40rem
--width-page64rem
--gutterclamp(1rem, 4vw, 2rem)
--radius-sm0.375rem
--radius-md0.75rem
--radius-pill999px
--border-width1px
--border-width-thick2px
--focus-width3px
--focus-offset2px
--duration-fast120ms
--duration-normal220ms
--duration-slow480ms
--ease-outcubic-bezier(0.2, 0.8, 0.2, 1)
--lift-sm-1px
--lift-md-2px
--lift-lg-0.5rem
--print-root-size9pt
--print-margin12mm

Components

Button

The one primary call to action of a page. A pill with inverted colours, on a link or a button.

Selectors
.button
States
  • default
  • hover (lifts by --lift-sm)
  • active
  • focus-visible
Accessibility
  • An <a> when it navigates; a <button type="button"> only for an in-page action (the résumé's “Save as PDF”).
  • The lift is a transform on --duration-fast, which is 0 under reduced motion.
Do
  • Keep one per block, for its main action: the closing CTA banner has one.
Don't
  • Don't put it on an <a> for an action, or on a <button> for navigation.

Theme toggle

Switches between the light and dark themes, at the end of every page's header. Follows the system until used; the choice is then remembered (ADR-0005).

Selectors
.theme-toggle
States
  • not pressed (light theme shown)
  • pressed (dark theme shown; the disc turns over on --duration-normal)
  • hover (border darkens)
  • focus-visible
  • narrow screens (below 48rem): 42px touch target, centred on the nav's line
  • hidden without JavaScript: the theme then follows the system
Accessibility
  • A <button type="button"> named “Dark theme” (visually hidden text, the icon is aria-hidden), its state in aria-pressed, so screen readers say “Dark theme, toggle button, pressed”.
  • aria-pressed reflects the theme shown, whether it comes from the system or the visitor's choice, and updates when the system changes.
  • The stored theme is applied in <head> before the stylesheet, so there is no flash of the other theme.
  • Hidden with @media (scripting: none): no dead button without JavaScript.
Do
  • Render it with themeToggle(); every .theme-toggle on a page acts on the same theme.
Don't
  • Don't change the label with the state: the name stays “Dark theme”, aria-pressed carries the state.
  • Don't print the chosen theme: dark tokens are screen-only.

Card

A case study teaser in the “Selected work” grid: cover, eyebrow, title, summary, impact.

Selectors
.cards .card .card-cover .is-portrait .is-wide .card-title .card-link .card-summary
States
  • default
  • hover / focus-within (accent border, card lifts by --lift-md, the cover's detail rises by --lift-lg)
  • contract open: construction lines, no lift, only the title links (see Card contract)
  • portrait cover (centred at 56% width, cut by the bottom edge)
  • wide cover (anchored left at 220% width, cut by the right edge)
Accessibility
  • One link per card, on the title; its ::after stretches the hit area over the whole card.
  • The “View contract” toggle comes after the <article>, so the article holds the teaser only.
  • The focus ring is drawn around the card, through that ::after.
  • The cover keeps the alt text written in the content.
Do
  • Wrap cards in .cards (a <ul>), one <li class="card"> each.
  • Use cardCover for one readable UI detail (a form, a component, a phone screen), cropped to the UI itself; the case study keeps its full screenshot in cover.
  • Let the cover's shape pick the rule: taller than wide is .is-portrait, otherwise .is-wide.
Don't
  • Don't add a second link or control inside a card, except the “View contract” toggle: the stretched link would cover it.
  • Don't use a full page or a device mockup as a card cover, and don't zoom it on hover.
  • The Rail Europe train ticket search form — from, to, dates and passengers.

    Rail Europe · 2025 — now

    Modernising a ten-year-old booking platform, one layer at a time

    Instead of a rewrite, a revamp — a legacy Rails monolith moved toward modern Rails and Hotwire, the mobile apps brought onto the same codebase, and booking pages made to feel faster.

    front-end bundle size
    −40%
    average, critical booking pages
    −1.3 s
    View contract

    A case study teaser in the “Selected work” grid: cover, eyebrow, title, summary, impact.

    Selectors
    .cards .card .card-cover .is-portrait .is-wide .card-title .card-link .card-summary
    Accessibility
    • One link per card, on the title; its ::after stretches the hit area over the whole card.
    • The “View contract” toggle comes after the <article>, so the article holds the teaser only.
    • The focus ring is drawn around the card, through that ::after.
    • The cover keeps the alt text written in the content.
    Contrast
    • --color-fg on --color-surface ≥ 4.5: title and summary
    • --color-muted on --color-surface ≥ 4.5: eyebrow and impact labels
    Don't
    • Don't add a second link or control inside a card, except the “View contract” toggle: the stretched link would cover it.
    • Don't use a full page or a device mockup as a card cover, and don't zoom it on hover.

    From src/system/contracts.ts · checked by tests/system.test.ts · Card on /system

Card contract

“View contract” on a card: opens the Card contract the card was built from, and draws construction lines on each part, labelled with its class.

Selectors
.card-contract .card-contract-panel
States
  • closed: a mono pill at the card's bottom right
  • hover (accent text and border)
  • focus-visible
  • opening / closing: the panel unfolds, fades and slides down over --duration-slow, and the { } opens up (instant under reduced motion, or where ::details-content is unsupported)
  • open: accent pill; the panel opens below it; every data-part gets crossing corner lines and a mono label; the card stops lifting and only its title links
Accessibility
  • A native <details> / <summary>: keyboard operable, announced as “View contract, collapsed / expanded”, and it works without JavaScript.
  • The { } is aria-hidden, so the name is “View contract” alone.
  • The labels are CSS content with empty alternative text (/ ""), so headings keep their names. The panel's Selectors row carries the same information.
  • The summary and the panel sit above the stretched card link (z-index: 1), so they can be clicked.
Do
  • Render the panel from the contract with workCard(work, root, contract), never by hand, so it can't drift.
  • Label each part with data-part, set to a class the element has and a contract owns (a test checks both).
Don't
  • Don't reuse .contract: that is the /system entry, with its top border.
  • Don't animate the lines: the open card holds still.
  • View contract

    A case study teaser in the “Selected work” grid: cover, eyebrow, title, summary, impact.

    Selectors
    .cards .card .card-cover .is-portrait .is-wide .card-title .card-link .card-summary
    Accessibility
    • One link per card, on the title; its ::after stretches the hit area over the whole card.
    • The “View contract” toggle comes after the <article>, so the article holds the teaser only.
    • The focus ring is drawn around the card, through that ::after.
    • The cover keeps the alt text written in the content.
    Contrast
    • --color-fg on --color-surface ≥ 4.5: title and summary
    • --color-muted on --color-surface ≥ 4.5: eyebrow and impact labels
    Don't
    • Don't add a second link or control inside a card, except the “View contract” toggle: the stretched link would cover it.
    • Don't use a full page or a device mockup as a card cover, and don't zoom it on hover.

    From src/system/contracts.ts · checked by tests/system.test.ts · Card on /system

Eyebrow

A short uppercase label above a title: client and period, or “Next”.

Selectors
.eyebrow
Accessibility
  • A plain paragraph, before the heading it introduces; uppercase is CSS only.
Don't
  • Don't use it as a heading.

Rail Europe · 2025 — now

Impact

Measured results of a case study: a big figure and what it measures.

Selectors
.impact .impact-item
Accessibility
  • A <dl> labelled “Impact”: the label (dt) comes before the figure (dd) in the source, the figure is shown first with column-reverse.
  • Figures use tabular numbers.
Do
  • Only figures with a source: the resume, or a before/after measurement.
Don't
  • Don't round or invent numbers to fill the row.
front-end bundle size
−40%
average, critical booking pages
−1.3 s

Tags

The stack of a case study, as small outlined pills.

Selectors
.tags .tag
Accessibility
  • A <ul> labelled “Stack”, so it is announced as a list.
Don't
  • Don't make tags interactive: they are not filters.
  • Ruby on Rails
  • Hotwire
  • Turbo
  • Stimulus
  • Capacitor
  • TypeScript

Screenshot

A WebP screenshot with an optional dark-mode variant and caption.

Selectors
.case-cover .shot .gallery
States
  • light
  • dark (swapped with prefers-color-scheme when a dark shot exists)
Accessibility
  • Alt text is required by the content schema; the build fails without it.
  • Width and height come from the file, so images don't shift the layout.
  • Lazy-loaded, except the cover of a case study.
Search results for Paris to Madrid on a phone, with two trains, their times, changes and prices, a price-trend hint and buttons for earlier and later trains.
Search results on mobile.

Hero

The top of the home page: name and role (the h1), tagline, intro and the call to action.

Selectors
.hero .hero-name .hero-role .tagline .intro
Accessibility
  • The h1 holds the name and role; the tagline is a paragraph styled large.
  • Only on the home page, since it holds the h1; the example shows the tagline and intro only.
  • The résumé header reuses .hero-name and .hero-role for its own h1.

I build design systems, polished UI, and the guardrails that let teams — and agents — ship them.

Ten years shipping production frontends, from startup studios to the booking funnel of Rail Europe. I care about the last 10% (interaction details, accessibility, perceived speed) and about making the right path the easy one for whoever builds next.

Practice

One way of working, in the “How I work” grid.

Selectors
.practices .practice
Accessibility
  • Each practice is a list item with an h3, under the section's h2.
  • Design systems, for humans and agents

    Tokens, documented components and Storybook as a shared language between design and code. Now making them readable by AI agents too — starting with this site.

CTA banner

The closing call to action of the home page: a question, one line, and a Button to the résumé, on a flat surface.

Selectors
.cta-banner
States
  • wide (text left, button right)
  • phone (button below the text, centred)
Accessibility
  • A <section> labelled by its h2, like the other home sections.
  • The action is a Button on an <a>, since it navigates to the résumé.
Don't
  • Don't add a border or a second action: one question, one button.

Want the full picture?

Every role, project and skill on one page, ready to print.

Read my CV

Prose

The Markdown body of a case study or a post, at a readable line length.

Selectors
.prose
Accessibility
  • Headings in the Markdown start at h2, under the page's h1.
  • Code blocks wrap instead of scrolling sideways, so they never need a horizontal scroll or a keyboard stop.

Rail Europe sells train tickets across Europe. Its customer-facing site was a Rails monolith more than ten years old, with layers of frontend history: CoffeeScript, jQuery, Sass, AJAX-driven partials, and a separate React SPA plus an API that mainly served the mobile apps.

Layout

Page

Page frame: centred column, header, sections, and the footer of every page: copyright on the left, Social links on the right; stacked and centred below 48rem.

Selectors
.page .section .subheading .site-header .site-nav .site-footer .copyright body h1 h2 h3 p ul ol img figcaption code
Accessibility
  • One h1 per page, headings never skip a level, <html lang="en">.
  • Sections are labelled by their heading (aria-labelledby).
  • Light and dark themes follow prefers-color-scheme until the visitor uses the Theme toggle.
  • Every page has a header <nav aria-label="Main"> (Home, Writing once a post is published, CV, System); below 48rem its items sit --space-4 apart instead of --space-6; the current page has aria-current="page" and stays underlined, so it doesn't rely on colour.

Case study

The structure of a case study page: back link, header, cover, impact, prose, links, next.

Selectors
.case .back .case-header .case-links .next
Accessibility
  • The “Next case study” block is a <nav> with its own label.

Writing

The /writing index (posts newest first) and a post page (back link, case-study header, prose). Not built until a post is published.

Selectors
.post-item
Accessibility
  • The index is an ordered list; each title is an h2 link under the page's h1.
  • Dates are <time datetime> elements, so they're machine-readable.
  • A post is an <article> inside main.
Don't
  • Don't publish a post with open notes for Antoine: the build warns about them.
  1. The thing is, I love coding

    You can keep saying that the value isn’t in the code, and that this was true even before the age of AI; I still love it as a practice.

Design system page

This page: token swatches and scales, contrast table, and one entry per contract.

Selectors
.system-toc .swatches .swatch .swatch-chip .table-scroll .system-table .type-sample .space-bar .contract .contract-spec .contract-example
Accessibility
  • Contrast ratios are computed at build time, in both themes, from tokens.css.
  • Examples are rendered by the same functions as the real pages, with the real content.

Résumé

/resume: profile, timeline and case studies as a résumé that prints on up to two A4 pages.

Selectors
.resume .resume-header .resume-contact .resume-hint .resume-print .resume-section .resume-entry .resume-role .resume-role-title .resume-period .resume-more .resume-impact .resume-list
States
  • screen (follows the colour scheme); from 48rem wide, section labels sit in a left gutter
  • phone (labels stack above their content)
  • with JavaScript: a “Save as PDF” button opens the print dialog
  • without JavaScript: the button is hidden (@media (scripting: none)) and a <noscript> hint explains how to print
  • print (A4, label gutter, black and grey only, no header, footer, button or hint; roles and short sections never split across pages)
Accessibility
  • The h1 holds the name and role, like the home page; one h2 per section, one h3 per organisation or project.
  • Links show their URL as text, so they still work on paper.
  • The email is written as HTML entities: plain text for browsers and screen readers, not for naive scrapers.
Do
  • Keep every claim in the content files; the page only rearranges them.
  • Name a skill in profile.yaml only if the content already mentions it — a test checks.
Don't
  • Don't add JavaScript beyond the button's inline onclick="print()"; a test allows that one handler only.
Frontend
TypeScript, JavaScript, HTML, CSS, Svelte, SvelteKit, React, Next.js, Vue.js, Hotwire, Turbo, Stimulus, Tailwind

Utilities

Visually hidden

Text for screen readers only, e.g. “(external)” after a link.

Selectors
.visually-hidden
Accessibility
  • Hidden with clip-path, never display: none, so it is still read out.

Muted

Secondary text colour.

Selectors
.muted
Accessibility
  • --color-muted passes AA on both backgrounds, in both themes.