# Antoine Duvauchelle — design system

Generated from `styles/tokens.css` and `src/system/contracts.ts`; don't edit by hand.
Rules for anyone changing the site, human or agent:

- Colours, px values and durations come from the tokens below; a test fails on any raw one elsewhere.
- Use the spacing, type and radius tokens too wherever one fits.
- Every CSS class belongs to exactly one contract. Add or update the contract first.
- Keep every contrast pair at or above its minimum, in both themes.
- Every page works without JavaScript. JavaScript only enhances: hide the enhanced control with
  `@media (scripting: none)`, put the fallback in `<noscript>`, no script files (ADR-0003).

## Tokens

### color

| Token | Light | Dark |
| --- | --- | --- |
| `--color-bg` | `oklch(98.5% 0.006 85)` | `oklch(18% 0.008 70)` |
| `--color-surface` | `oklch(96% 0.008 85)` | `oklch(22% 0.01 70)` |
| `--color-raised` | `oklch(100% 0 0)` | `oklch(26% 0.01 70)` |
| `--color-fg` | `oklch(22% 0.01 70)` | `oklch(93% 0.008 85)` |
| `--color-muted` | `oklch(46% 0.012 70)` | `oklch(72% 0.01 80)` |
| `--color-border` | `oklch(88% 0.01 80)` | `oklch(32% 0.01 70)` |
| `--color-accent` | `oklch(48% 0.16 262)` | `oklch(76% 0.12 262)` |
| `--color-accent-fg` | `oklch(98.5% 0.006 85)` | `oklch(18% 0.008 70)` |
| `--color-focus` | `oklch(55% 0.2 262)` | `oklch(78% 0.14 262)` |

### font

| Token | Value |
| --- | --- |
| `--font-sans` | `system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif` |
| `--font-mono` | `ui-monospace, "SF Mono", Menlo, Consolas, monospace` |

### text

| Token | Value |
| --- | --- |
| `--text-sm` | `0.875rem` |
| `--text-base` | `1.0625rem` |
| `--text-lg` | `clamp(1.1875rem, 1.1rem + 0.4vw, 1.375rem)` |
| `--text-xl` | `clamp(1.5rem, 1.3rem + 0.9vw, 2rem)` |
| `--text-2xl` | `clamp(2rem, 1.6rem + 2vw, 3.25rem)` |

### leading

| Token | Value |
| --- | --- |
| `--leading-tight` | `1.15` |
| `--leading-normal` | `1.6` |
| `--leading-print` | `1.45` |

### weight

| Token | Value |
| --- | --- |
| `--weight-normal` | `400` |
| `--weight-medium` | `550` |
| `--weight-bold` | `700` |

### space

| Token | Value |
| --- | --- |
| `--space-1` | `0.25rem` |
| `--space-2` | `0.5rem` |
| `--space-3` | `0.75rem` |
| `--space-4` | `1rem` |
| `--space-6` | `1.5rem` |
| `--space-8` | `2rem` |
| `--space-12` | `3rem` |
| `--space-16` | `4rem` |
| `--space-24` | `6rem` |

### width

| Token | Value |
| --- | --- |
| `--width-text` | `40rem` |
| `--width-page` | `64rem` |

### gutter

| Token | Value |
| --- | --- |
| `--gutter` | `clamp(1rem, 4vw, 2rem)` |

### radius

| Token | Value |
| --- | --- |
| `--radius-sm` | `0.375rem` |
| `--radius-md` | `0.75rem` |
| `--radius-pill` | `999px` |

### border

| Token | Value |
| --- | --- |
| `--border-width` | `1px` |
| `--border-width-thick` | `2px` |

### focus

| Token | Value |
| --- | --- |
| `--focus-width` | `3px` |
| `--focus-offset` | `2px` |

### duration

| Token | Value |
| --- | --- |
| `--duration-fast` | `120ms` |
| `--duration-normal` | `220ms` |
| `--duration-slow` | `480ms` |

### ease

| Token | Value |
| --- | --- |
| `--ease-out` | `cubic-bezier(0.2, 0.8, 0.2, 1)` |

### lift

| Token | Value |
| --- | --- |
| `--lift-sm` | `-1px` |
| `--lift-md` | `-2px` |
| `--lift-lg` | `-0.5rem` |

### print

| Token | Value |
| --- | --- |
| `--print-root-size` | `9pt` |
| `--print-margin` | `12mm` |

## Contrast pairs

