/*!
 * Yooku Obiri — Brand Designer
 * GENERATED FILE — do not edit.
 * Edit /css/partials/*.css and run `node build.js`.
 * Built 2026-09-04T10:54:01.636Z
 * Sources: 01-tokens.css, 02-reset.css, 03-base.css, 04-layout.css, 05-components.css, 06-sections.css, 07-utilities.css
 */

/* ===== 01-tokens.css =============================================== */
/* ==========================================================================
   01 — TOKENS
   Single source of truth. Nothing below this file should hard-code a value.
   ========================================================================== */

:root {
  /* ---------------------------------------------------------------------
     COLOR — monochrome ramp + one accent.
     The ramp is intentionally not "pure grey at even steps"; the dark end is
     compressed so near-blacks stay separable on cheap laptop panels.
     --------------------------------------------------------------------- */
  --c-black: #000000;
  --c-ink-900: #0a0a0a; /* page background (dark) — softer than pure black */
  --c-ink-800: #141414;
  --c-ink-700: #1f1f1f;
  --c-ink-600: #2e2e2e;
  --c-ink-500: #4a4a4a;
  --c-ink-400: #6e6e6e; /* 4.5:1 on white — lowest safe body grey on light */
  --c-ink-300: #9a9a9a; /* 4.5:1 on --c-ink-900 — lowest safe body grey on dark */
  --c-ink-200: #c9c9c9;
  --c-ink-100: #e6e6e6;
  --c-ink-050: #f4f4f4;
  --c-ink-025: #f9f9f9;
  --c-paper: #eeeeee; /* 238 — chosen to equal the FLOOR of the illustration
                         video's decoded ground (measured 238 across the whole
                         clip). The figure blends with `darken`, so min(238,
                         >=238) resolves to exactly this value and the video's
                         opaque plate is mathematically invisible. Raising this
                         reveals the plate as a lighter rectangle. */
  --c-white: #ffffff;

  /* ACCENT
     Constraint: 4.5:1 against BOTH #fff and #000, so one accent works on
     either surface without a light/dark variant swap.
     That window is tiny (relative luminance must sit ~0.175–0.183).
       #008846 → 4.55:1 on white, 4.61:1 on black.  ✅ both
     Variants below are for *known* backgrounds only (hover, large display
     type, decorative fills) — they do not pass on both surfaces. */
  --c-accent: #008846;
  --c-accent-bright: #00b85e; /* on dark surfaces only (7.0:1 on #0a0a0a) */
  --c-accent-deep: #00622f; /* on light surfaces only (7.4:1 on #fff) */
  --c-accent-wash: rgba(0, 136, 70, 0.12); /* tints, focus halos, hover fills */

  /* Semantic aliases — components use these, never the raw ramp.
     DEFAULT IS LIGHT. The reference direction is near-black type on an
     off-white ground; dark is the override, not the baseline. */
  --c-bg: var(--c-paper);
  --c-bg-raised: var(--c-ink-025);
  --c-fg: var(--c-ink-900);
  --c-fg-muted: var(--c-ink-400);
  --c-line: var(--c-ink-200);
  --c-focus: var(--c-accent-deep);
  --c-curtain: var(--c-ink-900); /* page-transition wipe */
  --c-selection-bg: var(--c-accent);
  --c-selection-fg: var(--c-white);

  /* =====================================================================
     TYPE  —  ★ FONT SWAP HAPPENS HERE AND NOWHERE ELSE ★

     To change a typeface you edit exactly TWO things:
       1. --font-family-display / --font-family-body below
       2. the Google Fonts <link> in each HTML <head>
          (one line, identical on every page — see README "Changing fonts")

     Nothing else in the codebase names a font. Every heading, label and
     paragraph resolves through --font-display or --font-sans.

     CURRENT PICK
       Display : DM Sans   — geometric, low-contrast grotesk. The closest
                             free match to the reference's Neue-Montreal-ish
                             feel: tight apertures, near-circular bowls,
                             holds up at 11rem without looking like Helvetica.
       Body    : DM Sans   — same family. One typeface, hierarchy from
                             weight and tracking. Fewer files, more discipline.

     DROP-IN ALTERNATIVES (all Google, all variable, all one-line swaps):
       Space Grotesk  more character, quirky 'g' — good if you want edge
       General Sans   (Fontshare, not Google) — closest of all to the ref
       Archivo        wider, has a true Expanded axis for display
       Inter          the safe default; more neutral, less personality
     ===================================================================== */
  --font-family-display: "DM Sans";
  --font-family-body: "DM Sans";

  --font-stack-fallback: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    Helvetica, Arial, sans-serif;

  --font-display: var(--font-family-display), var(--font-stack-fallback);
  --font-sans: var(--font-family-body), var(--font-stack-fallback);
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;

  --fw-regular: 400;
  --fw-medium: 500;
  --fw-semibold: 600;
  --fw-bold: 700;

  /* Fluid scale. Ratio ~1.25 mobile / ~1.333 desktop, clamped so the top end
     never outruns the viewport. Base stays 16px at every width — only the
     display sizes flex. */
  --fs-100: 0.75rem; /* 12 — eyebrow / meta / labels */
  --fs-200: 0.875rem; /* 14 — captions, nav */
  --fs-300: 1rem; /* 16 — body */
  --fs-400: clamp(1.125rem, 0.5vw + 1rem, 1.25rem); /* lead paragraph */
  --fs-500: clamp(1.5rem, 1.2vw + 1.1rem, 2rem); /* h4 / large lead */
  --fs-600: clamp(1.875rem, 2vw + 1.2rem, 2.75rem); /* h3 */
  --fs-700: clamp(2.25rem, 3.5vw + 1.2rem, 4rem); /* h2 / section head */
  --fs-800: clamp(3rem, 7vw + 0.5rem, 6.5rem); /* h1 */
  --fs-900: clamp(2.75rem, 12vw, 11rem); /* hero display — min sized so
                                            "Yooku Obiri" stays one line on a
                                            360px phone */

  --lh-tight: 0.95; /* display only */
  --lh-snug: 1.1;
  --lh-heading: 1.15;
  --lh-body: 1.6;
  --lh-loose: 1.75;

  --ls-display: -0.03em; /* big type needs negative tracking */
  --ls-heading: -0.015em;
  --ls-body: 0;
  --ls-label: 0.12em; /* uppercase eyebrows */

  --measure: 68ch; /* max readable line length */
  --measure-narrow: 46ch;

  /* ---------------------------------------------------------------------
     SPACE — 4px base, roughly geometric so vertical rhythm stays coarse.
     --------------------------------------------------------------------- */
  --sp-3xs: 0.25rem; /* 4 */
  --sp-2xs: 0.5rem; /* 8 */
  --sp-xs: 0.75rem; /* 12 */
  --sp-sm: 1rem; /* 16 */
  --sp-md: 1.5rem; /* 24 */
  --sp-lg: 2.5rem; /* 40 */
  --sp-xl: 4rem; /* 64 */
  --sp-2xl: 6rem; /* 96 */
  --sp-3xl: 9rem; /* 144 */
  --sp-4xl: 14rem; /* 224 */

  /* Fluid section rhythm — the only vertical spacing that should scale. */
  --sp-section: clamp(4rem, 10vw, 12rem);
  --sp-section-tight: clamp(2.5rem, 6vw, 6rem);

  /* ---------------------------------------------------------------------
     LAYOUT
     --------------------------------------------------------------------- */
  --container-max: 90rem; /* 1440 */
  --container-text: 46rem; /* prose column */
  --gutter: clamp(1.25rem, 4vw, 4rem);
  --grid-columns: 4; /* mobile default; layout partial bumps this up */
  --grid-gap: clamp(1rem, 2vw, 2rem);

  --header-height: 4.5rem;
  --border-width: 1px;
  --radius-none: 0; /* the design language is square. Kept as a token so a
                       future rebrand is one line, not a find-and-replace. */
  --radius-sm: 2px;
  --radius-pill: 999px;

  /* ---------------------------------------------------------------------
     MOTION — GSAP reads these via getComputedStyle in gsap-setup.js so JS and
     CSS transitions stay in sync.
     --------------------------------------------------------------------- */
  --dur-instant: 0.12s;
  --dur-fast: 0.25s;
  --dur-base: 0.45s;
  --dur-slow: 0.8s;
  --dur-transition-out: 0.5s; /* page leave */
  --dur-transition-in: 0.7s; /* page enter */

  --ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* expo-ish out — the house ease */
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --ease-linear: linear;

  /* ---------------------------------------------------------------------
     Z-INDEX — named, so nothing is ever "z-index: 9999".
     --------------------------------------------------------------------- */
  --z-canvas: 0; /* persistent WebGL layer, behind everything */
  --z-content: 1;
  --z-header: 100;
  --z-overlay: 200; /* page-transition curtain */
  --z-grain: 250; /* film grain — over content and the wipe, under the cursor */
  --z-cursor: 300; /* always on top */
  --z-skip-link: 400;
}

/* --------------------------------------------------------------------------
   THEME OVERRIDE — dark surfaces.
   Applied per-section (<section data-theme="dark">) or per-page on <body>.
   Only semantic aliases flip; the ramp never changes.
   -------------------------------------------------------------------------- */
[data-theme="dark"] {
  --c-bg: var(--c-ink-900);
  --c-bg-raised: var(--c-ink-800);
  --c-fg: var(--c-white);
  --c-fg-muted: var(--c-ink-300);
  --c-line: var(--c-ink-700);
  --c-focus: var(--c-accent-bright);
  --c-curtain: var(--c-paper);
}

/* Explicit light, for flipping a single section back inside a dark page. */
[data-theme="light"] {
  --c-bg: var(--c-paper);
  --c-bg-raised: var(--c-ink-025);
  --c-fg: var(--c-ink-900);
  --c-fg-muted: var(--c-ink-400);
  --c-line: var(--c-ink-200);
  --c-focus: var(--c-accent-deep);
  --c-curtain: var(--c-ink-900);
}

/* --------------------------------------------------------------------------
   REDUCED MOTION — the class is set on <html> by motion-preferences.js, and
   the media query is the no-JS backstop. Durations collapse to ~0 so any
   CSS transition we forgot to guard becomes an instant state change.
   -------------------------------------------------------------------------- */
