# Design System

**Direction:** structured-editorial (PRD §21.3): generous whitespace, strong typographic hierarchy, content in clear structured blocks, and one restrained accent. It deliberately avoids everything on PRD §21.2's list: no developer/terminal aesthetic, no decorative shapes or gradients, no glassmorphism, no stock photography.

**Source of truth for tokens:** `resources/css/app.css` (`@theme`). Components live in `resources/views/components/` and `app/View/Components/`.

**Status:** Tokens and primitives (Phase 3) and the site chrome and content components (Phase 5) are in place. Forms, booking embed and analytics behaviours land in Phase 6.

---

## 1. Colour

### 1.0 Colour roles and owner theming (ADR-047)

Components never use palette colours. They use **roles**: `page`, `surface`, `card`, `header`, `footer`, `heading`, `ink`, `muted`, `line`, `control`, `accent`, `accent-hover`, `on-accent`, `link`, `link-hover`, `focus`, `inverse`, `inverse-ink`, `inverse-muted`, `inverse-link`, `error`, `success`, `warning` and `shadow` (Tailwind utilities such as `bg-surface`, `text-muted`, `border-line`). An architecture test fails the build if a view uses a raw palette colour.

- **Defaults.** `resources/css/app.css` holds the light defaults (the PRD palette below). `ThemePalette` holds both the light and the dark defaults.
- **Owner theming.** The owner edits both palettes in **Site Management › Appearance**. `ThemeStylesheet` prints them as `:root` custom properties in a nonce'd `<style>`, according to the colour mode:
  - light;
  - dark;
  - system, which uses `prefers-color-scheme`;
  - with the optional visitor switch, the choice is stored per visitor and applied before paint via `data-theme`.
- **Contrast checks.** Every pair the site renders is listed in `ThemePalette::pairs()`. A save is refused if any text pair falls below 4.5:1, or a focus or control edge below 3:1.
- **Dark sections.** `.surface-inverse` (closing CTA, footer, sections set to "Dark") re-points the roles at the `inverse-*` colours, so components inside need no dark variant. The `onDark` props are kept for compatibility but are no longer needed.
- **Section backgrounds** (`SectionAppearance`): theme default, raised, dark, a custom colour or an image under an overlay.
  - Dark colours use the dark-section roles. Light colours pin the light-mode text roles, so they stay readable in dark mode.
  - For images, the overlay must be strong enough for the worst-case pixel. The editor is told the minimum.
- **Preview.** **Preview** applies unsaved Appearance values to public pages for the signed-in editor only (`ThemePreview`).
- **Browser tint.** `<meta name="theme-color">` follows the header colour per mode, and the visitor switch updates it.
- **Static error pages.** The 500 and 503 pages can't read settings (the database may be down), so they use the default light and dark palettes via `prefers-color-scheme`.
- **Verified.** Both default palettes pass every pair. The browser suite runs axe on every page in light and dark mode, including section backgrounds on About.

### 1.1 Palette (light defaults)

The five PRD values come from the swatch image embedded in PRD §21.4 (`docs/prd/assets/palette-swatch.jpeg`). The Electric Blue shades are proposed defaults (ADR-022): the swatch contains no blue.

| Token | Hex | Source | Use |
|---|---|---|---|
| `navy-950` | `#081B2C` | PRD | Body text, header/footer, Final CTA section (`.surface-navy`) |
| `navy-900` | `#102A43` | PRD | Headings on ivory, secondary dark surfaces |
| `steel-600` | `#3C5A73` | PRD | Muted text, meta lines, input borders, icons |
| `ivory-200` | `#E9E4D6` | PRD | **Page background (Warm Ivory)** |
| `stone-300` | `#C7BFAE` | PRD | Decorative dividers, card borders, tag fills, muted text on navy |
| `ivory-50` | `#F5F2EA` | derived | Card / raised surface on the ivory page |
| `blue-600` | `#1F55E0` | proposed | Primary button fill, links on ivory |
| `blue-700` | `#1A46BD` | proposed | Hover / pressed |
| `blue-500` | `#2F6BFF` | proposed | Electric Blue **non-text** accent: focus ring on ivory, highlights |
| `blue-300` | `#8FB0FF` | proposed | Links and focus ring on navy |
| `error-700` / `success-700` / `warning-700` | `#B42318` / `#05603A` / `#93370D` | derived | Form and status states |

Semantic aliases used by components: `page`, `surface`, `ink`, `ink-muted`, `line`, `accent`, `accent-hover`, `focus`.

### 1.2 Verified contrast (WCAG 2.1 AA)

Computed on 2026-10-03. Text needs ≥ 4.5:1 (≥ 3:1 for large text); UI component boundaries and focus indicators need ≥ 3:1.

