/* ═══════════════════════════════════════════════════════════════
   Leroy Matthews — Portfolio
   Refined minimalism: paper white, ink black, serif voice,
   grotesque utility, pixel loader.
   ═══════════════════════════════════════════════════════════════ */

:root {
  --paper: #ffffff;
  --ink: #131311;
  --ink-soft: #6b6b66;
  --ink-faint: #b8b8b2;
  --ink-caption: #9b9b9b;
  --line: #e4e4de;
  --pill: #f0f0ec;
  --pill-hover: #e8e8e2;

  --font-serif: 'Teodor', 'Instrument Serif', Georgia, serif;
  --font-sans: 'Familjen Grotesk', 'Helvetica Neue', Arial, sans-serif;
  --font-pixel: 'VT323', monospace;

  --nav-h: 76px;
  --page-pt: clamp(40px, 8vh, 90px);
  --gutter: clamp(20px, 4vw, 56px);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);

  /* page-slide timing — all three knobs in one place */
  --slide-delay: 1s;     /* hold after the click, before anything moves */
  --slide-dur: 0.9s;
  --slide-ease: cubic-bezier(0.65, 0, 0.35, 1); /* eased both ends, no snap */
}

/* Teodor — the licensed web release, replacing the evaluation .otf (which is
   now parked in /fonts, outside the deployed tree; see .vercelignore).
   Everything on the site renders Regular, exactly as it did with the trial —
   Medium and Bold are registered so that anything that ever asks for them
   gets the real cut instead of a synthesised one, and neither is downloaded
   until something does. */
@font-face {
  font-family: 'Teodor';
  src: url('/assets/fonts/Teodor-Regular.woff2') format('woff2'),
       url('/assets/fonts/Teodor-Regular.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Teodor';
  src: url('/assets/fonts/Teodor-Medium.woff2') format('woff2'),
       url('/assets/fonts/Teodor-Medium.woff') format('woff');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Teodor';
  src: url('/assets/fonts/Teodor-Bold.woff2') format('woff2'),
       url('/assets/fonts/Teodor-Bold.woff') format('woff');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* Cross-document view transitions: opt every page into the platform's
   navigation transition so elements sharing a view-transition-name
   (the nav pills) smart-animate across full page loads. No-op in
   browsers that don't support it yet — navigation just proceeds as
   normal, so this is pure progressive enhancement. */
@view-transition {
  navigation: auto;
}
/* Everything in a page transition — the sliding halves and the nav pills
   morphing — shares one clock so it moves as a single gesture. The delay
   holds the outgoing page still for a beat after the click, which also
   buys the incoming document time to paint before it slides into view. */
::view-transition-group(*) {
  animation-duration: var(--slide-dur, 0.9s);
  animation-delay: var(--slide-delay, 1s);
  animation-timing-function: var(--slide-ease, cubic-bezier(0.65, 0, 0.35, 1));
}

/* Page slide. The pages sit on a map — Home | Work | About left-to-right,
   with a project page below that row — and a transition moves the viewport
   across it, so the incoming page always enters from the side it occupies:

     data-vt      navigation                    new page enters from
     right        Home → Work → About           the right
     left         About → Work → Home           the left
     bottom       into a project page           the bottom
     top          back out to Home/Work/About   the top

   (data-vt names the edge the INCOMING page arrives from — the outgoing
   page always leaves through the opposite edge.) js/page-transition.js
   picks it and stamps it on <html> before navigating; the inline script in
   each <head> re-applies it on the incoming document so both halves agree.
   "right" is the unstamped default. Browser back/forward fires no click, so
   page-transition.js derives those from the same map on `pageswap` — a
   traversal is the mirror of the move that brought you here.
   The nav is held out of this: its wordmark and pills each carry a
   view-transition-name, so they're lifted out of the root snapshot and stay
   anchored (the pills keep smart-animating) while the page slides beneath. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--slide-dur, 0.9s);
  animation-delay: var(--slide-delay, 1s);
  animation-timing-function: var(--slide-ease, cubic-bezier(0.65, 0, 0.35, 1));
  animation-fill-mode: both;
  /* the UA default is plus-lighter, which would wash out the overlap
     while the two pages cross */
  mix-blend-mode: normal;
}
::view-transition-old(root) { animation-name: pageExitLeft; }
::view-transition-new(root) { animation-name: pageEnterRight; }
html[data-vt="left"]::view-transition-old(root)   { animation-name: pageExitRight; }
html[data-vt="left"]::view-transition-new(root)   { animation-name: pageEnterLeft; }
html[data-vt="bottom"]::view-transition-old(root) { animation-name: pageExitTop; }
html[data-vt="bottom"]::view-transition-new(root) { animation-name: pageEnterBottom; }
html[data-vt="top"]::view-transition-old(root)    { animation-name: pageExitBottom; }
html[data-vt="top"]::view-transition-new(root)    { animation-name: pageEnterTop; }

@keyframes pageExitLeft    { to   { transform: translateX(-100%); } }
@keyframes pageEnterRight  { from { transform: translateX(100%); } }
@keyframes pageExitRight   { to   { transform: translateX(100%); } }
@keyframes pageEnterLeft   { from { transform: translateX(-100%); } }
@keyframes pageExitTop     { to   { transform: translateY(-100%); } }
@keyframes pageEnterBottom { from { transform: translateY(100%); } }
@keyframes pageExitBottom  { to   { transform: translateY(100%); } }
@keyframes pageEnterTop    { from { transform: translateY(-100%); } }

.nav__mark { view-transition-name: nav-mark; }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none; }
}

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