.reduced-motion {
  --dur-instant: 0.01ms;
  --dur-fast: 0.01ms;
  --dur-base: 0.01ms;
  --dur-slow: 0.01ms;
  --dur-transition-out: 0.01ms;
  --dur-transition-in: 0.01ms;
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-instant: 0.01ms;
    --dur-fast: 0.01ms;
    --dur-base: 0.01ms;
    --dur-slow: 0.01ms;
    --dur-transition-out: 0.01ms;
    --dur-transition-in: 0.01ms;
  }
}


/* ===== 02-reset.css ================================================ */
/* ==========================================================================
   02 — RESET
   Opinionated, minimal. Not normalize.css — only what this site actually needs.
   ========================================================================== */

*,
*::before,
*::after {
  box-sizing: border-box;
}

* {
  margin: 0;
  padding: 0;
}

html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  -moz-tab-size: 2;
  tab-size: 2;
}

body {
  min-height: 100svh;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

/* Media defaults to block so it never sits on a text baseline. */
img,
picture,
video,
canvas,
svg {
  display: block;
  max-width: 100%;
}

img,
video {
  height: auto;
}

/* Form controls do not inherit type by default. Make them. */
input,
button,
textarea,
select {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  background: none;
  border: none;
}

button {
  cursor: pointer;
}

textarea {
  resize: vertical;
}

/* Avoid overflow from long unbroken strings (URLs in case-study copy). */
p,
h1,
h2,
h3,
h4,
h5,
h6,
li,
dd,
dt {
  overflow-wrap: break-word;
}

ul[role="list"],
ol[role="list"] {
  list-style: none;
}

a {
  color: inherit;
  text-decoration: none;
}

table {
  border-collapse: collapse;
}

/* Anchor scrolling accounts for the fixed header. Disabled under reduced
   motion (see tokens) because smooth scroll is itself vestibular motion. */
:where(html:not(.reduced-motion)) {
  scroll-behavior: smooth;
}

html {
  scroll-padding-top: calc(var(--header-height) + var(--sp-md));
}

::selection {
  background-color: var(--c-selection-bg);
  color: var(--c-selection-fg);
}


/* ===== 03-base.css ================================================= */
/* ==========================================================================
   03 — BASE
   Element-level typography and document defaults. No classes here except the
   two accessibility primitives (.skip-link, .visually-hidden).
   ========================================================================== */

html {
  font-family: var(--font-sans);
  font-size: 100%; /* respect the user's browser setting — never px here */
  background-color: var(--c-bg);
  color: var(--c-fg);
}

body {
  font-size: var(--fs-300);
  line-height: var(--lh-body);
  letter-spacing: var(--ls-body);
  font-weight: var(--fw-regular);
  background-color: var(--c-bg);
  color: var(--c-fg);
  /* The persistent WebGL canvas is fixed behind content; body must not paint
     over it, so page-level backgrounds live on sections, not here.
     `transparent` is set by the .has-webgl class in 05-components. */
  transition: background-color var(--dur-base) var(--ease-out),
    color var(--dur-base) var(--ease-out);
}

/* --------------------------------------------------------------------------
   HEADINGS — tight, negative-tracked, weight-driven hierarchy.
   -------------------------------------------------------------------------- */
h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: var(--font-display);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-heading);
  letter-spacing: var(--ls-heading);
  text-wrap: balance;
}

h1 {
  font-size: var(--fs-800);
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-display);
  font-weight: var(--fw-bold);
}

h2 {
  font-size: var(--fs-700);
  line-height: var(--lh-snug);
  letter-spacing: var(--ls-display);
}

h3 {
  font-size: var(--fs-600);
}

h4 {
  font-size: var(--fs-500);
}

h5 {
  font-size: var(--fs-400);
}

h6 {
  font-size: var(--fs-200);
  font-weight: var(--fw-medium);
  text-transform: uppercase;
  letter-spacing: var(--ls-label);
}

p {
  max-width: var(--measure);
  text-wrap: pretty;
}

/* --------------------------------------------------------------------------
   LINKS — inline links in PROSE get an underline.

   `a:not([class])` is the opt-out mechanism: a link with a class is a
   component (card, button, nav item) and owns its own styling, so it never
   picks up prose decoration just because it happens to sit inside a <li>.
   Unclassed links are, by definition, body copy.
   -------------------------------------------------------------------------- */
:where(main p, main li) a:not([class]) {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.2em;
  text-decoration-color: var(--c-accent);
  transition: color var(--dur-fast) var(--ease-out),
    text-decoration-color var(--dur-fast) var(--ease-out);
}

:where(main p, main li) a:not([class]):hover {
  color: var(--c-accent);
}

/* --------------------------------------------------------------------------
   FOCUS — visible, high contrast, never removed.
   :focus-visible only, so mouse users don't see rings, but the fallback below
   covers browsers without :focus-visible support.
   -------------------------------------------------------------------------- */
:focus {
  outline: 2px solid var(--c-focus);
  outline-offset: 3px;
}

:focus:not(:focus-visible) {
  outline: none;
}

:focus-visible {
  outline: 2px solid var(--c-focus);
  outline-offset: 3px;
  border-radius: var(--radius-sm);
}

/* --------------------------------------------------------------------------
   ACCESSIBILITY PRIMITIVES
   -------------------------------------------------------------------------- */
.skip-link {
  position: fixed;
  top: var(--sp-2xs);
  left: var(--sp-2xs);
  z-index: var(--z-skip-link);
  padding: var(--sp-2xs) var(--sp-sm);
  background-color: var(--c-white);
  color: var(--c-ink-900);
  font-size: var(--fs-200);
  font-weight: var(--fw-medium);
  /* Off-screen until focused — kept in the a11y tree, unlike display:none. */
  transform: translateY(calc(-100% - var(--sp-sm)));
  transition: transform var(--dur-fast) var(--ease-out);
}

.skip-link:focus {
  transform: translateY(0);
}

.visually-hidden:not(:focus):not(:active) {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Programmatic focus target after a page transition. transition-manager.js
   focuses <main tabindex="-1">; suppress the ring there since the user did
   not tab to it — the page change is already visible. */
main:focus {
  outline: none;
}


/* ===== 04-layout.css =============================================== */
/* ==========================================================================
   04 — LAYOUT
   Structural primitives only. Mobile-first; breakpoints below.

   BREAKPOINTS (em-based so they respect browser zoom):
     sm   40em   640px   large phone / small tablet
     md   48em   768px   tablet portrait
     lg   64em  1024px   tablet landscape / small laptop  <- main layout shift
     xl   80em  1280px   desktop
     2xl 100em  1600px   large desktop
   ========================================================================== */

/* --------------------------------------------------------------------------
   CONTAINER
   -------------------------------------------------------------------------- */
.container {
  width: 100%;
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.container--text {
  max-width: var(--container-text);
}

.container--flush {
  padding-inline: 0;
}

/* --------------------------------------------------------------------------
   SECTION — the vertical rhythm unit. Sections own their background so the
   persistent canvas can show through where they don't.
   -------------------------------------------------------------------------- */
.section {
  position: relative;
  z-index: var(--z-content);
  padding-block: var(--sp-section);
}

.section--tight {
  padding-block: var(--sp-section-tight);
}

.section--flush {
  padding-block: 0;
}

/* Opaque sections hide the WebGL layer behind them; transparent ones reveal
   it. Defaulting to transparent keeps the canvas the visual base layer. */
.section--solid {
  background-color: var(--c-bg);
}

/* --------------------------------------------------------------------------
   GRID — a single 4/8/12-column grid used site-wide.
   -------------------------------------------------------------------------- */
.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
  gap: var(--grid-gap);
}

@media (min-width: 48em) {
  :root {
    --grid-columns: 8;
  }
}

@media (min-width: 64em) {
  :root {
    --grid-columns: 12;
  }
}

/* Column span helpers. Mobile: everything is full width by default, so only
   the wider breakpoints need spans. */
.grid > * {
  grid-column: span var(--grid-columns);
}

@media (min-width: 64em) {
  .col-3 {
    grid-column: span 3;
  }
  .col-4 {
    grid-column: span 4;
  }
  .col-5 {
    grid-column: span 5;
  }
  .col-6 {
    grid-column: span 6;
  }
  .col-7 {
    grid-column: span 7;
  }
  .col-8 {
    grid-column: span 8;
  }
  .col-9 {
    grid-column: span 9;
  }
  .col-12 {
    grid-column: span 12;
  }
  .col-start-2 {
    grid-column-start: 2;
  }
  .col-start-4 {
    grid-column-start: 4;
  }
  .col-start-7 {
    grid-column-start: 7;
  }
}

/* --------------------------------------------------------------------------
   STACK / CLUSTER — the two flow primitives that replace ad-hoc margins.
   -------------------------------------------------------------------------- */
.stack > * + * {
  margin-block-start: var(--stack-space, var(--sp-md));
}

.stack--sm {
  --stack-space: var(--sp-2xs);
}
.stack--lg {
  --stack-space: var(--sp-lg);
}
.stack--xl {
  --stack-space: var(--sp-xl);
}

.cluster {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--cluster-space, var(--sp-sm));
}

.cluster--between {
  justify-content: space-between;
}

/* --------------------------------------------------------------------------
   PAGE SHELL — header / main / footer scaffolding.
   -------------------------------------------------------------------------- */
.page {
  display: flex;
  flex-direction: column;
  min-height: 100svh;
  position: relative;
  z-index: var(--z-content);
  /* The paper is ALSO painted here, not just on <html>. The header blends with
     mix-blend-mode, and an element only blends with what is painted inside its
     nearest stacking context — which is this one. With the background only on
     <html> (outside .page) the header had nothing to blend against and its
     white ink stayed white on the light page. Using --c-bg means this tracks
     the theme for free: dark case studies and the white contact page both stay
     correct, and the header inverts against them automatically.

     Trade-off: .page is now opaque, so the persistent .webgl-layer (fixed,
     behind .page) would be hidden if a scene were ever switched on. Nothing
     uses one today — every route ships data-scene="" and the home route must
     not paint one at all. To restore scenes later, move .webgl-layer inside
     .page, above this background and below the content. */
  background-color: var(--c-bg);
}