| Foreground / background | Ratio | Use | Pass |
|---|---|---|---|
| navy-950 / ivory-200 | 13.74 | body text on page | ✓ |
| navy-950 / ivory-50 | 15.60 | text on cards | ✓ |
| navy-900 / ivory-200 | 11.53 | headings | ✓ |
| steel-600 / ivory-200 | 5.70 | muted text; input borders | ✓ |
| steel-600 / ivory-50 | 6.47 | muted text on cards | ✓ |
| navy-950 / stone-300 | 9.55 | text on tags | ✓ |
| ivory-200 / navy-950 | 13.74 | text on navy | ✓ |
| stone-300 / navy-950 | 9.55 | muted text on navy | ✓ |
| blue-600 / ivory-200 | 4.81 | links on page | ✓ |
| blue-600 / ivory-50 | 5.47 | links on cards | ✓ |
| blue-700 / ivory-200 | 6.23 | link hover | ✓ |
| white / blue-600 | 6.12 | primary button label | ✓ |
| blue-300 / navy-950 | 8.16 | links and focus ring on navy | ✓ |
| blue-500 / ivory-200 | 3.54 | focus ring (non-text) | ✓ non-text only |
| error-700 / ivory-200 | 5.18 | error text | ✓ |
| success-700 / ivory-200 | 6.03 | success text (`#067647` measured 4.48, so it was darkened) | ✓ |
| warning-700 / ivory-200 | 5.92 | warning / placeholder text | ✓ |
| stone-300 / ivory-200 | 1.44 | decorative dividers only, never a component boundary | n/a |

**Rules:**
- `blue-500` is never used for text.
- Meaning is never carried by colour alone: placeholders also carry a dashed border and bracketed text, and errors also carry text and an icon.

## 2. Typography

| Role | Family | Notes |
|---|---|---|
| Headings | **Source Serif 4 Variable** (`font-serif`) | Weight axis only (51 KB Latin file), weight 600, `text-wrap: balance` |
| Body / UI | **Inter Variable** (`font-sans`) | 400–600 |

Fonts are self-hosted via `@fontsource-variable/*` and bundled and hashed by Vite. Each font is its own small entry in `resources/css/fonts/`.

- **Choosing fonts.** The owner picks the heading and body fonts in **Appearance** (`FontCatalogue`): 6 sans-serif fonts, 6 serif fonts and two system options that download nothing. Only the selected fonts' CSS loads, and their Latin files are preloaded by `<x-theme.head>`.
- **Utilities.** Components use `font-heading` and `font-body`.
- **Loading.** `unicode-range` subsets mean only the Latin files download in practice. `font-display: swap` is used.
- **Hosting.** No third-party font CDN is used on the public site. The admin uses Filament's bundled Inter.

| Token | Size | Line height |
|---|---|---|
| `text-h1` | clamp(2.25rem → 3.75rem) | 1.08 |
| `text-h2` | clamp(1.75rem → 2.5rem) | 1.15 |
| `text-h3` | clamp(1.25rem → 1.5rem) | 1.3 |
| `text-h4` | 1.125rem | 1.4 |
| `text-lead` | 1.1875rem | 1.65 |
| body | 1rem (1.0625rem in `.prose-content`) | 1.6 / 1.75 |
| `text-eyebrow` | 0.8125rem, uppercase, +0.08em tracking | 1.25rem |

The readable measure is `max-w-[var(--container-prose)]` (45rem ≈ 70ch).

## 3. Space, grid, breakpoints

- **Spacing:** Tailwind's 0.25rem (4px) scale; components use multiples of 8px.
- **Content width:** `--container-content` 75rem (1200px); prose 45rem.
- **Breakpoints (PRD §25):** base = mobile (375–428), `md` 768, `lg` 1024, `xl` 1440, `2xl` 1920.
- **Grids:** 3 columns → 2 → 1 for project, article and certification grids. The page never scrolls horizontally (`overflow-x: clip` on `body`).
- **Touch targets:** buttons and links styled as buttons are `min-h-11` (44px).

## 4. Elevation, radius, motion

- **Card:** `--radius-card` 0.75rem, defined by `--shadow-card` (no visible border; a transparent border keeps an outline in forced-colors mode). Placeholder cards keep a dashed warning border. Interactive cards lift 2px with `--shadow-card-hover`.
- **Motion:** purposeful only (hover lift, subtle entrance). Everything is disabled under `prefers-reduced-motion`. No parallax, no decorative animation.
- **Focus:** a 3px `focus` outline with a 2px offset on every `:focus-visible`. It switches to `blue-300` inside `.surface-navy`.

## 5. Icons

One set only: **Heroicons outline** (already a Filament dependency), used for capabilities, process steps, and social/contact icons. Editors pick from the same set in the block schemas.

## 6. Components (Phase 3)