html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: 0.001s !important; transition-duration: 0.001s !important; }
}

body {
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

::selection { background: var(--ink); color: var(--paper); }

img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }

/* ── Fine grain over everything ─────────────────────────────── */
body::after {
  content: '';
  position: fixed; inset: 0;
  pointer-events: none;
  z-index: 2000;
  opacity: 0.028;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* ═══ NAV ════════════════════════════════════════════════════
   Wordmark + a row of pills, one per page. Only the current
   page's pill expands to show its label — the other two collapse
   to a bare square digit. Switching pages (a real navigation,
   since this is a static multi-page site) morphs each pill's box
   from its old size/position to its new one via the browser's
   cross-document view-transition, matching the Figma prototype's
   Smart Animate between the Home/Work/About variants.            */
.nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: var(--nav-h);
  display: flex;
  align-items: center;
  justify-content: center;
  /* 16px between the wordmark and the pill row, 8px between pills — Figma
     "Nav bar" 132:5930 */
  gap: 16px;
  z-index: 900;
  pointer-events: none;
}
.nav > * { pointer-events: auto; }

.nav__mark {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: 14px;
  color: #000;
  transition: opacity 0.25s;
}
.nav__mark:hover { opacity: 0.55; }
/* on the home page the wordmark also points at "/" — same no-op treatment */
.nav__mark.is-here { cursor: default; }
.nav__mark.is-here:hover { opacity: 1; }

.nav__pills {
  display: flex;
  align-items: center;
  gap: 8px;
}

.nav__pill {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 39px;
  padding: 0 12px;
  border-radius: 8px;
  background: var(--pill);
  color: var(--ink);
  font-family: var(--font-serif);
  font-size: 15px;
  line-height: 1;
  white-space: nowrap;
  overflow: hidden;
  transition: background 0.25s, width 0.35s var(--ease-out), padding 0.35s var(--ease-out);
}
.nav__pill:hover { background: var(--pill-hover); }
.nav__pill:not(.nav__pill--active) { width: 39px; padding: 0; }
/* Links to the page you're already on are inert — page-transition.js
   swallows the click and tags them .is-here, so drop the affordances that
   imply otherwise. Note this is NOT .nav__pill--active: on a project page
   the Work pill renders active but /work/ is still a real destination. */
.nav__pill.is-here { cursor: default; }
.nav__pill.is-here:hover { background: var(--pill); }
/* The pill is an inline-flex box, so the label and this sup are flex items —
   which means `vertical-align: super` never applied and the digit sat dead
   centre in every pill. Raise it explicitly instead. Figma 132:5930 puts the
   digit's centre ~4px above the pill's on both states: reading as a true
   superscript beside the active pill's word, and top-aligned rather than
   centred on the inactive pills, where it is the only glyph in the box. */
.nav__pill sup {
  position: relative;
  /* the digits are Familjen Grotesk Medium, not Teodor — the pill's serif
     is the word's voice only (Figma 132:5911) */
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: 9.675px;
  margin-left: 3px;
  top: -4px;
}
/* the label's own box tweens shut (rather than display:none, which
   can't animate) so it never fights the pill's width transition */
.nav__pill-label {
  display: inline-block;
  max-width: 0;
  overflow: hidden;
  opacity: 0;
  transition: max-width 0.35s var(--ease-out), opacity 0.2s var(--ease-out);
}
.nav__pill--active .nav__pill-label { max-width: 80px; opacity: 1; }

/* per-label expanded widths, measured from the real rendered text —
   width transitions only tween between two concrete values, never
   to/from "auto", so this is what actually removes the jump */
/* --home is the route; its label reads "Inspo" (Figma 223:9312).
   The -sm figures are re-measured at the 13px small-screen size and now
   include the superscript, which used to be hidden below 560px. */
.nav__pill--home { --pill-w: 71px; --pill-w-sm: 58px; }
.nav__pill--work { --pill-w: 70px; --pill-w-sm: 59px; }
.nav__pill--about { --pill-w: 75px; --pill-w-sm: 63px; }
.nav__pill--active { width: var(--pill-w); }