.page__main {
  flex: 1 0 auto;
}

.site-header {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: var(--z-header);
  min-height: var(--header-height);
  display: flex;
  align-items: center;
  /* No background by default — the canvas reads through. A scrolled state
     class can add one later. */
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-md);
  width: 100%;
}

/* --------------------------------------------------------------------------
   ScrollSmoother requires this exact wrapper/content nesting. Both elements
   exist in every page shell even when smoothing is disabled, so the DOM
   shape does not change between capability tiers.
   -------------------------------------------------------------------------- */
#smooth-wrapper {
  position: relative;
}

#smooth-content {
  will-change: transform;
}

/* When smoothing is off (reduced motion / low tier), drop the will-change so
   we are not promoting a full-page layer for nothing. */
.reduced-motion #smooth-content,
.no-smooth-scroll #smooth-content {
  will-change: auto;
}


/* ===== 05-components.css =========================================== */
/* ==========================================================================
   05 — COMPONENTS
   Structural basics only: the engine's own UI (canvas layer, cursor,
   transition curtain) plus bare nav/button skeletons.
   No case-study, card, or hero styling yet — that arrives with the creative.
   ========================================================================== */

/* --------------------------------------------------------------------------
   PERSISTENT WEBGL LAYER
   One fixed canvas, created once, never re-created across page transitions.
   -------------------------------------------------------------------------- */
.webgl-layer {
  position: fixed;
  inset: 0;
  z-index: var(--z-canvas);
  pointer-events: none; /* never steals clicks; Observer listens on window */
  /* CSS fallback is painted here and simply stays visible if WebGL is off. */
  background-color: var(--c-bg);
}

.webgl-layer canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* Fade the canvas in once the first frame has rendered, so a black flash or
   a half-compiled shader is never visible. scene-manager.js adds .is-ready. */
.webgl-layer canvas {
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease-out);
}

.webgl-layer.is-ready canvas {
  opacity: 1;
}

/* FALLBACK — used when WebGL is unsupported, reduced-motion is set, or the
   capability tier is too low. Pure CSS, no image required, but a real cover
   image can be layered on top via --fallback-image. */
.webgl-layer--fallback {
  /* FLAT on purpose, for now. The home illustration is an opaque H.264 video
     blended with `darken`, which is only invisible against a uniform ground —
     any gradient here outlines it as a rectangle. Re-introduce a gradient
     (or a real shader) once the illustration is alpha-capable, or scope it to
     routes that do not carry the video. */
  background-color: var(--c-bg);
}

.webgl-layer--fallback[style*="--fallback-image"] {
  background-image: var(--fallback-image);
  background-size: cover;
  background-position: center;
}

/* When the canvas is live, the body must not paint over it. */
.has-webgl body {
  background-color: transparent;
}

/* --------------------------------------------------------------------------
   MAGNETIC CURSOR
   Two elements: a small dot that tracks 1:1, and a ring that lags and scales.
   Hidden entirely on touch / no-hover devices and under reduced motion.
   -------------------------------------------------------------------------- */
.cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--z-cursor);
  pointer-events: none;
  will-change: transform;
  /* Positioned by GSAP quickTo via translate; centred on its own origin. */
  margin-block-start: -0.5px;
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-out);
}

.cursor.is-active {
  opacity: 1;
}

.cursor__dot {
  width: 6px;
  height: 6px;
  margin: -3px 0 0 -3px;
  border-radius: var(--radius-pill);
  background-color: var(--c-accent);
}

.cursor__ring {
  width: 40px;
  height: 40px;
  margin: -20px 0 0 -20px;
  border: var(--border-width) solid var(--c-ink-400);
  border-radius: var(--radius-pill);
  transition: background-color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out);
}

/* State classes are toggled by cursor.js, scaling is done in GSAP so the two
   systems never fight over `transform`. */
.cursor__ring.is-hovering {
  border-color: var(--c-accent);
  background-color: var(--c-accent-wash);
}

/* Hide the native cursor only when ours is genuinely running. */
.has-custom-cursor,
.has-custom-cursor a,
.has-custom-cursor button,
.has-custom-cursor [data-cursor] {
  cursor: none;
}

/* Kill switches — no custom cursor without a real pointer. */
@media (hover: none), (pointer: coarse) {
  .cursor {
    display: none;
  }
  .has-custom-cursor,
  .has-custom-cursor a,
  .has-custom-cursor button {
    cursor: auto;
  }
}

/* --------------------------------------------------------------------------
   PAGE-TRANSITION CURTAIN
   Owned by transition-manager.js. Inert and non-blocking at rest.
   -------------------------------------------------------------------------- */
.transition-curtain {
  position: fixed;
  inset: 0;
  z-index: var(--z-overlay);
  pointer-events: none;
  background-color: var(--c-curtain);
  transform: scaleY(0);
  transform-origin: bottom center;
  visibility: hidden;
}

.transition-curtain.is-active {
  visibility: visible;
}

/* --------------------------------------------------------------------------
   FILM GRAIN — a fixed, non-interactive noise layer over the whole site.

   The element is oversized (inset: -50%) so the animation can shift the tile
   without ever exposing an edge, and only `transform` is animated: that is
   composited on the GPU with no repaint, unlike animating background-position.

   `steps(1, end)` makes each keyframe HOLD rather than interpolate, so the tile
   jumps between 8 positions per second instead of easing at the display's
   refresh rate. That is what makes it read as film grain (a smooth drift reads
   as a moving texture) and it is also the real battery lever — the compositor
   does work 8 times a second, not 60. Browsers throttle this to nothing when
   the tab is hidden.

   No mix-blend-mode: plain opacity avoids compounding a second full-screen
   blend every frame, and keeps this clear of the header's own blending.
   -------------------------------------------------------------------------- */
.grain {
  position: fixed;
  inset: -50%;
  z-index: var(--z-grain);
  pointer-events: none;
  background-image: url("/assets/noise/grain.png");
  background-repeat: repeat;
  /* The tile is 192px but drawn at 96 — deliberately half size. At 1 CSS px per
     texel the speck becomes a 2x2 block on a retina screen, which reads coarse;
     halving it maps roughly one texel per DEVICE pixel, giving the finest,
     crispest grain the display can show. The 192px source also carries 2.25x
     more unique noise than a 128px one, so there is more detail before the
     tile repeats. */
  background-size: 96px 96px;
  /* Nudged up from 0.035: finer specks average out more per unit area, so the
     texture needs slightly more opacity to keep the same presence. */
  opacity: 0.045;
  animation: grain-boil 1s steps(1, end) infinite;
}

@keyframes grain-boil {
  0%   { transform: translate3d(0, 0, 0); }
  12.5%{ transform: translate3d(-1%, -2%, 0); }
  25%  { transform: translate3d(-3%, 1%, 0); }
  37.5%{ transform: translate3d(2%, -3%, 0); }
  50%  { transform: translate3d(-2%, 2%, 0); }
  62.5%{ transform: translate3d(3%, 1%, 0); }
  75%  { transform: translate3d(1%, -1%, 0); }
  87.5%{ transform: translate3d(-1%, 3%, 0); }
}

/* Keep the texture, stop the flicker. Full-screen flicker is a photosensitivity
   trigger, so this is a requirement rather than a nicety. The class is stamped
   on <html> by js/core/motion-preferences.js. */
.reduced-motion .grain {
  animation: none;
}

/* Non-visual signal for assistive tech / debugging while a route loads. */
.is-transitioning {
  cursor: progress;
}

/* --------------------------------------------------------------------------
   NAV — skeleton only.
   -------------------------------------------------------------------------- */
.site-nav__list {
  display: flex;
  align-items: center;
  gap: var(--sp-md);
  list-style: none;
}

.site-nav__link {
  display: inline-block;
  font-size: var(--fs-200);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--c-fg-muted);
  transition: color var(--dur-fast) var(--ease-out);
}

.site-nav__link:hover {
  color: var(--c-fg);
}

/* aria-current="page" is the single source of truth for the active route —
   transition-manager.js updates it after every swap. */
.site-nav__link[aria-current="page"] {
  color: var(--c-fg);
}

.site-nav__link[aria-current="page"]::after {
  content: "";
  display: block;
  height: 1px;
  margin-block-start: 2px;
  background-color: var(--c-accent);
}

.brand {
  font-size: var(--fs-200);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-heading);
  text-transform: uppercase;
}

/* --------------------------------------------------------------------------
   BUTTON / LINK PRIMITIVES — unstyled-but-structural.
   -------------------------------------------------------------------------- */
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2xs);
  min-height: 2.75rem; /* 44px — touch target floor */
  padding: var(--sp-2xs) var(--sp-md);
  border: var(--border-width) solid currentColor;
  border-radius: var(--radius-none);
  font-size: var(--fs-200);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  transition: background-color var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out);
}

.button--primary {
  background-color: var(--c-accent);
  border-color: var(--c-accent);
  color: var(--c-white);
}

.button--primary:hover {
  background-color: var(--c-accent-bright);
  border-color: var(--c-accent-bright);
  color: var(--c-ink-900);
}

.button--ghost:hover {
  background-color: var(--c-fg);
  color: var(--c-bg);
}

.button[disabled],
.button[aria-disabled="true"] {
  opacity: 0.45;
  cursor: not-allowed;
}

/* --------------------------------------------------------------------------
   MEDIA — figures and cover images used by the case-study template.
   -------------------------------------------------------------------------- */
.media {
  position: relative;
  overflow: hidden;
  background-color: var(--c-line); /* holds space before the image loads */
}

.media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.media--16x9 {
  aspect-ratio: 16 / 9;
}
.media--4x3 {
  aspect-ratio: 4 / 3;
}
.media--1x1 {
  aspect-ratio: 1 / 1;
}


/* ===== 06-sections.css ============================================= */
/* ==========================================================================
   06 — SECTIONS
   Page-level composition. Home page (hero / stage / projects / CTA) plus the
   real site header and mobile menu.

   Runs AFTER components so it can compose them, BEFORE utilities so a
   utility class still wins.
   ========================================================================== */