| Component | Props | States / behaviour |
|---|---|---|
| `<x-layouts.app>` (`components/layouts/app.blade.php`) | `seo` (SeoData), `bodyClass`; slots `head`, `header`, `footer` | Skip link → `#main-content`; `<main tabindex="-1">` |
| `<x-layout.skip-link>` | — | Visually hidden until focused |
| `<x-theme.head>` | — | Selected fonts (entries + preload), theme CSS custom properties, pre-paint script when visitors may switch (ADR-047) |
| `<x-seo.head>` | `seo` | Title, description, canonical, robots, Open Graph (with image dimensions), Twitter card, JSON-LD (escaped with `JSON_HEX_*`, CSP nonce) |
| `<x-button>` | `variant` (primary / secondary / tertiary), `href`, `type`, `newTab`, `loading`, `disabled`, `onDark`, `track`, `trackProps` | `<a>` when `href` is set, otherwise `<button>`; hover/focus/disabled/loading (`aria-busy`, spinner); new-tab hint for screen readers; `data-track` for analytics |
| `<x-card>` | `as`, `interactive`, `padded` | Base card; interactive variant lifts on hover and focus-within |
| `<x-tag>` | `tone` (neutral / accent / placeholder) | Placeholder tone is dashed + warning colour so placeholders never look real (PRD §15.2) |
| `<x-section-heading>` | `eyebrow`, `title`, `subtitle`, `level` (default 2), `align`, `id` | Renders nothing when empty; blocks use h2 and below (one H1 per page) |
| `<x-rich-text>` | `html` | The **only** component that outputs CMS HTML. It re-sanitises at render (enforced by an architecture test) |
| `<x-media.image>` | `media`, `sizes`, `priority`, `conversion`, `alt` | srcset/sizes, intrinsic width/height (prevents CLS), `loading=lazy` by default or eager + `fetchpriority=high` for the LCP image; renders nothing when media is missing |

**Mobile navigation:** a native `<details>` disclosure (works without JS). The `mobile-nav` module adds Escape-to-close with focus return, moves focus into the menu, and closes it on navigation (ADR-034, ADR-043).

**JS loading:** `app.js` is a 4.6 KB core (analytics listener and module loader; no framework, ADR-043). Feature modules in `resources/js/modules/*.js` load only when a `[data-module="name"]` element is on the page.

## 7. Components (Phase 5)

| Component | Props | Notes |
|---|---|---|
| `<x-layouts.site>` | `seo`, `stickyContact`, `preview`, `bodyClass` | Header + footer + mobile CTA bar (+ sticky contact on long pages, preview banner) |
| `<x-site.header>` | — | Name/logo, primary nav (lg+), Download CV button at every breakpoint, Let's Connect (lg+), `<details>` mobile menu (ADR-034) |
| `<x-site.mobile-cta-bar>` | — | Fixed bottom bar below 1024px: CV + Contact (PRD §6.4) |
| `<x-site.footer>` | — | Identity, full sitemap (footer menu), email, social links, CV, copyright with `{year}` |
| `<x-site.sticky-contact>` | — | Floating Let's Connect on long pages after 60% of a viewport of scroll (desktop) |
| `<x-cv-button>` | `location`, `variant`, `onDark`, `label` | Opens `/cv?from=` in a new tab (`cv_download_{location}`), or the "unavailable — email me" fallback |
| `<x-cta-link>` | `cta` (ResolvedCta), `location` | Booking CTAs vanish when booking is off (CtaResolver) |
| `<x-breadcrumbs>` | `trail` | Project/article detail only (PRD §6.7) |
| `<x-page-header>` | `title`, `intro`, `eyebrow` | Owns the H1 when the page has no hero |
| `<x-section>` | `vm`, `tone` (default / surface / navy), `spacing` | Anchor target + vertical rhythm for every block |
| `<x-project-card>` | `project`, `source`, `featured`, `headingLevel` | All PRD §10.1 fields; organisation via OrganisationPresenter; whole-card link; `project_card_click` by source |
| `<x-article-card>` | `article`, `source` | Cover, category, date, reading time, excerpt |
| `<x-certification-card>` | `certification`, `compact` | Placeholder treatment (dashed + "Placeholder" tag, no link) |
| `<x-recommendation-card>` | `recommendation`, `excerptOnly` | Name, title, relationship, "via LinkedIn" attribution; never Review schema |
| `<x-experience.item>` | `experience`, `open` | Timeline entry as `<details>`; achievements first and emphasised |
| `<x-skill-group>` | `category` | Tags only; no proficiency graphics |
| `<x-empty-state>` | `message`, `icon`; slot for actions | "Clear filters" etc. (PRD §33) |
| `<x-share-links>` | `url`, `title` | LinkedIn first; plain links, no third-party scripts |
| `<x-social-link>` | `link`, `location` | Platform label + generic icon (ADR-036) |
| `<x-icon>` | `name`, `label` | Heroicons outline; decorative unless `label` given |

Layout utilities in `app.css`: `.container-site`, `.section-y`, `.grid-cards` (3→2→1), `.grid-auto` (auto-fit, safe at 5+), `.template-narrative`.

JS modules: `filters` (in-place filtering, URL sync, Back/Forward, empty state, Load more, live status), `carousel` (manual, ADR-035), `sticky-contact`.

**Verified 2026-10-03:** no horizontal overflow at 390 / 768 / 1024 / 1440; **zero axe WCAG 2.1 A/AA violations** on Home, Projects, Project detail, Experience, Article, Contact, Certifications and 404, at 1440 and 390 widths.