/* hover an inactive pill → it expands to show its label, and the
   active pill (which no longer needs to announce itself) minimizes
   to a bare digit in its place. Reverts on hover-out. Scoped to
   hover-capable pointers so a tap on touch can't leave it stuck open. */
@media (hover: hover) {
  .nav__pill:not(.nav__pill--active):hover {
    width: var(--pill-w);
    padding: 0 12px;
  }
  .nav__pill:not(.nav__pill--active):hover .nav__pill-label { max-width: 80px; opacity: 1; }
  .nav__pills:has(> .nav__pill:not(.nav__pill--active):hover) .nav__pill--active {
    width: 39px;
    padding: 0;
  }
  .nav__pills:has(> .nav__pill:not(.nav__pill--active):hover) .nav__pill--active .nav__pill-label {
    max-width: 0;
    opacity: 0;
  }
}

@media (max-width: 560px) {
  :root { --nav-h: 60px; }
  .nav__mark { font-size: 12.5px; }
  .nav__pill { height: 33px; font-size: 13px; padding: 0 10px; }
  .nav__pill:not(.nav__pill--active) { width: 33px; }
  .nav__pill--active { width: var(--pill-w-sm); }
  /* NB: no `sup { display: none }` here. The superscript IS the label on an
     inactive pill — its word is collapsed to zero width — so hiding it left
     the two unselected tabs rendering as empty rounded rectangles. */
}

/* ═══ LOAD COUNTER ═══════════════════════════════════════════
   The load animation itself is the image pile assembling in the
   center of the home canvas (sphere.js); this is just the small
   counter at the bottom while it builds. .loader__count is also
   reused by the "images coming soon" block on project pages.    */