/* ==========================================================================
   SITE HEADER — logo left, nav right, menu button on small screens.
   ========================================================================== */

.site-header {
  padding-block: var(--sp-md);
  /* Non-blocking by default so the header never eats clicks on the hero;
     the children opt back in. */
  pointer-events: none;
}

.site-header > * {
  pointer-events: auto;
}

/* ---------------------------------------------------------------------------
   SMART INVERSION — the header reads correctly over anything it scrolls past.

   `difference` inverts the header's ink against whatever is painted beneath it,
   per pixel: black ink on the light paper, white ink over a dark project cover
   or the About page's dark band, and it tracks a photograph's own light and
   dark areas. No tagging of "dark sections" — which matters because the covers
   are not uniformly dark (JellyLab's is light, Bobo's is near-black).

   This works because .page (position:relative + z-index) is the stacking
   context holding BOTH this fixed header and the page content, and nothing
   between them isolates. The canvas and the transition curtain live outside
   .page, so they are unaffected.

   Everything is behind @supports for a reason: the technique requires WHITE
   ink, which without blending would be invisible on the light paper. A browser
   that cannot blend simply keeps the normal dark header above.
   --------------------------------------------------------------------------- */
@supports (mix-blend-mode: difference) {
  .site-header {
    mix-blend-mode: difference;
  }

  /* Pure white so `difference` resolves to clean black-on-light / white-on-dark.
     Anything non-greyscale would come back hue-shifted (the accent green would
     read pink), so the header is deliberately monochrome here. */
  .site-header .logo,
  .site-header .site-nav__link,
  .site-header .menu-toggle {
    color: #fff;
  }

  /* Was the accent; green survives `difference` as pink. */
  .site-header .site-nav__link[aria-current="page"]::after {
    background-color: #fff;
  }

  /* Mid grey, so the © stays subdued after inversion instead of flipping to
     near-black against the paper. */
  .site-header .logo__c {
    color: #9a9a9a;
  }

  /* The artwork is black ink on transparency, and `difference` against black is
     a no-op — the logo would simply vanish. Inverting it to white first is what
     makes the blend paint it black on light grounds. */
  .site-header .logo__img {
    filter: invert(1);
  }
}

/* LOGO -------------------------------------------------------------------- */
.logo {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2xs);
  font-family: var(--font-display);
  font-size: var(--fs-400);
  font-weight: var(--fw-bold);
  letter-spacing: -0.04em;
  line-height: 1;
  color: var(--c-fg);
}

/* The signature logotype. Sized by HEIGHT so it keeps its own aspect ratio and
   the header row height never depends on the artwork's proportions. */
.logo__img {
  display: block;
  height: 1.75rem;
  width: auto;
}

.logo__c {
  font-size: 0.55em;
  vertical-align: super;
  font-weight: var(--fw-medium);
  color: var(--c-fg-muted);
}

/* DESKTOP NAV ------------------------------------------------------------- */
.site-nav {
  display: none; /* mobile-first: the menu button is the default */
}

.site-nav__list {
  gap: var(--sp-lg);
}

.site-nav__link {
  position: relative;
  font-size: var(--fs-200);
  letter-spacing: 0.02em;
  text-transform: none; /* the reference uses sentence case, not caps */
  color: var(--c-fg);
}

/* Underline that wipes in from the left on hover. Uses a pseudo-element
   rather than text-decoration so the timing is controllable. */
.site-nav__link::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-end: -0.25em;
  height: 1px;
  background-color: currentColor;
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--dur-fast) var(--ease-out);
}

.site-nav__link:hover::after,
.site-nav__link:focus-visible::after {
  transform: scaleX(1);
}

.site-nav__link[aria-current="page"]::after {
  transform: scaleX(1);
  background-color: var(--c-accent);
}

/* MENU BUTTON ------------------------------------------------------------- */
.menu-toggle {
  display: inline-flex;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 2.75rem; /* 44px touch target */
  height: 2.75rem;
  align-items: flex-end;
  color: var(--c-fg);
}

.menu-toggle__bar {
  display: block;
  width: 22px;
  height: 1.5px;
  background-color: currentColor;
  transition: transform var(--dur-fast) var(--ease-out),
    opacity var(--dur-fast) var(--ease-out), width var(--dur-fast) var(--ease-out);
}

/* nth-child(2), not :last-child — the button's real last child is the
   .visually-hidden "Menu" span, so :last-child never matched the 2nd bar and
   the X only half-formed. */
.menu-toggle__bar:nth-child(2) {
  width: 14px;
}

.menu-toggle[aria-expanded="true"] .menu-toggle__bar:first-child {
  transform: translateY(3.25px) rotate(45deg);
}

.menu-toggle[aria-expanded="true"] .menu-toggle__bar:nth-child(2) {
  width: 22px;
  transform: translateY(-3.25px) rotate(-45deg);
}

/* MOBILE MENU PANEL ------------------------------------------------------- */
.menu-panel {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-header) - 1);
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: var(--sp-2xl) var(--gutter) var(--sp-xl);
  background-color: var(--c-bg);
  /* `visibility` (not display) so the panel can be animated and so focus
     management has something real to move into. */
  visibility: hidden;
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-out),
    visibility 0s linear var(--dur-base);
}

.menu-panel.is-open {
  visibility: visible;
  opacity: 1;
  transition-delay: 0s;
}

.menu-panel__list {
  list-style: none;
  display: grid;
  gap: var(--sp-sm);
}

.menu-panel__link {
  display: flex;
  align-items: baseline;
  justify-content: flex-end; /* right-align each item within its full-width row */
  gap: var(--sp-sm);
  font-family: var(--font-display);
  font-size: var(--fs-700);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-display);
  line-height: 1.1;
  color: var(--c-fg);
}

.menu-panel__index {
  font-family: var(--font-sans);
  font-size: var(--fs-100);
  font-weight: var(--fw-regular);
  letter-spacing: var(--ls-label);
  color: var(--c-fg-muted);
}

.menu-panel__footer {
  margin-block-start: var(--sp-2xl);
  font-size: var(--fs-200);
  color: var(--c-fg-muted);
  text-align: right;
}

/* CLOSE (X) — injected into the panel by nav.js (the header toggle is covered
   by the opaque panel once open, so it can't close it). Sits where the
   hamburger is, so opening reads as the icon becoming an X. */
.menu-panel__close {
  position: absolute;
  inset-block-start: var(--sp-md);
  inset-inline-end: var(--gutter);
  display: grid;
  place-items: center;
  width: 2.75rem; /* 44px touch target, matches the toggle */
  height: 2.75rem;
  color: var(--c-fg);
}

.menu-panel__close-bar {
  grid-area: 1 / 1; /* stack both bars on one cell, then cross them */
  width: 22px;
  height: 1.5px;
  background-color: currentColor;
  transition: transform var(--dur-fast) var(--ease-out);
}

.menu-panel__close-bar:first-child {
  transform: rotate(45deg);
}

.menu-panel__close-bar:last-child {
  transform: rotate(-45deg);
}

/* Quarter-turn on hover — small, deliberate. */
.menu-panel__close:hover .menu-panel__close-bar:first-child,
.menu-panel__close:focus-visible .menu-panel__close-bar:first-child {
  transform: rotate(135deg);
}

.menu-panel__close:hover .menu-panel__close-bar:last-child,
.menu-panel__close:focus-visible .menu-panel__close-bar:last-child {
  transform: rotate(45deg);
}

/* Lock scroll behind the open panel without the iOS jump-to-top bug. */
.menu-open {
  overflow: hidden;
}

@media (min-width: 64em) {
  .site-nav {
    display: block;
  }
  .menu-toggle,
  .menu-panel {
    display: none;
  }
}

/* ==========================================================================
   HOME — STAGE
   Hero and about share ONE section. The video travels from centre (hero) to
   the right (about) as you scroll, revealing the about copy on the left.

   BASELINE (mobile, no-JS, reduced motion): a plain stacked, centred column —
   name, role, video, caption, about copy. Everything visible, nothing pinned.
   The pin + travel is added only on desktop with motion allowed (home.js).
   ========================================================================== */

.stage {
  position: relative;
  z-index: var(--z-content);
}

.stage__pin {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-xl);
  text-align: center;
  padding-block: calc(var(--header-height) + var(--sp-xl)) var(--sp-2xl);
}

.stage__hero {
  width: 100%;
}

.stage__figure {
  width: min(100%, 30rem);
}

.stage__about {
  text-align: start; /* paragraphs read left-aligned even in the centred stack */
}

/* Arrow is a desktop scroll affordance for the travel; hidden in the stack. */
.stage .scroll-cue {
  display: none;
}

/* ---- DESKTOP: pin + travel ----------------------------------------------
   From 64em up, with JS and motion allowed. The pin is a two-column END state
   (about left, video right, vertically centred); home.js starts the video in
   the centre with the about hidden, and scrubs to this resting layout. If JS
   never runs, none of this applies and the stack above is what shows. */
@media (min-width: 64em) {
  .js:not(.reduced-motion) .stage {
    /* Scroll length of the travel. ~1.4 viewports reads as deliberate. */
    height: 240vh;
  }

  .js:not(.reduced-motion) .stage__pin {
    position: sticky;
    inset-block-start: 0;
    height: 100svh;
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    align-content: center;
    gap: 0;
    padding-block: 0;
    text-align: start;
    overflow: hidden;
  }

  /* About: left column, vertically centred. */
  .js:not(.reduced-motion) .stage__about {
    grid-column: 1 / span 5;
    grid-row: 1;
    align-self: center;
  }

  /* Video: right column, vertically centred. home.js translates it to the
     viewport centre for the hero state, then back to here. */
  .js:not(.reduced-motion) .stage__figure {
    grid-column: 8 / -1;
    grid-row: 1;
    align-self: center;
    width: 100%;
    margin: 0;
  }

  /* Hero name/role: a centred overlay pinned to the TOP of the viewport, so it
     sits above the video at rest. NOTE: no grid placement — an absolutely
     positioned grid item is contained by its (vertically-centred) grid cell,
     which would drop the name to mid-height right onto the video. Without a
     grid row its containing block is the pin's padding box, so inset-block-start
     measures from the top. home.js pushes the video down to clear it, and both
     fade/travel apart on scroll. */
  .js:not(.reduced-motion) .stage__hero {
    position: absolute;
    inset-block-start: calc(var(--header-height) + var(--sp-lg));
    inset-inline: 0;
    text-align: center;
    pointer-events: none;
  }

  /* Arrow: bottom-centre affordance. */
  .js:not(.reduced-motion) .stage .scroll-cue {
    display: flex;
    position: absolute;
    inset-inline: 0;
    inset-block-end: var(--sp-lg);
    justify-content: center;
    margin: 0;
  }
}