| Text / UI | On | Minimum | Light | Dark | Used for |
| --- | --- | --- | --- | --- | --- |
| `--color-accent` | `--color-bg` | 4.50:1 | 6.44:1 | 8.71:1 | Link: link text |
| `--color-focus` | `--color-bg` | 3.00:1 | 4.83:1 | 9.11:1 | Link: focus ring; Theme toggle: focus ring |
| `--color-focus` | `--color-surface` | 3.00:1 | 4.48:1 | 8.39:1 | Link: focus ring on surfaces |
| `--color-bg` | `--color-fg` | 4.50:1 | 16.60:1 | 15.31:1 | Button: label on the inverted pill |
| `--color-bg` | `--color-fg` | 3.00:1 | 16.60:1 | 15.31:1 | Social links: logo on the inverted disc |
| `--color-fg` | `--color-surface` | 4.50:1 | 15.43:1 | 14.10:1 | Card: title and summary; CTA banner: banner heading; Design system page: examples |
| `--color-muted` | `--color-surface` | 4.50:1 | 6.36:1 | 6.98:1 | Card: eyebrow and impact labels; CTA banner: banner text; Muted: secondary text on surfaces |
| `--color-accent` | `--color-surface` | 4.50:1 | 5.98:1 | 8.02:1 | Card contract: open pill; construction lines; Design system page: links in examples |
| `--color-accent-fg` | `--color-accent` | 4.50:1 | 6.44:1 | 8.71:1 | Card contract: part labels; Skip link: label |
| `--color-accent` | `--color-bg` | 3.00:1 | 6.44:1 | 8.71:1 | Card contract: construction lines on the cover |
| `--color-muted` | `--color-bg` | 4.50:1 | 6.84:1 | 7.58:1 | Card contract: panel sources line; Page: secondary text; Writing: secondary text; Design system page: secondary text; Résumé: secondary text; Muted: secondary text |
| `--color-fg` | `--color-raised` | 4.50:1 | 17.33:1 | 12.66:1 | Prose: callouts |
| `--color-accent` | `--color-raised` | 4.50:1 | 6.72:1 | 7.20:1 | Prose: links in callouts |
| `--color-fg` | `--color-bg` | 4.50:1 | 16.60:1 | 15.31:1 | Page: body text; Writing: body text; Design system page: body text; Résumé: body text |

## Contracts

### Link

*component.* Inline navigation, in text and lists. External links say so to screen readers.

**Selectors:** `a`

**States**

- default
- hover (underline moves down)
- focus-visible (focus ring)

**Accessibility**

- Underlined, so links don't rely on colour alone.
- External links get a visually hidden “(external)” and `rel="noopener"`.
- Every focusable element shows the global `:focus-visible` ring (`--focus-width`, `--color-focus`).

**Do**

- Use `linkTo()` for links built from content, so external links are labelled.

**Don't**

- Don't remove the underline or the focus ring.

### Button

*component.* 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.

### Social links

*component.* The profile links (LinkedIn, Codeberg, GitHub) as round logo buttons with inverted colours, at the end of the hero and in every page's footer.

**Selectors:** `.social-links` `.social-link`

**States**

- default
- hover (lifts by `--lift-sm`)
- active
- focus-visible

**Accessibility**

- A list labelled “Elsewhere”; each link is named by its label plus a visually hidden “(external)”, the logo is `aria-hidden`.
- 48px round targets, above the 24px minimum.
- The lift is a transform on `--duration-fast`, which is 0 under reduced motion.

**Do**

- Render it with `socialLinks()`, from `profile.links` in `content/profile.yaml`; a link needs a logo in `SOCIAL_ICONS`, or the build fails.

**Don't**

- Don't add a visible text label next to the logos, or a logo without a name.

### Theme toggle

*component.* 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

*component.* 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.

### Card contract

*component.* “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.

### Eyebrow

*component.* 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.

### Impact

*component.* 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.

### Tags

*component.* 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.

### Screenshot

*component.* 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.

### Hero

*component.* 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`.

### Practice

*component.* 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`.

### CTA banner

*component.* 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.

### Prose

*component.* 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.

### Page

*layout.* 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

*layout.* 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

*layout.* 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.

### Design system page

*layout.* 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é

*layout.* `/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.

### Skip link

*utility.* “Skip to content”, the first focusable element of every page; visible on focus only.

**Selectors:** `.skip-link`

**States**

- hidden
- focus-visible (slides in)

**Accessibility**

- Targets `#main`; every page has a `<main id="main">`. Press Tab at the top of any page to see it.

### Visually hidden

*utility.* 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

*utility.* Secondary text colour.

**Selectors:** `.muted`

**Accessibility**

- `--color-muted` passes AA on both backgrounds, in both themes.