.loader__count {
  font-family: var(--font-pixel);
  font-size: 44px;
  line-height: 0.9;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* ═══ LOADING LAYER (lead) ═══════════════════════════════════
   Name wipes up at left, title wipes down at right, horse gif
   fades in center, counter wipes up at bottom (001 → 100).
   body.lead-in plays entrances; body.lead-out reverses them.  */
body.home { background: #ffffff; }

.lead { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.no-webgl .lead,
body.skip-intro .lead { display: none; }

.lead__name,
.lead__title {
  position: absolute;
  top: 48.9%;
  transform: translateY(-50%);
  margin: 0;
  overflow: hidden;
}
.lead__name {
  left: clamp(20px, 5.5vw, 80px);
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: 18px;
  letter-spacing: 0.01em;
  white-space: pre;
}
.lead__title {
  right: clamp(20px, 5.5vw, 80px);
  font-family: var(--font-serif);
  font-size: 18px;
}

.home__count {
  position: absolute;
  /* 16px, sitting 24px off the bottom edge — Figma 212:6691 puts the text's
     baseline box at 876 of the 900-tall frame */
  left: 50%; bottom: 24px;
  transform: translateX(-50%);
  overflow: hidden;
  font-family: var(--font-serif);
  font-size: 16px;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
  z-index: 2;
  pointer-events: none;
}

/* wipe reveals: outer clips, inner span slides */
.wipe {
  display: inline-block;
  transform: translateY(120%);
  transition: transform 0.7s var(--ease-out);
}
.lead__title .wipe { transform: translateY(-120%); }
body.lead-in .wipe { transform: none; }
body.lead-out .lead__name .wipe,
body.lead-out .home__count .wipe { transform: translateY(120%); }
body.lead-out .lead__title .wipe { transform: translateY(-120%); }

.lead__horse {
  position: absolute;
  left: 50%; top: 49.9%;
  transform: translate(-50%, -50%);
  width: clamp(240px, 28.3vw, 407px);
  height: auto;
  opacity: 0;
  transition: opacity 0.6s var(--ease-out);
}
body.lead-in .lead__horse { opacity: 1; }
body.lead-out .lead__horse { opacity: 0; }

/* Between these widths the flanking name and title crowd the horse, so the
   loader runs with just the gif and the counter. */
@media (min-width: 641px) and (max-width: 1338px) {
  .lead__name,
  .lead__title { display: none; }
}
/* …and the same when the window is too short for the type to sit clear of
   both the ring and the counter — js/sphere.js measures that and stamps the
   class, because the threshold depends on both viewport dimensions. */
body.lead-cramped .lead__name,
body.lead-cramped .lead__title { display: none; }

/* Narrower than that they stack instead of flanking: name off the top, title
   off the bottom.

   80px off each edge is the target, but it can't be a fixed 80 — the ring of
   photos fades in and sits on screen for ~2.2s BEFORE the name and title wipe
   away, and the ring grows relative to the viewport, so on a short window it
   creeps under the type. --ring-clear mirrors computeRingMetrics() in
   js/sphere.js (radius min(29.889vh, 36vw), photos 11.333vh tall, centred on
   the viewport) to give the room between an edge and the ring's outer edge;
   the type then takes 80px or whatever that band allows, whichever is less.
   The 44px covers the line box plus a little air. */
@media (max-width: 640px) {
  :root {
    --ring-clear: calc(50vh - min(29.889vh, 36vw) - 5.667vh);
    --lead-inset: min(80px, calc(var(--ring-clear) - 44px));
  }
  .lead__name {
    left: 50%; right: auto;
    top: max(16px, var(--lead-inset));
    transform: translateX(-50%);
  }
  .lead__title {
    left: 50%; right: auto;
    top: auto;
    /* floored so it stays clear of the percentage counter at bottom: 24px,
       which is the other thing it must never sit on top of */
    bottom: max(56px, var(--lead-inset));
    transform: translateX(-50%);
  }
}

/* nav slides in from the top once the sphere has formed */
.nav { transition: transform 0.65s var(--ease-out), opacity 0.5s; }
body.is-loading .nav { transform: translateY(-130%); opacity: 0; }

/* ═══ HOME ═══════════════════════════════════════════════════ */
.home {
  position: fixed; inset: 0;
  overflow: hidden;
  cursor: grab;
}
.home.is-dragging { cursor: grabbing; }
.home.has-hover { cursor: pointer; }

.home__canvas { position: absolute; inset: 0; }
.home__canvas canvas { display: block; }

/* ── Focus view ──────────────────────────────────────────────
   The photo itself is a WebGL mesh (sphere.js) tweened to a fixed
   portrait box, dead-center by default and shifted up + shrunk
   when "expanded". This DOM layer only holds the text. The
   name/discipline caption row is bottom-anchored, 24px off the
   edge; the description (expanded state only) hangs off the PHOTO
   instead — 56px below its bottom edge per Figma 249:9766 — so it
   is taken out of the flow and positioned from --focus-desc-top,
   which sphere.js measures because the photo's height depends on
   the viewport's width. A cursor-following label ("Overview"/
   "View"/"Close"/"Close overview") stands in for the pointer.     */
.focus {
  position: fixed; inset: 0;
  z-index: 950;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 22px;
  padding: var(--gutter);
  /* the caption is the last child, so this is what holds it 24px off the
     bottom edge — same as the Work carousel's and the loader's counter */
  padding-bottom: 24px;
  pointer-events: none;
}

.focus__desc {
  position: absolute;
  top: var(--focus-desc-top, 62%);
  left: var(--gutter);
  right: var(--gutter);
  margin-inline: auto;
  max-width: 46ch;
  text-align: center;
  font-family: var(--font-serif);
  font-size: 17px;
  line-height: 1.55;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.4s var(--ease-out), transform 0.4s var(--ease-out);
}
.home.is-expanded .focus__desc { opacity: 1; transform: none; }

/* ── Hover caption (.hcap) ────────────────────────────────────
   Name + discipline on one baseline: the name wipes UP into place,
   the discipline wipes DOWN. Shared by the home sphere (hovering an
   idle photo, or having one focused) and the Work carousel (hovering
   a thumbnail) — same element shape, same wipe, per the Figma
   annotation on 553:12263. Shown by putting .show-caption on the page
   element (.home / .page.work2).                                    */
.hcap {
  display: flex;
  align-items: baseline;
  gap: 16px;
  font-size: 16px;
}
.hcap__name,
.hcap__discipline { overflow: hidden; }
.hcap__name {
  font-family: var(--font-sans);
  font-weight: 400;
}
.hcap__discipline {
  font-family: var(--font-serif);
  color: var(--ink-soft);
}
.hcap .wipe { transition-duration: 0.6s; }
.hcap__discipline .wipe { transform: translateY(-120%); }
/* The wipe only plays at the true show/hide boundary (see setCaption /
   clearCaption in sphere.js and work-carousel.js), never when the text
   just swaps between items or when a hover hands off into a click. */
.show-caption .hcap .wipe { transform: none; }

/* …except on touch, where the carousel caption never leaves the screen, so
   there IS no show/hide boundary to carry the wipe. Changing project drives
   it directly instead: .is-swapping puts the wipe back to its out position
   (name below its box, discipline above), the text is swapped while it sits
   there, and dropping the class wipes the new pair in — the same up/down
   motion as the entrance, once per project. Out is quicker than in so a
   flick through several projects doesn't feel like it is lagging behind. */
.hcap.is-swapping .wipe { transition-duration: 0.26s; }
.show-caption .hcap.is-swapping .hcap__name .wipe { transform: translateY(120%); }
.show-caption .hcap.is-swapping .hcap__discipline .wipe { transform: translateY(-120%); }

/* The cursor-following label, shared by the home sphere ("Overview" /
   "View" / "Close") and the Work carousel ("View" over a thumbnail).
   mix-blend-mode is the whole trick behind the designs' "the colour should
   dynamically change to contrast against whatever image it's hovering over"
   (Figma 577:12334, 249:9764): white differenced against the backdrop reads
   dark over light images and light over dark ones, with no sampling. The
   one exception is the plain "Close" — hovering outside the image, always
   over the plain page background — which is a flat, reliable #000.      */
.cursor-label {
  position: fixed;
  top: 0; left: 0;
  z-index: 960;
  /* the no-JS / tainted-canvas fallback: difference at least inverts the
     backdrop, which reads everywhere except mid-grey */
  color: #fff;
  mix-blend-mode: difference;
  font-family: var(--font-sans);
  /* 16px, matching the bottom caption — Leroy's call, and smaller than the
     20px the Figma frames draw it at */
  font-size: 16px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, -50%);
  transition: opacity 0.2s, color 0.15s linear;
}
/* Once js/work-carousel.js has sampled the thumbnail under the pointer it
   stamps the winning ink here and the blend mode steps aside — see the
   annotation on Figma 809:45091, "the color of the cursor text should
   dynamically change to contrast … against whatever image it's hovering
   over". */
.cursor-label[data-ink] { mix-blend-mode: normal; }
.cursor-label[data-ink="light"] { color: #fff; }
.cursor-label[data-ink="dark"]  { color: var(--ink); }

.cursor-label.is-on { opacity: 1; }
.cursor-label.is-plain { mix-blend-mode: normal; color: #000000; }
.home.is-focused { cursor: none; }

@media (hover: none) {
  .cursor-label { display: none; }
  .home.is-focused { cursor: auto; }
}
@media (max-width: 700px) {
  /* caption keeps its 16px / 24px treatment here too — only the gap above
     the description tightens */
  .focus { gap: 14px; }
  .focus__desc { font-size: 15px; max-width: 36ch; }
}

/* SEO / no-JS fallback content on home */
.home-fallback { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.no-webgl .home-fallback {
  position: static; width: auto; height: auto; clip-path: none;
  padding: calc(var(--nav-h) + 40px) var(--gutter) 80px;
  max-width: 720px; margin: 0 auto;
}

/* ═══ SHARED PAGE LAYOUT (work / detail / about) ═════════════ */
.page {
  min-height: 100vh;
  padding: calc(var(--nav-h) + var(--page-pt)) var(--gutter) 0;
  max-width: 1280px;
  margin: 0 auto;
}

/* Opacity only, deliberately: the cross-document slide already supplies
   the movement on navigation, and a transform here would both fight it
   and shift elements the Work page's WebGL layer measures against. */
.page-enter { animation: pageIn 0.8s var(--ease-out) both; }
@keyframes pageIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.kicker {
  font-size: 10.5px;
  letter-spacing: 0.36em;
  text-indent: 0.36em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* ═══ WORK INDEX — carousel (Figma "Work - Default" 408:10784) ═
   The page is pinned to the viewport and never scrolls: the wheel drives
   the carousel instead (js/work-carousel.js). Nothing else is on the page
   — per the design's annotation the "Selected work" header is gone, and
   the footer with it, since the hover caption now owns the bottom-centre
   slot the copyright used to sit in. So the carousel simply IS the page:
   it fills the viewport edge to edge, which is what lets the row bleed
   off both sides, and the cards land dead-centre vertically as in the
   Figma frame (band 230..727 of 956).                                 */
body:has(.page.work2) {
  height: 100vh;
  overflow: hidden;
}

.page.work2 {
  max-width: none;
  /* the base .page centres itself with `margin: 0 auto` and pads for the
     nav; neither applies here — the carousel is full-bleed and sits under
     the nav rather than below it */
  width: 100%;
  margin-inline: 0;
  padding: 0;
  height: 100vh;
  position: relative;
}

/* The stage the cylinder is projected into. Slots are absolutely
   positioned and driven entirely by JS transforms — no document flow.
   The perspective origin is the middle of the viewport, so the slot at
   the centre of the arc is the one seen square-on. The perspective
   *distance* below is only a pre-script placeholder: work-carousel.js
   overwrites it in measure() with 3× the cylinder radius, which it can't
   know until the cards have been measured (see PERSPECTIVE_RATIO). */
.carousel {
  --carousel-gap: 40px;
  position: absolute;
  inset: 0;
  perspective: 4600px;
  overflow: hidden;
  touch-action: pan-y;   /* let the drag handler own horizontal movement */
}
.carousel__stage {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
}

.carousel__slot {
  position: absolute;
  top: 0;
  left: 0;
  /* 333px at the 1440×956 the design was drawn at (23vw ≈ 331). The
     second term keeps the card's *height* at the designed 52% of the
     viewport on shorter windows, so a laptop screen doesn't crop it:
     333/497 = 0.67 converts the height budget back into a width. */
  width: min(clamp(180px, 23vw, 333px), calc(52vh * 0.67));
  will-change: transform;
  backface-visibility: hidden;
  cursor: pointer;
}
/* over a thumbnail the pointer is REPLACED by a word — "View", or "Coming
   Soon" on a project with no page yet (Figma 577:12334 and 809:45091) — so
   the native arrow gets out of the way, but only where there's a real
   cursor to hide */
@media (hover: hover) {
  .carousel__slot { cursor: none; }
}
.carousel__thumb {
  display: block;
  width: 100%;
  aspect-ratio: 333 / 497;
  background: #d9d9d9;
  overflow: hidden;
}
.carousel__thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* One caption for the whole carousel (Figma 553:12263), naming whichever
   thumbnail is hovered — the per-card labels are gone. Sits bottom-centre
   like the home page's, and uses the same .hcap wipe. */
.work2-caption {
  position: absolute;
  left: 50%;
  bottom: 24px;
  transform: translateX(-50%);
  white-space: nowrap;
  pointer-events: none;
  z-index: 5;
}

@media (max-width: 700px) {
  /* gap is already the design's 40px at every width — only the card narrows */
  .carousel__slot { width: min(clamp(160px, 56vw, 240px), calc(52vh * 0.67)); }
}

/* ═══ PROJECT DETAIL — two-column (Figma "Project - Two column") ══
   The page itself never scrolls: .page.proj2 is pinned to exactly
   the viewport height below the nav, and each column scrolls its
   own content independently within that fixed row. No max-width —
   the layout runs the full browser width, per Figma's percentage-
   based right-column position.                                    */
.page.proj2 {
  /* Clearance below the nav, per Figma (content top 143px against its 71px
     nav bar = 72px of air). Expressed as a gap rather than an absolute top
     offset so it holds when --nav-h changes — ours is 76px, and the nav
     shrinks again on mobile. Doubles as the distance the media column is
     extended upward so it can scroll under the nav (see .proj2-media). */
  --proj-pt: calc(var(--nav-h) + 72px);
  height: 100vh;
  max-width: none;
  padding: var(--proj-pt) 80px 24px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* Figma: a 615px text column and a 553px media column, 112px apart,
   inside the 1280px band left by the 80px side padding. */
.proj2-cols {
  flex: 1;
  min-height: 0;
  display: grid;
  grid-template-columns: minmax(300px, 615px) 1fr;
  gap: 0 112px;
  align-items: stretch;
}

/* both scrolling columns keep native scroll (wheel/trackpad/keyboard)
   but hide the OS scrollbar chrome — Figma shows no visible track */
.proj2-side, .proj2-media {
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.proj2-side::-webkit-scrollbar, .proj2-media::-webkit-scrollbar {
  display: none;
}

.proj2-side {
  height: 100%;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 40px;
  padding-bottom: 24px;
}

.proj2-top { display: flex; flex-direction: column; gap: 32px; }
.proj2-title-group { display: flex; flex-direction: column; gap: 8px; }
.proj2-title-group h1 {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: 32px;
  line-height: 1.2;
  color: var(--ink);
}
.proj2-sub {
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 1.5;
  color: var(--ink-soft);
}

.proj2-brief { display: flex; flex-direction: column; gap: 24px; }
.proj2-brief section { display: flex; flex-direction: column; gap: 12px; }
.proj2-brief h2 {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: 17px;
  color: var(--ink);
}
.proj2-brief p, .proj2-brief li {
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 1.6;
  color: var(--ink-soft);
}
/* "My work" and "Results" are bulleted in the design — real discs,
   indented 24px to match Figma's per-item inline start */
.proj2-brief ul {
  list-style: disc;
  padding-left: 24px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
/* The design cites its source inline, mid-bullet ("…launched globally in
   July 2025 (Article)"), keeping the body colour — so the underline is the
   only thing marking it as a link, and the site's global `text-decoration:
   none` on anchors has to be undone here. */
.proj2-brief a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
  transition: color 0.25s;
}
.proj2-brief a:hover { color: var(--ink); }

.proj2-links { display: flex; flex-direction: column; gap: 16px; }
.proj2-links a {
  font-family: var(--font-sans);
  font-size: 16px;
  color: var(--ink);
  text-decoration: underline;
  text-underline-offset: 2px;
  transition: opacity 0.25s;
}
.proj2-links a:hover { opacity: 0.55; }

/* the media column's own scroll frame runs the full viewport height
   (not just the space below the nav's clearance), so its content
   scrolls up underneath the fixed nav instead of clipping at an
   artificial boundary partway down the screen. padding-top restores
   the same starting position content had before this extension. */
.proj2-media {
  height: calc(100% + var(--proj-pt));
  margin-top: calc(-1 * var(--proj-pt));
  padding-top: var(--proj-pt);
  overflow-y: auto;
}
.proj2-media__inner { display: flex; flex-direction: column; gap: 56px; padding-bottom: 64px; }

.proj2-item { display: flex; flex-direction: column; gap: 8px; }
.proj2-frame {
  position: relative;
  width: 100%;
  border-radius: 12px;
  overflow: hidden;
  background: var(--pill);
}
.proj2-frame video, .proj2-frame img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.proj2-caption {
  font-family: var(--font-sans);
  font-weight: 400;
  font-size: 14px;
  line-height: 1.45;
  color: var(--ink-caption);
  text-align: center;
}

/* click-to-pause affordance on video items */
.proj2-toggle {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  background: transparent;
  padding: 0;
  cursor: pointer;
}
.proj2-toggle__icon {
  position: relative;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: rgba(19, 19, 17, 0.55);
  opacity: 0;
  transition: opacity 0.25s;
}
.proj2-frame:hover .proj2-toggle__icon,
.proj2-toggle.is-paused .proj2-toggle__icon { opacity: 1; }
.proj2-toggle__icon svg {
  position: absolute;
  top: 50%; left: 50%;
  width: 16px; height: 16px;
  transform: translate(-50%, -50%);
  fill: #fff;
}
.proj2-toggle__icon .i-play { display: none; }
.proj2-toggle.is-paused .proj2-toggle__icon .i-pause { display: none; }
.proj2-toggle.is-paused .proj2-toggle__icon .i-play { display: block; }
@media (hover: none) { .proj2-toggle__icon { opacity: 1; background: rgba(19, 19, 17, 0.4); } }

/* ── media set inside a tinted card (Figma "Project - Dynamic profiles",
   551:12185). Not every item is full-bleed: the two search recordings sit
   at a fixed spot inside a coloured panel, framed by a device mock, and
   the old-slider clip is inset from its panel's edges. build_pages.py
   writes those positions inline as percentages of the card, so the mock
   and the recording inside it stay registered at any column width. ── */
.proj2-frame--card > .proj2-inset,
.proj2-frame--card > .proj2-device {
  inset: auto;
  width: auto;
  height: auto;
}
/* The mock is a bezel with a transparent screen, sized to its exact design
   box — matched, not cropped, so the cut-out lines up with the recording.
   It sits OVER the video: the bezel's rounded aperture is what trims the
   recording's corners, the way a real screen would. Stacking is set here
   rather than left to document order so the pause button stays reachable
   above both. */
.proj2-frame .proj2-device {
  position: absolute;
  object-fit: fill;
  pointer-events: none;
  z-index: 2;
}
.proj2-frame--card > .proj2-inset { z-index: 1; }
.proj2-frame .proj2-toggle { z-index: 3; }

/* ── Concept A / B switch (Figma "Component 5", 765:41305) ─────────
   One tinted card holding a pill toggle and a screenshot per concept.
   Clicking a tab is a single coordinated move rather than a swap: the
   white pill slides under the new label, the card's tint cross-fades,
   the outgoing screenshot drifts out while the incoming one drifts in
   from the side it was picked from, and the stage eases between the
   two screenshots' very different heights so the column below never
   jumps. js/project.js drives the measured parts (pill box, stage
   height); everything else is CSS, and with JS off the first concept
   simply stands as a static card.                                  */
.dp-concept {
  --dp-ease: cubic-bezier(0.32, 0.72, 0, 1);
}
.dp-concept__card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 32px;
  padding: 32px;
  border-radius: 12px;
  background: var(--dp-bg, var(--pill));
  transition: background-color 0.6s var(--ease-out);
}

.dp-concept__tabs {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px;
  border-radius: 999px;
  background: rgba(36, 46, 66, 0.06);
}
/* the moving pill is one element behind both labels, so the selection
   travels instead of blinking from one tab to the other */
.dp-concept__thumb {
  position: absolute;
  top: 4px;
  left: 0;
  height: 32px;
  width: 114px;
  border-radius: 999px;
  background: #fff;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.08));
  pointer-events: none;
  transition: transform 0.55s var(--dp-ease), width 0.55s var(--dp-ease);
}
.dp-concept__tab {
  position: relative;
  z-index: 1;
  height: 32px;
  padding: 4px 24px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  cursor: pointer;
  white-space: nowrap;
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: 14px;
  line-height: 22px;
  /* held constant across states: the design's two tracking values differ
     by a hair, and animating them would reflow the label mid-slide */
  letter-spacing: 0.03em;
  color: #fafafa;
  transition: color 0.4s var(--ease-out);
}
.dp-concept__tab.is-active { color: #1a1a1a; }
.dp-concept__tab:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

.dp-concept__stage {
  position: relative;
  width: 100%;
  /* the pre-JS height; once measured, js/project.js pins it in px so the
     switch between the two aspect ratios has something to interpolate */
  aspect-ratio: var(--dp-ratio);
  transition: height 0.65s var(--dp-ease);
}
/* The screenshots themselves never move. Switching is a straight
   cross-fade, and each panel is a rounded window onto an image drawn at
   its natural size from a fixed top-left corner. As the stage eases
   between the two concepts' heights the window grows or shrinks around
   the image rather than scaling it, so neither screenshot slides, zooms
   or reflows while the other fades past it. */
.dp-concept__panel {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 12px;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.42s var(--ease-out), visibility 0s linear 0.42s;
}
.dp-concept__panel.is-active {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.46s var(--ease-out), visibility 0s linear 0s;
}
/* the panel's rounded clip keeps the corners while the image overflows it;
   the image's own radius takes over once the window is taller than it is */
.dp-concept__panel img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 12px;
}

@media (prefers-reduced-motion: reduce) {
  .dp-concept__thumb,
  .dp-concept__stage { transition: none; }
}

@media (max-width: 700px) {
  .dp-concept__card { gap: 24px; padding: 16px; }
  .dp-concept__tab { padding-inline: 18px; }
}

.proj2-empty {
  margin: 24px 0;
  border: 1px dashed var(--ink-faint);
  border-radius: 4px;
  padding: clamp(40px, 9vh, 100px) 24px;
  text-align: center;
}
.proj2-empty .loader__count { font-size: 44px; }
.proj2-empty p {
  margin-top: 10px;
  font-size: 11px;
  letter-spacing: 0.32em;
  text-indent: 0.32em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

@media (max-width: 900px) {
  /* independent-scroll columns only make sense with room to spare —
     below this width the page reverts to one normal scrolling flow.
     The 80px side padding is a 1440-canvas figure: kept at this width it
     would eat most of a phone's screen, so fall back to the site gutter. */
  .page.proj2 {
    height: auto;
    min-height: 100vh;
    overflow: visible;
    padding-inline: var(--gutter);
    padding-bottom: clamp(30px, 6vh, 60px);
  }
  .proj2-cols { flex: none; grid-template-columns: 1fr; gap: 40px; }
  .proj2-side { height: auto; overflow: visible; padding-bottom: 0; }
  .proj2-media { height: auto; margin-top: 0; padding-top: 0; overflow: visible; }
  .proj2-media__inner { padding-bottom: 0; }
}

/* reveal on scroll */
.reveal { opacity: 0; transform: translateY(30px); transition: opacity 0.9s var(--ease-out), transform 0.9s var(--ease-out); }
.reveal.is-in { opacity: 1; transform: none; }


/* ═══ ABOUT (Figma "About - Default", 621:12681) ═════════════
   One screen, three layers: a photograph drifting behind everything
   like a DVD-logo screensaver, the bio and status centred over it,
   and "Let's connect" along the bottom edge.                      */
.page.about3 {
  min-height: 100vh;
  max-width: none;
  /* the bio takes the free height and centres in it; the connect row
     is its own track, so it sits on the bottom edge at any height */
  display: grid;
  grid-template-rows: 1fr auto;
  justify-items: center;
  padding: var(--nav-h) var(--gutter) 28px;
  position: relative;
  z-index: 1;
}

.about3-mid {
  align-self: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 48px;
  max-width: 673px;
}
.about3-bio {
  font-family: var(--font-serif);
  /* 36/44 on the 1440 artboard */
  font-size: clamp(22px, 2.5vw, 36px);
  line-height: 1.222;
  color: var(--ink);
  text-align: center;
}

/* 834:57644 drops the bordered pill and its green dot — the status is now
   just a line of type under the bio */
.about3-status {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: clamp(16px, 1.4vw, 20px);
  line-height: 1.45;
  color: #4f4f4f;
  white-space: nowrap;
}

.about3-connect {
  display: flex;
  align-items: center;
  gap: 16px;
  font-size: 16px;
  line-height: 20px;
}
.about3-connect__label { font-family: var(--font-sans); color: var(--ink); }
.about3-connect a {
  font-family: var(--font-serif);
  color: var(--ink);
  text-decoration: underline;
  text-underline-offset: 3px;
  transition: opacity 0.3s var(--ease-out);
}
.about3-connect a:hover { opacity: 0.55; }

/* ── the drifting photograph ──
   Pinned to the viewport and under every other layer, per the design's
   annotation ("should float behind all content on the screen"). All five
   photos are stacked in the frame and cut between, so a bounce never waits
   on a network request. All five ship pre-cropped to the frame's 130x161,
   so every picture fills it identically. */
.about3-float {
  position: fixed;
  inset: 0;
  overflow: hidden;
  z-index: 0;
  pointer-events: none;
}
.about3-float__frame {
  position: absolute;
  top: 0;
  left: 0;
  width: 130px;
  height: 161px;
  overflow: hidden;
  will-change: transform;
}
/* the swap is a hard cut, like the DVD logo changing colour — no fade */
.about3-float__pic {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
}
.about3-float__pic.is-current { opacity: 1; }

@media (max-width: 820px) {
  .about3-mid { gap: 32px; }
  .about3-connect { gap: 12px; font-size: 15px; }
}

/* ═══ FOOTER (Figma "Work - Default" 248:9585) ═══════════════ */
.footer {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px 8px;
  margin-top: 56px;
  font-family: var(--font-serif);
  font-size: 16px;
  color: var(--ink);
  text-align: center;
}