/* HERO TITLE -------------------------------------------------------------- */
.hero__title {
  font-family: var(--font-display);
  font-size: var(--fs-900);
  font-weight: var(--fw-medium);
  line-height: 0.9;
  letter-spacing: -0.045em;
  text-align: center;
  /* white-space is the reliable way to keep "Yooku Obiri" on one line; the
     masks are inline-block, and text-wrap:nowrap does not always suppress the
     break opportunity at the whitespace between them. */
  white-space: nowrap;
  margin: 0;
}

/* The mask clips its word so the intro reads as type being REVEALED rather
   than type sliding around. The padding/negative-margin pair stops the clip
   from cutting descenders — without it the 'y' in "Yooku" loses its tail. */
.hero__mask {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  padding-block-end: 0.14em;
  margin-block-end: -0.14em;
}

/* Per-word spans exist so GSAP can stagger them. Inline-block is required
   for transforms to apply. */
.hero__word {
  display: inline-block;
  will-change: transform;
}

/* Flex column rather than text-align, so the uppercase tracking (which adds a
   trailing space after the last glyph) can't pull the line off-centre — a
   difference that shows at the desktop title size and not at the mobile one. */
.hero__role {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-block-start: var(--sp-sm);
  /* Base CSS caps every <p> at 68ch; on a wide title block that shrinks this
     below full width and — because a block does not auto-centre — parks it on
     the left. That is why the role looked centred on mobile (container already
     < 68ch) but not on desktop. Lift the cap so align-items can do its job. */
  max-width: none;
  font-size: var(--fs-200);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--c-fg);
  /* Cancel the trailing letter-spacing so the visual centre matches the box
     centre. */
  text-indent: var(--ls-label);
}

.hero__role-sub {
  margin-block-start: var(--sp-3xs);
  font-size: var(--fs-100);
  font-weight: var(--fw-regular);
  font-style: italic;
  letter-spacing: 0.02em;
  text-indent: 0.02em;
  text-transform: none;
  color: var(--c-fg-muted);
}

/* ---- SCROLL CUE ---------------------------------------------------------
   A downward arrow that bobs on a loop. Positioned by the stage rules above
   (hidden in the stacked baseline, bottom-centre on desktop). Decorative. */
.scroll-cue {
  color: var(--c-fg-muted);
}

.scroll-cue__arrow {
  width: 0.9rem;
  height: auto;
  animation: scroll-cue-bob 1.8s var(--ease-in-out) infinite;
}

@keyframes scroll-cue-bob {
  0%,
  100% {
    transform: translateY(-15%);
    opacity: 0.45;
  }
  50% {
    transform: translateY(25%);
    opacity: 1;
  }
}

/* Reduced motion: show the arrow, drop the bob. */
.reduced-motion .scroll-cue__arrow {
  animation: none;
  opacity: 0.6;
}

/* ILLUSTRATION ------------------------------------------------------------ */
.figure {
  margin: 0;
  width: min(100%, 34rem);
  margin-inline: auto;
}

.figure__inner {
  /* Reserve the aspect ratio up front so nothing reflows when the video (or a
     Lottie JSON) swaps in over the poster. Matches the artwork's 720x405. */
  aspect-ratio: 16 / 9;
  width: 100%;
  position: relative;

  /* The video is H.264, which cannot carry alpha, so it arrives as artwork on
     an opaque near-white plate. Two things kill that plate exactly:

       isolation      — makes this element the blending backdrop, instead of
                        the page (#smooth-content is a stacking context with a
                        transparent background, so blending against it is a
                        no-op and the plate stays visible).
       background     — a known, exact token colour to blend against.

     `darken` on the media then takes the per-channel minimum, so the plate
     resolves to exactly var(--c-bg) while every dark line survives. Matching
     --c-paper to the video by eye is not enough on its own: H.264 chroma
     compression drifts the decoded ground a level or two frame to frame. */
  isolation: isolate;
  background-color: var(--c-bg);
}

/* One rule for all three renderers: the poster <img>, the <video>, and the
   <svg> Lottie injects. They are swapped in and out of the same box. */
.figure__media,
.figure__inner svg {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  mix-blend-mode: darken;
}

/* On a dark ground `darken` would erase the artwork entirely (the plate is
   lighter than the backdrop everywhere), so invert instead: the near-white
   plate becomes near-black and the ink becomes white. One asset, both themes. */
[data-theme="dark"] .figure__media {
  mix-blend-mode: lighten;
  filter: invert(1);
}

.figure__caption {
  margin-block-start: var(--sp-sm);
  text-align: center;
  font-size: var(--fs-200);
  color: var(--c-fg-muted);
}

/* ABOUT COPY (in the stage's left column, or stacked on mobile). */
.about {
  max-width: var(--measure-narrow);
}

.about__title {
  font-size: var(--fs-600);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-heading);
  line-height: var(--lh-snug);
  margin-block-end: var(--sp-md);
}

.about__body > * + * {
  margin-block-start: var(--sp-sm);
}

.about__link {
  display: inline-block;
  margin-block-start: var(--sp-lg);
}

/* ==========================================================================
   HOME — PROJECTS
   ========================================================================== */

.projects {
  background-color: var(--c-bg);
  position: relative;
  z-index: var(--z-content);
}

.projects__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-sm);
  margin-block-end: var(--sp-xl);
  padding-block-end: var(--sp-md);
  border-block-end: var(--border-width) solid var(--c-line);
}

.projects__title {
  font-size: var(--fs-500);
  font-weight: var(--fw-medium);
}

.projects__list {
  list-style: none;
  display: grid;
  gap: var(--sp-xl) var(--grid-gap);
}

@media (min-width: 48em) {
  .projects__list {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--sp-lg) var(--grid-gap);
  }
}

/* CARD -------------------------------------------------------------------- */
.card {
  display: block;
  color: var(--c-fg);
}

.card__media {
  position: relative;
  overflow: hidden;
  aspect-ratio: 4 / 5;
  background-color: var(--c-bg-raised);
  border: var(--border-width) solid var(--c-line);
}

.card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--dur-slow) var(--ease-out),
    filter var(--dur-base) var(--ease-out);
}

/* Grayscale by default, colour on hover — keeps the grid monochrome at rest,
   which is the whole point of the palette, without losing the real imagery. */
.card:hover .card__media img,
.card:focus-visible .card__media img {
  transform: scale(1.04);
  filter: grayscale(0);
}

.card__media img {
  filter: grayscale(1);
}

/* Placeholder shown until real cover images exist. Delete .card__placeholder
   and its rules once assets/images/ is populated. */
.card__placeholder {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: var(--fs-100);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--c-fg-muted);
  background-image: repeating-linear-gradient(
    -45deg,
    transparent 0 10px,
    var(--c-line) 10px 11px
  );
}

.card__index {
  display: block;
  margin-block-start: var(--sp-sm);
  font-size: var(--fs-100);
  letter-spacing: var(--ls-label);
  color: var(--c-fg-muted);
}

.card__title {
  margin-block-start: var(--sp-3xs);
  font-size: var(--fs-400);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-heading);
  line-height: 1.25;
}

.card__meta {
  margin-block-start: var(--sp-2xs);
  font-size: var(--fs-200);
  color: var(--c-fg-muted);
}

/* ==========================================================================
   HOME — CTA
   ========================================================================== */

.cta {
  text-align: center;
  border-block-start: var(--border-width) solid var(--c-line);
}

/* Both paragraphs keep their readable max-width but centre as blocks —
   text-align alone can't, because the capped <p> box itself sits left. */
.cta__eyebrow,
.cta__mail {
  margin-inline: auto;
}

.cta__eyebrow {
  margin-block-end: var(--sp-md);
}

.cta__link {
  display: inline-block;
  font-family: var(--font-display);
  font-size: var(--fs-800);
  font-weight: var(--fw-bold);
  line-height: 1;
  letter-spacing: var(--ls-display);
  color: var(--c-fg);
}

/* Underline drawn as a pseudo-element so it can wipe rather than blink. */
.cta__link::after {
  content: "";
  display: block;
  height: 2px;
  margin-block-start: 0.15em;
  background-color: currentColor;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform var(--dur-base) var(--ease-out);
}

.cta__link:hover::after,
.cta__link:focus-visible::after {
  transform: scaleX(1);
}

.cta__mail {
  margin-block-start: var(--sp-lg);
  font-size: var(--fs-300);
  color: var(--c-fg-muted);
}

/* ==========================================================================
   FOOTER
   ========================================================================== */

/* ==========================================================================
   PROJECTS PAGE — page header, filter bar, card grid.
   Reuses the .card component (defined above for the homepage).
   ========================================================================== */

.page-head {
  padding-block: calc(var(--header-height) + var(--sp-2xl)) var(--sp-lg);
}

.page-head__title {
  font-size: var(--fs-800);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-display);
  line-height: var(--lh-tight);
}

.page-head__lead {
  margin-block-start: var(--sp-md);
  max-width: var(--measure);
  font-size: var(--fs-400);
  color: var(--c-fg-muted);
}

/* FILTER BAR ------------------------------------------------------------- */
.filter {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2xs);
  padding-block-end: var(--sp-lg);
  margin-block-end: var(--sp-xl);
  border-block-end: var(--border-width) solid var(--c-line);
}

.filter__chip {
  padding: var(--sp-2xs) var(--sp-md);
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-pill);
  font-size: var(--fs-200);
  color: var(--c-fg-muted);
  transition: color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out),
    background-color var(--dur-fast) var(--ease-out);
}

.filter__chip:hover {
  color: var(--c-fg);
  border-color: var(--c-fg-muted);
}

/* aria-pressed is the single source of truth for the active chip. */
.filter__chip[aria-pressed="true"] {
  color: var(--c-bg);
  background-color: var(--c-fg);
  border-color: var(--c-fg);
}

.filter__count {
  margin-inline-start: var(--sp-xs);
  font-variant-numeric: tabular-nums;
  opacity: 0.6;
}

/* GRID ------------------------------------------------------------------- */
.projects-grid {
  list-style: none;
  display: grid;
  gap: var(--sp-xl) var(--grid-gap);
  padding-block-end: var(--sp-section);
}

@media (min-width: 40em) {
  .projects-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (min-width: 64em) {
  .projects-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--sp-2xl) var(--grid-gap);
  }
}

/* A card hidden by the active filter. JS toggles the attribute; the reveal
   animation is handled in projects.js. */
.projects-grid__item[hidden] {
  display: none;
}

.projects-empty {
  padding-block: var(--sp-2xl);
  color: var(--c-fg-muted);
}

/* Shared placeholder for any media box with no real image yet. Striped so it
   reads as an intentional placeholder, not a broken image. */
.media__placeholder,
.card__placeholder {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: var(--fs-100);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--c-fg-muted);
  background-image: repeating-linear-gradient(
    -45deg,
    transparent 0 10px,
    var(--c-line) 10px 11px
  );
}

/* ==========================================================================
   CASE STUDY — the generated /projects/[slug].html pages.
   ========================================================================== */

/* FLOATING CLOSE — a persistent "×" that returns to the projects index. Fixed
   top-right, below the header so it never overlaps the nav / menu button. It
   lives inside <main>, so it only exists on case studies and ScrollSmoother
   pins it the same way it pins the header. Uses semantic tokens so it adapts to
   a dark case-study theme. */
.case-study__close {
  position: fixed;
  inset-block-start: calc(var(--header-height) + var(--sp-xs));
  inset-inline-end: var(--gutter);
  z-index: var(--z-header);
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-pill);
  background-color: var(--c-bg);
  color: var(--c-fg);
  transition: background-color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out);
}

.case-study__close:hover,
.case-study__close:focus-visible {
  background-color: var(--c-accent);
  border-color: var(--c-accent);
  color: var(--c-white);
}

/* Two bars crossed in one grid cell = the ×. */
.case-study__close-bar {
  grid-area: 1 / 1;
  width: 16px;
  height: 1.5px;
  background-color: currentColor;
  transition: transform var(--dur-fast) var(--ease-out);
}

.case-study__close-bar:nth-child(1) {
  transform: rotate(45deg);
}

.case-study__close-bar:nth-child(2) {
  transform: rotate(-45deg);
}

.case-study__close:hover .case-study__close-bar:nth-child(1),
.case-study__close:focus-visible .case-study__close-bar:nth-child(1) {
  transform: rotate(135deg);
}

.case-study__close:hover .case-study__close-bar:nth-child(2),
.case-study__close:focus-visible .case-study__close-bar:nth-child(2) {
  transform: rotate(45deg);
}

/* Hint label, revealed to the LEFT on hover/focus. Absolute so it never widens
   the button's hit area. */
.case-study__close-label {
  position: absolute;
  inset-inline-end: 100%;
  margin-inline-end: var(--sp-sm);
  white-space: nowrap;
  font-size: var(--fs-100);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--c-fg-muted);
  opacity: 0;
  transform: translateX(var(--sp-2xs));
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}

.case-study__close:hover .case-study__close-label,
.case-study__close:focus-visible .case-study__close-label {
  opacity: 1;
  transform: none;
}

.case-study__hero {
  padding-block: calc(var(--header-height) + var(--sp-2xl)) var(--sp-lg);
  max-width: var(--container-max);
}

.case-study__hero h1 {
  font-size: var(--fs-800);
  max-width: 20ch;
}

.case-study__hero .lead {
  margin-block-start: var(--sp-md);
}

.case-study__meta {
  margin-block-start: var(--sp-xl);
  padding-block-start: var(--sp-md);
  border-block-start: var(--border-width) solid var(--c-line);
  row-gap: var(--sp-md);
}

.case-study__meta dt {
  margin-block-end: var(--sp-3xs);
}

.case-study__cover {
  margin-block: var(--sp-lg) var(--sp-section);
}

/* SOLUTION — image cards. */
.case-study__solution-head {
  margin-block-end: var(--sp-lg);
}

.case-study__solution {
  display: grid;
  gap: var(--grid-gap);
}

@media (min-width: 48em) {
  .case-study__solution {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  /* A "full" layout image spans both columns. */
  .case-study__figure--full {
    grid-column: 1 / -1;
  }
}

.case-study__figure {
  margin: 0;
}

.case-study__figure figcaption {
  margin-block-start: var(--sp-2xs);
  font-size: var(--fs-200);
  color: var(--c-fg-muted);
}

/* PROCESS — ordered steps. */
.case-study__process {
  counter-reset: step;
}

.case-study__step-title {
  font-size: var(--fs-400);
  font-weight: var(--fw-medium);
  margin-block-end: var(--sp-2xs);
}

.case-study__step-title .eyebrow {
  display: block;
  margin-block-end: var(--sp-3xs);
}

/* RESULTS — the hard-number row in the outcome block. */
.results {
  margin-block-start: var(--sp-lg);
  padding-block-start: var(--sp-md);
  border-block-start: var(--border-width) solid var(--c-line);
  gap: var(--sp-xl);
}

.results__item {
  display: flex;
  flex-direction: column-reverse;
  gap: var(--sp-3xs);
}

.results__value {
  font-family: var(--font-display);
  font-size: var(--fs-600);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-heading);
  line-height: 1;
}

/* PROJECT PAGER — previous / next studies. Each link stacks a direction arrow
   over the project name; prev sits at the start edge, next at the end edge. */
.case-study__pager {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-lg);
  margin-block-start: var(--sp-section);
  padding-block: var(--sp-xl);
  border-block: var(--border-width) solid var(--c-line);
}

.case-study__pager-link {
  display: inline-flex;
  flex-direction: column;
  gap: var(--sp-2xs);
  min-width: 0; /* let long names wrap instead of forcing the row wider */
  color: var(--c-fg);
}

.case-study__pager-link--prev {
  align-items: flex-start;
  text-align: start;
}

.case-study__pager-link--next {
  align-items: flex-end;
  text-align: end;
}

.case-study__pager-arrow {
  font-size: var(--fs-500);
  line-height: 1;
  transition:
    transform var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out);
}

.case-study__pager-link--prev:hover .case-study__pager-arrow {
  transform: translateX(-0.2em);
  color: var(--c-accent);
}

.case-study__pager-link--next:hover .case-study__pager-arrow {
  transform: translateX(0.2em);
  color: var(--c-accent);
}

.case-study__pager-title {
  font-family: var(--font-display);
  font-size: var(--fs-500);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-display);
  line-height: var(--lh-snug);
  overflow-wrap: anywhere;
  transition: color var(--dur-fast) var(--ease-out);
}

.case-study__pager-link:hover .case-study__pager-title {
  color: var(--c-accent);
}

/* ==========================================================================
   ABOUT PAGE — hand-authored (about.html). Deliberately low-density: the only
   display-scale text is the "About" title; every subsection is marked with a
   small eyebrow (h2.eyebrow in the markup), matching the section-name
   treatment used site-wide. New block namespaces so nothing collides with the
   home stage's .about.* / .figure.* classes.
   ========================================================================== */

/* ABOUT LEDE — title + portrait + intro + "What I do" + "How I work".
   Below 64em: a plain vertical stack in DOM order (this base block). From 64em
   up: one ~100vh grid so the whole opening reads on the first screen. */
.about-lede {
  /* Clear the fixed header (the old page-head carried this offset). */
  padding-block: calc(var(--header-height) + var(--sp-lg)) var(--sp-section);
}

.about-lede__grid {
  display: grid;
  gap: var(--sp-xl);
}

/* Intro (bio + status) and "What I do" travel together as the aside beside the
   portrait on desktop, stacked on mobile. */
.about-lede__aside {
  display: flex;
  flex-direction: column;
  gap: var(--sp-lg);
}

.about-lede__intro .lead {
  margin-block-end: var(--sp-lg);
}

/* Tighter line spacing than the site default across the About body copy. */
.about-lede__intro .lead,
.approach__card p,
.exp-row__note,
.about-cta__personal {
  line-height: 1.45;
}

/* DESKTOP: one screen. A 12-col grid places the big title across the top, the
   square photo at left, the intro + "What I do" beside it, and the "How I work"
   cards full-width beneath. The composition is vertically centred in the
   viewport, below the fixed header. */
@media (min-width: 64em) {
  .about-lede {
    display: flex;
    align-items: center;
    min-height: 100svh;
    padding-block: var(--header-height) var(--sp-md);
  }

  .about-lede .container {
    width: 100%;
  }

  .about-lede__grid {
    grid-template-columns: repeat(12, minmax(0, 1fr));
    column-gap: var(--grid-gap);
    row-gap: var(--sp-lg);
    align-items: start;
  }

  .about-lede__title {
    grid-column: 1 / -1;
    grid-row: 1;
  }

  /* Square photo, left. Held to 4 columns so its height leaves room for the
     rows above and below to all land on one screen. */
  .about-lede__portrait {
    grid-column: 1 / span 4;
    grid-row: 2;
  }

  /* Bio + status + "What I do": top-aligned with the top of the photo. */
  .about-lede__aside {
    grid-column: 6 / -1;
    grid-row: 2;
    gap: var(--sp-md);
  }

  /* "How I work" spans full width beneath, so the three cards stay roomy. */
  .about-lede__work {
    grid-column: 1 / -1;
    grid-row: 3;
  }
}

/* STATUS PILL ------------------------------------------------------------- */
.status-pill {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2xs);
  max-width: max-content; /* hug content — overrides the base <p> measure cap */
  padding: var(--sp-2xs) var(--sp-md);
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-pill);
  font-size: var(--fs-200);
  color: var(--c-fg-muted);
}

.status-pill__loc {
  color: var(--c-fg);
  font-weight: var(--fw-medium);
}

.status-pill__sep {
  color: var(--c-line);
}

/* A live-availability dot with a soft outward pulse. */
.status-pill__dot {
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: var(--radius-pill);
  background-color: var(--c-accent);
  animation: status-pulse 2.4s var(--ease-in-out) infinite;
}

@keyframes status-pulse {
  0% {
    box-shadow: 0 0 0 0 rgba(0, 136, 70, 0.35);
  }
  70% {
    box-shadow: 0 0 0 0.6rem rgba(0, 136, 70, 0);
  }
  100% {
    box-shadow: 0 0 0 0 rgba(0, 136, 70, 0);
  }
}

.reduced-motion .status-pill__dot {
  animation: none;
}

/* PORTRAIT ---------------------------------------------------------------- */
.portrait {
  margin: 0;
}

.portrait__frame {
  position: relative; /* containing block for .media__placeholder / .portrait__img */
  aspect-ratio: 1 / 1;
  border: var(--border-width) solid var(--c-line);
  background-color: var(--c-bg-raised);
  overflow: hidden;
}

/* Real portrait, once it exists. Monochrome at rest to match the palette. */
.portrait__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: grayscale(1);
}

.portrait__caption {
  margin-block-start: var(--sp-2xs);
  font-size: var(--fs-200);
  color: var(--c-fg-muted);
}

/* HOW I WORK — three steps as cards with a flow arrow between them. Titles
   stay at body scale (fs-400) so nothing competes with the "About" title. */
.approach__flow {
  display: flex;
  flex-direction: column;
  gap: var(--sp-sm);
  margin-block-start: var(--sp-md);
}

@media (min-width: 48em) {
  .approach__flow {
    flex-direction: row;
    align-items: stretch; /* cards share the tallest card's height */
  }
}

.approach__card {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--sp-2xs);
  padding: var(--sp-md);
  border: var(--border-width) solid var(--c-line);
  background-color: var(--c-bg-raised);
}

.approach__step {
  font-size: var(--fs-100);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-label);
  color: var(--c-accent);
}

.approach__title {
  font-size: var(--fs-400);
  font-weight: var(--fw-medium);
}

.approach__card p {
  color: var(--c-fg-muted);
}

/* Flow arrow: points down between stacked cards (mobile) and right between
   columns (desktop). Decorative — aria-hidden in the markup. */
.approach__arrow {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  color: var(--c-fg-muted);
  transform: rotate(90deg);
}

@media (min-width: 48em) {
  .approach__arrow {
    transform: none;
  }
}

/* WHAT I DO — kept inline and minimal, not a heavy grid. */
.about-lede__do .inline-list {
  margin-block-start: var(--sp-sm);
}

.inline-list {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--sp-2xs) var(--sp-sm);
  font-size: var(--fs-400);
}

/* Middot between items so the inline set reads as a list, not stray words. */
.inline-list li {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2xs) var(--sp-sm);
}

.inline-list li:not(:last-child)::after {
  content: "·";
  color: var(--c-fg-muted);
}

/* EXPERIENCE -------------------------------------------------------------- */
.experience__list {
  list-style: none;
  margin-block-start: var(--sp-md);
}

.exp-row {
  display: grid;
  gap: var(--sp-3xs);
  padding-block: var(--sp-md);
  border-block-start: var(--border-width) solid var(--c-line);
}

/* Close the table with a bottom rule on the final row. */
.exp-row:last-child {
  border-block-end: var(--border-width) solid var(--c-line);
}

/* Compact two-column table from md up: year, then role on one line. */
@media (min-width: 48em) {
  .exp-row {
    grid-template-columns: 8rem minmax(0, 1fr);
    align-items: baseline;
    gap: var(--grid-gap);
  }
}

.exp-row__year {
  font-size: var(--fs-200);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--ls-label);
  color: var(--c-fg-muted);
}

.exp-row__role {
  font-size: var(--fs-400);
  font-weight: var(--fw-medium);
  transition: color var(--dur-fast) var(--ease-out);
}

.exp-row:hover .exp-row__role {
  color: var(--c-accent);
}

.exp-row__note {
  color: var(--c-fg-muted);
}

/* PERSONAL + CTA ---------------------------------------------------------- */
/* A full-bleed dark band. The section carries data-theme="dark", which flips
   the semantic tokens for everything inside it, so --c-bg resolves to the dark
   ink here and the whole closing block reads as a distinct surface between the
   light page above and the light footer below. */
.about-cta {
  text-align: center;
  background-color: var(--c-bg);
}

.about-cta__personal {
  max-width: var(--measure);
  margin-inline: auto;
  margin-block-end: var(--sp-2xl);
  font-size: var(--fs-400);
  color: var(--c-fg-muted);
}

.about-cta__title {
  margin-block: var(--sp-2xs) var(--sp-lg);
  font-size: var(--fs-500);
  font-weight: var(--fw-medium);
  /* Explicit so it re-resolves against the section's data-theme="dark" tokens.
     Headings otherwise inherit body's already-computed (light) colour and would
     render dark-on-dark here. */
  color: var(--c-fg);
}

.about-cta__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--sp-sm);
}

/* ==========================================================================
   ARCHIVE PAGE — a masonry "scrapbook" of posters and general design work.
   Hand-authored (archive.html): a full-height hero, then a CSS multi-column
   gallery whose card heights follow each image's ratio.
   ========================================================================== */

/* HERO — "Archive" label top-centre, a large (not bold) statement in the
   middle, a scroll cue at the bottom. One viewport tall. */
.archive-hero {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 100svh;
  padding-block: calc(var(--header-height) + var(--sp-lg)) var(--sp-lg);
  text-align: center;
}

/* "Archive" reuses .page-head__title for the exact section-header format used
   site-wide; this just removes the default heading margin inside the flex hero. */
.archive-hero__heading {
  margin: 0;
}

/* Large but deliberately NOT bold — a quiet, editorial statement. The auto
   block margins push it to the vertical centre and shove the cue to the base. */
.archive-hero__title {
  margin-block: auto;
  max-width: 22ch;
  font-size: var(--fs-700);
  font-weight: var(--fw-regular);
  line-height: var(--lh-snug);
  letter-spacing: var(--ls-heading);
}

.archive-hero__cue {
  display: flex;
  justify-content: center;
}

/* GALLERY — CSS multi-column masonry. Column widths are equal; each card's
   height follows its media ratio, so the grid reads organic without a rigid
   row structure. Order flows down each column, then to the next. */
.archive-grid {
  list-style: none;
  column-count: 2;
  column-gap: var(--grid-gap);
  padding-block-end: var(--sp-section);
}

@media (min-width: 48em) {
  .archive-grid {
    column-count: 3;
  }
}

@media (min-width: 64em) {
  .archive-grid {
    column-count: 4;
  }
}

.archive-card {
  position: relative;
  /* Keep each card whole within its column; the bottom margin is the vertical
     gap (column-gap only spaces columns horizontally). */
  break-inside: avoid;
  margin-block-end: var(--grid-gap);
}

/* Hover-lift lives on the inner element, NOT the card: the GSAP reveal sets an
   inline transform on .archive-card[data-animate], which would otherwise win
   over a :hover transform on the same element. */
.archive-card__inner {
  display: block;
  transform-origin: center;
  transition: transform var(--dur-base) var(--ease-out);
}

.archive-card:hover {
  z-index: 1; /* lift above neighbours while scaled */
}

.archive-card:hover .archive-card__inner {
  transform: scale(1.03);
}

.archive-card__media {
  position: relative; /* containing block for the placeholder */
  overflow: hidden;
  background-color: var(--c-bg-raised);
}

/* Real artwork fills the column width; height follows the image's own ratio —
   this is what makes the masonry organic. */
.archive-card__img {
  display: block;
  width: 100%;
  height: auto;
}

/* Meta row: year bottom-left, category + name stacked bottom-right. */
.archive-card__meta {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--sp-sm);
  margin-block-start: var(--sp-2xs);
}

.archive-card__year {
  font-size: var(--fs-100);
  font-variant-numeric: tabular-nums;
  color: var(--c-fg-muted);
}

.archive-card__detail {
  display: flex;
  flex-direction: column;
  text-align: right;
}

.archive-card__category {
  font-size: var(--fs-100);
  color: var(--c-fg-muted);
}

.archive-card__name {
  font-size: var(--fs-200);
  color: var(--c-fg);
}

/* ==========================================================================
   CONTACT PAGE — a single, static (non-scrolling) screen: the "Let's Talk"
   statement, a one-line lead, the discovery-call CTA and the social icons.
   Desktop puts every one of those on the container's left gutter; mobile
   centres the stack.
   ========================================================================== */

/* Full-white ground, kept from the original design. Overriding --c-bg on
   <body> cascades to the fixed
   .webgl-layer that actually paints the page. */
body[data-page="contact"] {
  --c-bg: var(--c-white);
  --c-bg-raised: var(--c-white);
}

/* Contact is a single, STATIC screen — it never scrolls. #smooth-content
   becomes a viewport-tall flex column (the header is fixed / out of flow), the
   main grows to fill it, the footer pins to the bottom, and overflow is clipped
   so nothing spills into a scroll. Scoped to the contact route only, so every
   other page keeps normal document flow. */
body[data-page="contact"] {
  overflow: hidden;
}
body[data-page="contact"] #smooth-content {
  height: 100svh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
body[data-page="contact"] .page__main {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* HERO — fills the space between the fixed header and the footer; content
   vertically centred. flex:1 (not min-height:100svh) so it shares the column
   with the footer instead of forcing a scroll. */
.contact-hero {
  display: grid;
  align-items: center;
  flex: 1 1 auto;
  min-height: 0;
  padding-block: calc(var(--header-height) + var(--sp-lg)) var(--sp-2xl);
  position: relative;
  z-index: var(--z-content);
}

.contact-hero__grid {
  display: grid;
  gap: var(--sp-xl);
  align-items: center;
}

/* "Let's Talk" — large, Title case, bold. No top margin: nothing sits above it
   now (the eyebrow was removed). */
.contact-hero__title {
  margin-block: 0 var(--sp-lg);
  text-align: center;
  font-size: var(--fs-800);
  font-weight: var(--fw-bold);
  line-height: 0.95;
  letter-spacing: -0.02em;
}

/* Each line is a clip mask; the inner span is what slides. The padding /
   negative-margin pair keeps descenders (the apostrophe tail, the 'y' hooks in
   other words) from being shaved by the mask. */
.contact-hero__line {
  display: block;
  overflow: hidden;
  padding-block-end: 0.08em;
  margin-block-end: -0.08em;
}

.contact-hero__line-inner {
  display: block;
  will-change: transform;
}

/* Pre-hide the lines for the reveal ONLY when JS is alive and motion allowed —
   same guard as [data-animate], so a JS/GSAP failure never hides the title. */
.js:not(.reduced-motion) .contact-hero__line-inner {
  transform: translateY(110%);
}

.contact-hero__body {
  text-align: center;
}

/* Measure-capped, so centre the BLOCK too — not just the text inside it. */
.contact-hero__lead {
  margin-inline: auto;
  max-width: 42ch;
  line-height: 1.5;
  color: var(--c-fg-muted);
}

.contact-actions {
  margin-block-start: var(--sp-lg);
  text-align: center;
}

/* MOBILE (single column) — centre the stack and make the title the focal
   point. The animation is shrunk so a larger, centred "Let's Talk" AND the
   animation both land inside one screen (the hero already centres the stack
   vertically via align-items:center + min-height:100svh). */
/* Desktop: ONE centred column, mirroring the mobile treatment — title, lead,
   CTA and the social icons all share the page's centre line. (It was briefly
   left-aligned to the gutter; centred reads better on a wide screen.) The
   statement is 30% larger than the shared h1 step. */
@media (min-width: 64em) {
  .contact-hero__grid {
    align-items: center;
    row-gap: var(--sp-lg);
  }
  .contact-hero__title {
    font-size: calc(var(--fs-800) * 1.3);
  }
}

@media (max-width: 63.999em) {
  /* The contact screen is LOCKED (no scroll), so the mobile stack has a hard
     budget: header clearance + title + animation + lead + CTA + social icons +
     footer must all fit one viewport. These values are tuned to that budget.
     With the previous spacing the stack ran ~240px long, so the CTA overflowed
     the hero box and the footer — which paints an opaque background — hid the
     button completely. The animation is capped in svh as well as rem so short
     phones (375x667) shrink it instead of pushing the CTA off-screen. The
     title stays deliberately large. */
  .contact-hero {
    padding-block: calc(var(--header-height) + var(--sp-sm)) var(--sp-sm);
  }

  .contact-hero__grid {
    gap: var(--sp-md);
  }

  .contact-hero__title {
    font-size: clamp(3.5rem, 15vw, 5rem);
    margin-block-end: var(--sp-md);
  }

  .contact-actions {
    margin-block-start: var(--sp-md);
  }
}

/* EMAIL BUTTON — says "Email"; hover slides up to reveal the address, fills
   with the accent, and nudges the arrow. Magnetic via [data-magnetic]. */
.email-button {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-md);
  padding: var(--sp-sm) var(--sp-lg);
  border: var(--border-width) solid var(--c-fg);
  border-radius: var(--radius-pill);
  color: var(--c-fg);
  overflow: hidden;
  transition: background-color var(--dur-base) var(--ease-out),
    border-color var(--dur-base) var(--ease-out),
    color var(--dur-base) var(--ease-out);
}

.email-button:hover,
.email-button:focus-visible {
  background-color: var(--c-accent);
  border-color: var(--c-accent);
  color: var(--c-white);
}

/* Single static label (the discovery-call CTA). Same type as the old email
   faces; no hover slide — the pill fill + arrow nudge carry the interaction. */
.email-button__label {
  display: block;
  font-size: var(--fs-300);
  font-weight: var(--fw-medium);
  letter-spacing: 0.01em;
  white-space: nowrap;
}

/* Single-line window over the two stacked faces. Its width follows the wider
   face (the address), so the reveal never reflows the button. */
.email-button__viewport {
  position: relative;
  display: block;
  height: 1.5em;
  overflow: hidden;
  font-size: var(--fs-300);
  font-weight: var(--fw-medium);
  letter-spacing: 0.01em;
}

.email-button__face {
  display: block;
  height: 1.5em;
  line-height: 1.5em;
  white-space: nowrap;
  transition: transform var(--dur-base) var(--ease-out);
}

.email-button:hover .email-button__face,
.email-button:focus-visible .email-button__face {
  transform: translateY(-100%);
}

.email-button__arrow {
  display: inline-flex;
  flex: none;
  transition: transform var(--dur-base) var(--ease-out);
}

.email-button__arrow svg {
  width: 1.15em;
  height: 1.15em;
}

.email-button:hover .email-button__arrow,
.email-button:focus-visible .email-button__arrow {
  transform: translateX(4px);
}

/* SOCIAL BAR — monoline icons pinned to the bottom of the static contact
   screen, above the footer. Centred; icons inherit the muted ink and lift to
   the accent on hover/focus. Each link is a 44px touch target. */
.contact-social-bar {
  /* Both sizes live here so the optical offset below stays in sync with them. */
  --social-hit: 2.75rem;   /* invisible 44px touch target */
  --social-glyph: 1.05rem; /* the visible mark */
  display: flex;
  justify-content: center;
  padding-block-end: var(--sp-lg);
}

/* Left-aligned from 64em up. The bar is a .container, so flex-start lands the
   icons on the same left gutter as the rest of the page content. Mobile keeps
   the centred default above, matching the centred mobile stack. This must sit
   AFTER the base rule: a media query adds no specificity, so declaring it
   earlier let `justify-content: center` win. */
.social-links {
  display: flex;
  gap: var(--sp-2xs);
  list-style: none;
  margin: 0;
  padding: 0;
}

.social-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--social-hit);
  height: var(--social-hit);
  color: var(--c-fg-muted);
  transition:
    color var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}

.social-link:hover,
.social-link:focus-visible {
  color: var(--c-accent);
  transform: translateY(-2px);
}

/* Small and subtle. The <svg> elements ALSO carry width/height attributes in
   the markup, so they stay this size even if this stylesheet is stale or fails
   to load — without an intrinsic size a bare <svg> falls back to the browser
   default (~300x150) and renders enormous. */
.social-link svg {
  width: var(--social-glyph);
  height: var(--social-glyph);
}

/* DETAILS — plain address, socials, location. */
.contact-details {
  padding-block-start: var(--sp-xl);
  padding-block-end: var(--sp-section);
  border-block-start: var(--border-width) solid var(--c-line);
}

.contact-details__grid {
  display: grid;
  gap: var(--sp-lg);
}

@media (min-width: 48em) {
  .contact-details__grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--grid-gap);
  }
}

.contact-detail .eyebrow {
  margin-block-end: var(--sp-sm);
}

.contact-email-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-sm);
}

.contact-email {
  font-size: var(--fs-400);
  color: var(--c-fg);
  transition: color var(--dur-fast) var(--ease-out);
}

.contact-email:hover {
  color: var(--c-accent);
}

.contact-copy {
  padding: var(--sp-3xs) var(--sp-sm);
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-pill);
  font-size: var(--fs-100);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--c-fg-muted);
  transition: color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out);
}

.contact-copy:hover {
  color: var(--c-fg);
  border-color: var(--c-fg-muted);
}

/* contact.js toggles this after a successful clipboard write. */
.contact-copy.is-copied {
  color: var(--c-accent);
  border-color: var(--c-accent);
}

.contact-socials {
  list-style: none;
  display: grid;
  gap: var(--sp-2xs);
}

.contact-social {
  display: inline-block;
  font-size: var(--fs-400);
  color: var(--c-fg);
  transition: color var(--dur-fast) var(--ease-out);
}

.contact-social:hover {
  color: var(--c-accent);
}

.contact-detail__value {
  font-size: var(--fs-400);
  color: var(--c-fg-muted);
}


/* ===== 07-utilities.css ============================================ */
/* ==========================================================================
   06 — UTILITIES
   Last in the cascade. Single-purpose, no nesting, safe to add to any element.
   ========================================================================== */

/* TEXT ------------------------------------------------------------------- */
.text-muted {
  color: var(--c-fg-muted);
}
.text-accent {
  color: var(--c-accent-bright);
}
[data-theme="light"] .text-accent {
  color: var(--c-accent-deep);
}

.eyebrow {
  display: block;
  font-size: var(--fs-100);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--c-fg-muted);
}

.lead {
  font-size: var(--fs-400);
  line-height: var(--lh-loose);
  max-width: var(--measure);
}

.measure {
  max-width: var(--measure);
}
.measure-narrow {
  max-width: var(--measure-narrow);
}

.text-center {
  text-align: center;
}

/* LAYOUT ----------------------------------------------------------------- */
.flow-none > * + * {
  margin-block-start: 0;
}

.full-bleed {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
}

.hide {
  display: none !important;
}

@media (max-width: 47.999em) {
  .hide-below-md {
    display: none !important;
  }
}

@media (min-width: 64em) {
  .hide-above-lg {
    display: none !important;
  }
}

/* MOTION ----------------------------------------------------------------- */

/* Elements GSAP will animate in. Pre-hidden in CSS so there is no flash of
   un-animated content — but ONLY when JS is confirmed alive (.js on <html>,
   set inline in <head>) and motion is allowed. Without that guard, a JS
   failure would leave the page permanently invisible. */
.js:not(.reduced-motion) [data-animate] {
  opacity: 0;
}

/* Reduced motion: reveal immediately, no transform, no stagger. */
.reduced-motion [data-animate] {
  opacity: 1 !important;
  transform: none !important;
}

/* Belt-and-braces for anything not routed through GSAP. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* PRINT ------------------------------------------------------------------ */
@media print {
  .webgl-layer,
  .cursor,
  .transition-curtain,
  .grain,
  .site-header,
  .skip-link {
    display: none !important;
  }

  html,
  body {
    background: #fff !important;
    color: #000 !important;
  }
}
