@font-face {
  font-family: 'Neue Montreal';
  src: local('Neue Montreal Regular'), local('NeueMontreal-Regular'),
       url('Fonts/NeueMontreal-Regular.otf') format('opentype');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Neue Montreal';
  src: local('Neue Montreal Medium'), local('NeueMontreal-Medium'),
       url('Fonts/NeueMontreal-Medium.otf') format('opentype');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Neue Montreal';
  src: local('Neue Montreal Light'), local('NeueMontreal-Light'),
       url('Fonts/NeueMontreal-Light.otf') format('opentype');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Neue Montreal';
  src: local('Neue Montreal Bold'), local('NeueMontreal-Bold'),
       url('Fonts/NeueMontreal-Bold.otf') format('opentype');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Neue Montreal';
  src: local('Neue Montreal Italic'), local('NeueMontreal-Italic'),
       url('Fonts/NeueMontreal-Italic.otf') format('opentype');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: 'Neue Montreal';
  src: local('Neue Montreal Medium Italic'), local('NeueMontreal-MediumItalic'),
       url('Fonts/NeueMontreal-MediumItalic.otf') format('opentype');
  font-weight: 500;
  font-style: italic;
  font-display: swap;
}

:root {
  --ink: #1f1f1f;
  --cream: #fffdd6;
  --muted: var(--cream);
  /* Kept as an alpha of --cream so the hairline rule stays a tint of the
     ink colour rather than drifting to a separate grey when the palette
     moves. */
  --line: rgba(255, 253, 214, 0.18);
  color-scheme: light dark;
}

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

html {
  background: var(--ink);
  scroll-behavior: auto;
  overflow-x: clip;
}

body {
  margin: 0;
  background: var(--ink);
  color: var(--cream);
  font-family: 'Neue Montreal', Arial, Helvetica, sans-serif;
  overflow-x: clip;
}

body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 10000;
  pointer-events: none;
  opacity: 0.045;
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
  mix-blend-mode: soft-light;
}

a { color: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }

.intro-loader {
  position: fixed;
  inset: 0;
  z-index: 15000;
  background: var(--ink);
  overflow: hidden;
}

.intro-glow {
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at 50% 50%, rgba(255, 253, 214, 0.2), rgba(255, 253, 214, 0) 55%);
  opacity: 0;
  pointer-events: none;
}

.intro-liquid-stage {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  perspective: 1200px;
  perspective-origin: 50% 50%;
}

.intro-liquid-frame {
  position: relative;
  width: clamp(220px, 32vw, 520px);
  aspect-ratio: 1 / 1;
  transform-style: preserve-3d;
  will-change: transform, filter;
}

.intro-liquid {
  position: absolute;
  inset: 0;
}
.intro-liquid canvas { display: block; width: 100%; height: 100%; }


.intro-mark-fallback {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 1;
  width: clamp(72px, 9vw, 128px);
  opacity: 0;
}
.intro-mark-fallback svg { display: block; width: 100%; height: auto; }
.intro-mark-base { color: rgba(255, 253, 214, 0.16); }
.intro-mark-fill {
  position: absolute;
  inset: 0;
  overflow: hidden;
  color: var(--cream);
  clip-path: inset(100% 0 0 0);
}

.site-nav {
  position: fixed;
  z-index: 9000;
  top: 20px;
  left: 18px;
  right: 18px;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  min-height: 28px;
  color: var(--cream);
  pointer-events: none;
  mix-blend-mode: difference;
}

.site-nav a {
  pointer-events: auto;
  color: inherit;
  text-decoration: none;
  text-transform: lowercase;
  /* 38px on request, matching the hero text below — clamped rather than
     flat since the brand name, links and email share one fixed-width row
     sitewide (every page carries this nav) and a static 38px risked
     crowding or wrapping on a narrower desktop window. */
  font: 500 clamp(22px, 1.7vw, 38px)/1 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.035em;
}

.site-nav__brand {
  justify-self: start;
}
.site-nav__links { display: flex; gap: clamp(18px, 2vw, 38px); list-style: none; margin: 0; padding: 0; }
.site-nav__email {
  justify-self: end;
  padding-bottom: 5px;
  border-bottom: 1px solid rgba(255,253,214,.35);
}

/* Wraps .site-nav__email plus the randomise button below it — home page
   only (sporsho_portfolio.html), everywhere else .site-nav__email is
   still the grid's 3rd child directly, and its own justify-self: end above
   still positions it there. This wrapper carries justify-self: end
   instead when it's present; .site-nav__email's own justify-self is
   simply inert once it's nested in a flex parent rather than a grid cell. */

.site-nav a:focus-visible,
.view-toggle button:focus-visible,
.project-card:focus-visible,
.nav-burger:focus-visible,
.mobile-menu a:focus-visible {
  outline: 1px solid currentColor;
  outline-offset: 5px;
}

/* -- mobile nav ---------------------------------------------------------
   Markup for both of these is injected once by MotionEnhancements.astro
   (the only component on every page) rather than duplicated into each
   legacy HTML file. Hidden entirely above the mobile breakpoint. */
.nav-burger {
  display: none;
  position: relative;
  z-index: 2;
  justify-self: end;
  /* 44px is the minimum comfortable touch target; the visual lines are
     much smaller, so the padding is what makes it tappable. */
  width: 44px;
  height: 44px;
  margin: -8px -10px -8px 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  pointer-events: auto;
  -webkit-tap-highlight-color: transparent;
  cursor: pointer;
}

.nav-burger-line {
  position: absolute;
  left: 50%;
  width: 24px;
  height: 1.5px;
  margin-left: -12px;
  background: currentColor;
  transition: transform 0.5s cubic-bezier(.76,0,.24,1), opacity 0.3s ease;
}

.nav-burger-line:nth-child(1) { top: 19px; }
.nav-burger-line:nth-child(2) { top: 25px; }

body.menu-open .nav-burger-line:nth-child(1) { transform: translateY(3px) rotate(45deg); }
body.menu-open .nav-burger-line:nth-child(2) { transform: translateY(-3px) rotate(-45deg); }

/* The nav sits in difference-blend so it stays legible over the hero
   video; over the solid menu panel that inverts the burger to near
   invisibility, so it reverts to normal painting while open.
   z-index has to clear .mobile-menu (9500) as well: .site-nav is a
   stacking context, so the burger's own z-index counts only *within* the
   nav — at the nav's resting 9000 the whole thing, close button
   included, would be sealed behind the panel it's meant to dismiss. */
body.menu-open .site-nav {
  mix-blend-mode: normal;
  z-index: 9600;
}

.mobile-menu {
  position: fixed;
  inset: 0;
  z-index: 9500;
  display: none;
  flex-direction: column;
  justify-content: center;
  padding: calc(env(safe-area-inset-top) + 96px) 22px calc(env(safe-area-inset-bottom) + 32px);
  background: var(--ink);
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* Animated by JS; these are the closed resting values so the panel
     never flashes open before the script runs. */
  opacity: 0;
  visibility: hidden;
  clip-path: inset(0 0 100% 0);
}

.mobile-menu.is-active { visibility: visible; }

/* Scroll lock while the panel is open. Deliberately not the
   position:fixed body technique — .hero-stage is itself fixed and the
   home page's ScrollTrigger reads live scroll offsets, so taking the
   body out of flow would shift both. overflow+overscroll-behavior keeps
   layout identical, and the panel is its own scroll container on short
   screens. */
body.menu-open {
  overflow: hidden;
  overscroll-behavior: none;
}

.mobile-menu-links {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Each link rides inside its own overflow-hidden row so the entrance
   reads as the word rising out of a mask rather than just fading. */
.mobile-menu-row { overflow: hidden; }

.mobile-menu-links a {
  display: block;
  padding: 6px 0;
  color: var(--cream);
  text-decoration: none;
  text-transform: lowercase;
  font: 500 clamp(40px, 12.5vw, 60px)/1.06 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.05em;
  transition: opacity 0.35s ease, transform 0.45s cubic-bezier(.16,1,.3,1);
}

.mobile-menu-links a[aria-current='page'] { opacity: 0.42; }
.mobile-menu-links a:active { transform: translateX(10px); opacity: 0.65; }

.mobile-menu-foot {
  margin-top: auto;
  padding-top: 40px;
  overflow: hidden;
}

.mobile-menu-cta {
  display: inline-block;
  color: var(--cream);
  text-decoration: none;
  text-transform: lowercase;
  font: 500 20px/1 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.04em;
  border-bottom: 1px solid rgba(255,253,214,.35);
  padding-bottom: 6px;
}

.mobile-menu-cta:active { opacity: 0.6; }

.mobile-menu-meta {
  margin: 22px 0 0;
  color: var(--cream);
  opacity: 0.4;
  text-transform: uppercase;
  font: 500 10px/1.5 'Neue Montreal', Arial, sans-serif;
  letter-spacing: 0.14em;
}

.hero-scroll {
  position: relative;
  background: var(--ink);
}

.hero-stage {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 4500;
  height: 100svh;
  min-height: 620px;
  overflow: hidden;
  isolation: isolate;
  background: var(--ink);
}

.hero-stage.is-done {
  visibility: hidden;
}

.hero-canvas-pixels {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.hero-canvas-wrap {
  position: absolute;
  z-index: 2;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  opacity: 1;
  background: var(--ink);
  will-change: opacity, transform;
}

/* Pointer-driven 3D tilt layer, separate from the video's own translate/
   scale parallax below — two independent layers moving at once is what
   reads as "spatial" rather than a flat image sliding around. */
/* Retained as a plain wrapper. It used to carry a pointer-driven 3D tilt
   (see the removed parallax note in portfolio-experience.js); with that
   gone it animates nothing, so it must NOT declare will-change — that
   would pin a compositor layer for a static element and cost memory for
   no benefit, which is part of what this removal was meant to reclaim. */
.hero-canvas-tilt {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* The hero's resting backdrop, behind the video. Flat --ink: it covers
   the two moments the video cannot — the frames before it has decoded,
   and no-JS/no-video — and nothing else needs to see it. It used to carry
   a snapshot of the video's first frame via a --hero-pixels custom
   property, which existed so the intro loader's pixel windows could
   assemble the picture the hero was about to show; both the windows and
   the snapshot are gone.

   `cover` rather than 100% 100% keeps the same composition at every
   aspect ratio the video's own object-fit: cover also uses. */
.hero-canvas-pixels {
  display: block;
  background-color: var(--ink);
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
}

/* The live backdrop. Plain object-fit: cover fill of the hero; pointer-
   events: none is load-bearing rather than hygiene — this sits over the
   whole hero and the headline's "View selected work" link sits under it.
   No z-index, deliberately: it only needs to be above
   .hero-canvas-pixels, which DOM order already gives it inside
   .hero-canvas-tilt, and anything positive would lift it out past the
   bottom fade and the grain in .hero-canvas-wrap. */
.hero-canvas-video {
  position: absolute;
  left: 0;
  right: 0;
  /* The clip is letterboxed in the file itself: a 2.55:1 picture baked
     into a 16:9 frame with ~165px of solid black above and below it
     (ffmpeg cropdetect over the whole clip: content at y 164, height
     750-754 of 1080 — 750 is the most conservative reading and the one
     used here, so no bar can creep back in on a frame where the picture
     sits a couple of pixels shorter). object-fit: cover alone fits the
     FRAME, bars included, so it cropped the black instead of the picture
     and the bars stayed on screen.

     Oversizing the box by 1080/750 and re-centring makes cover fit the
     PICTURE instead: the box is 144% of the hero's height, the picture
     is 69.4% of the box, and 1.44 x 0.694 = 1.0, so the content strip
     lands at exactly 100% of the hero with the bars pushed past its
     edges. Expressed as a ratio rather than a fixed scale() on purpose —
     it stays exactly-enough zoom at every viewport aspect up to 2.55:1
     (past that the width drives the fit and it still covers), where a
     flat scale tuned for one window over-zooms every other one and
     throws away more of the sides than it needs to.

     .hero-canvas-wrap's overflow: hidden is what clips the overhang. */
  top: 50%;
  display: block;
  width: 100%;
  height: calc(100% * 1080 / 750);
  transform: translateY(-50%);
  object-fit: cover;
  pointer-events: none;
}


.hero-canvas-wrap::after {
  content: '';
  position: absolute;
  inset: -20%;
  /* Low opacity here on purpose: heroPixelField's shader (grainGradient)
     has its own screen-space u_noise now, so this layer only needs to add
     a little extra fine grit on top rather than carry the texture alone —
     stacking two full-strength grain passes just looked loud. Both this
     and the shader's u_noise came down further on request ("reduce the
     noise on the gradient") — 0.08 here is close to the floor before the
     texture disappears entirely. */
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.6' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.6'/%3E%3C/svg%3E");
  background-size: 100px 100px;
  mix-blend-mode: overlay;
  opacity: 0.08;
  pointer-events: none;
  animation: hero-grain 0.5s steps(4) infinite;
}

@keyframes hero-grain {
  0%   { transform: translate(0, 0); }
  25%  { transform: translate(-2%, 2%); }
  50%  { transform: translate(2%, -2%); }
  75%  { transform: translate(-1%, -1%); }
  100% { transform: translate(0, 0); }
}

@media (prefers-reduced-motion: reduce) {
  .hero-canvas-wrap::after { animation: none; }
}

/* Softens the video's bottom edge into the work section beneath it —
   without this the video is a flat rectangle that stops dead against
   whatever's behind it, reading as a hard seam no matter how the
   scroll-exit opacity is timed. The ::after gradient wash fades to the
   exact colour (--ink) the next section uses, so by 100% it is a solid
   colour match rather than a visible cutoff. (Four stacked backdrop-blur
   bands used to sit here too, to melt the video's own pixels near the
   edge; they were switched off long before they were removed.) */
.hero-video-fade {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 32%;
  pointer-events: none;
  isolation: isolate;
}

.hero-video-fade::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, transparent 0%, rgba(31, 31, 31, 0.6) 55%, var(--ink) 100%);
}

/* Off, on request, for the same reason as the sitewide edge-blur above:
   these bands were built to melt a video's fine per-pixel detail into the
   section beneath it, and the shader gradient that replaced the video
   doesn't have that kind of detail to hide — the plain colour wash in
   .hero-video-fade::after above is enough on its own to reach --ink by
   100% without a visible cutoff, and without smearing the shader's
   deliberately sharp, grained edge along the way. Markup and the
   --hvf-blur/--hvf-mask variables are left in place rather than stripped
   out of sporsho_portfolio.html, the same way LOADER_ENABLED etc. leave
   their machinery intact behind a flag. */

.hero-statement {
  position: absolute;
  z-index: 4;
  left: 6vw;
  right: 6vw;
  top: 50%;
  margin: 0;
  /* The hero copy is white rather than --cream, deliberately: it sits on
     the video in mix-blend-mode: difference, where pure white gives a
     clean full inversion of the footage beneath. This is the one place
     on the site that does not take its ink from the palette. */
  color: #fff;
  text-transform: uppercase;
  font: 500 clamp(52px, 7.6vw, 148px)/0.85 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.072em;
  transform: translateY(-50%);
  will-change: transform, opacity;
  pointer-events: none;
  mix-blend-mode: difference;
}

.hero-line {
  display: flex;
  width: 100%;
  justify-content: center;
}

.hero-line--mid { gap: 0.22em; }

.hero-word {
  display: inline-block;
  width: max-content;
  max-width: 100%;
}

.hero-statement.is-split .hero-line--top { justify-content: flex-start; }
.hero-statement.is-split .hero-line--mid { justify-content: space-between; }
.hero-statement.is-split .hero-line--bottom { justify-content: flex-end; }

.hero-supporting {
  position: absolute;
  z-index: 5;
  left: 18px;
  bottom: 18px;
  width: min(620px, 42vw);
  margin: 0;
  color: #fff;
  text-transform: lowercase;
  /* 38px on request, as a clamp rather than a flat value so it scales
     across desktop widths instead of looking oversized on a 1280px laptop
     or capped-too-early on a 2560px monitor — reaches the full 38px around
     ~1620px and holds there. */
  font: 500 clamp(30px, 2.35vw, 38px)/1.08 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.04em;
  mix-blend-mode: difference;
}

.hero-scroll-cue {
  position: absolute;
  z-index: 5;
  left: 50%;
  bottom: 20px;
  color: #fff;
  text-transform: lowercase;
  font: 500 12px/1 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.02em;
  transform: translateX(-50%);
  will-change: opacity;
  mix-blend-mode: difference;
}

.hero-selected-link {
  position: absolute;
  z-index: 5;
  right: 18px;
  bottom: 18px;
  color: #fff;
  text-decoration: none;
  text-transform: lowercase;
  /* Same clamp as .hero-supporting, on purpose — the two now share a
     target size (38px) instead of the paragraph running larger than the
     link the way 35px/24px did. */
  font: 500 clamp(30px, 2.35vw, 38px)/1 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.045em;
  border-bottom: 1px solid rgba(255,255,255,.35);
  padding-bottom: 6px;
  mix-blend-mode: difference;
}

.hero-selected-link span { display: inline-block; margin-left: .35em; }


.debug-hud {
  position: fixed;
  z-index: 12000;
  left: 16px;
  top: 54px;
  display: none;
  padding: 8px 10px;
  background: rgba(0, 0, 0, 0.78);
  color: #fff;
  font: 10px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace;
  white-space: pre;
  pointer-events: none;
}

html.is-debugging .debug-hud { display: block; }
html.show-bounds .hero-canvas-pixels { outline: 1px solid #ff315c; outline-offset: -2px; }

.work-showcase {
  position: relative;
  min-height: 180vh;
  padding: 55vh 16px 90px;
  background: var(--ink);
}

.work-intro {
  display: grid;
  grid-template-columns: 2fr 1fr;
  align-items: start;
  margin-bottom: clamp(72px, 10vh, 150px);
}

.project-category,
.project-year,
.project-index {
  text-transform: uppercase;
  font: 400 10px/1.25 'Neue Montreal', Arial, sans-serif;
  letter-spacing: 0.09em;
}

.work-kicker-group {
  align-self: stretch;
  justify-self: end;
  text-align: right;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
}
/* Not one of the reusable --ink/--cream/--muted/--line tokens — a
   one-off dark grey for this secondary index label, deliberately dimmer
   than --cream against the --ink ground. */
.work-index {
  margin-top: 16px;
  color: rgba(255, 253, 214, 0.42);
  text-transform: uppercase;
  font: 500 13px/1.25 'Neue Montreal', Arial, sans-serif;
  letter-spacing: 0.09em;
}

.work-title {
  max-width: 100%;
  margin: 0;
  justify-self: start;
  text-align: left;
  text-transform: uppercase;
  font: 500 clamp(36px, 6vw, 100px)/0.9 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.05em;
  /* Base state for no-JS / reduced-motion; GSAP owns opacity/transform via
     inline styles once ScrollTrigger is set up (see scrubReveal in
     portfolio-experience.js). No CSS transition here on purpose — a
     transition on the same properties GSAP scrubs every frame would fight
     it and turn a crisp scroll-tied motion into a muted, laggy one. */
}

/* Per-word wrappers injected by the work-heading reveal in
   portfolio-experience.js. inline-block so each word can carry its own
   transform/opacity in the staggered entrance; only present when JS runs
   and motion is allowed. */
.work-title-word { display: inline-block; will-change: transform, filter, opacity; }

.work-scroll-arrow {
  display: block;
  width: clamp(78px, 7.5vw, 132px);
  height: clamp(78px, 7.5vw, 132px);
  /* margin-top:auto drops it to the bottom of the flex column so it sits
     level with the last line of .work-title; the label above stays pinned
     to the top of the row. */
  margin: auto 0 0 auto;
  color: var(--cream);
}

.project-collection {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  column-gap: clamp(18px, 1.6vw, 28px);
  row-gap: clamp(28px, 3vh, 44px);
  align-items: start;
}

.project-card {
  position: relative;
  display: block;
  min-width: 0;
  color: var(--cream);
  text-decoration: none;
  will-change: transform, opacity;
}

@media (min-width: 1025px) {
  /* A distinct offset per column (rather than one shared value on two of
     them) gives the grid an organic, staggered rhythm instead of reading
     as two flat rows. */
  .project-collection:not(.is-list) .project-card:nth-child(4n+1) { margin-top: clamp(30px, 4vw, 56px); }
  .project-collection:not(.is-list) .project-card:nth-child(4n+2) { margin-top: 0; }
  .project-collection:not(.is-list) .project-card:nth-child(4n+3) { margin-top: clamp(16px, 2vw, 32px); }
  .project-collection:not(.is-list) .project-card:nth-child(4n+4) { margin-top: clamp(8px, 1vw, 14px); }
}

/* The project grid's entrance: a card is switched on at full opacity
   under a grid of these, which then go out one square at a time. Built
   and torn down by pixelIn() in portfolio-experience.js, so nothing here
   is ever visible outside that ~1s window.

   The squares are --ink because that is exactly what the work section's
   own background is — they have to be indistinguishable from the empty
   page, or the reveal reads as blocks lifting off a card rather than the
   card arriving.

   overflow: hidden matters: buildPixelGrid deliberately overhangs the box
   so edge squares stay square instead of being cut short, and this is
   what crops the overhang back to the card. */
.pixel-in {
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow: hidden;
  pointer-events: none;
}

.pixel-in__cell {
  position: absolute;
  background: var(--ink);
}

.project-media {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background: var(--ink);
  transition: opacity 0.45s ease, transform 0.8s cubic-bezier(.16,1,.3,1);
}

.project-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transform: scale(1.001);
  transition: transform 1.1s cubic-bezier(.16,1,.3,1), filter 0.45s;
}

.project-card:hover .project-media img { transform: scale(1.035); }

.project-meta {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 14px;
  align-items: start;
  padding-top: 10px;
}

.project-title {
  margin: 0;
  text-transform: uppercase;
  font: 500 clamp(18px, 1.75vw, 29px)/0.95 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.045em;
}

.project-details { text-align: right; }
.project-category, .project-year, .project-index { display: block; color: var(--cream); }
.project-index { display: none; }

.project-collection.is-list {
  display: block;
  min-height: 720px;
  padding: 0 clamp(0px, 12vw, 220px);
}

.project-collection.is-list .project-card {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) 0.9fr 72px 54px;
  align-items: center;
  min-height: 76px;
  margin: 0;
  border-top: 1px solid var(--line);
}

.project-collection.is-list .project-card:last-child { border-bottom: 1px solid var(--line); }

.project-collection.is-list .project-media {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.project-collection.is-list .project-meta {
  display: contents;
  padding: 0;
}

.project-collection.is-list .project-title {
  grid-column: 1;
  font-size: clamp(18px, 1.65vw, 29px);
}

.project-collection.is-list .project-details { display: contents; }
.project-collection.is-list .project-category { grid-column: 2; text-align: left; }
.project-collection.is-list .project-year { grid-column: 3; text-align: right; }
.project-collection.is-list .project-index { display: block; grid-column: 4; text-align: right; }

/* Dimming the siblings is a cursor affordance and has to be gated as one.
   On a touchscreen :hover latches to the last-tapped element, so tapping
   anywhere in list view left every other project title stranded at 25%
   opacity (~2.1:1 against --ink) until something else was tapped. The
   view toggle is reachable at every width, so this was hitting phones.
   Desktop rendering is unchanged — the query matches a real pointer. */
@media (hover: hover) and (pointer: fine) {
  .project-collection.is-list:hover .project-card { opacity: 0.25; }
  .project-collection.is-list:hover .project-card:hover { opacity: 1; }
}

/* Ungated: keyboard focus must survive on every device. */
.project-collection.is-list .project-card:focus-visible { opacity: 1; }

.view-toggle {
  position: fixed;
  z-index: 8000;
  left: 50%;
  bottom: 22px;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  height: 34px;
  padding: 2px;
  border: 1px solid rgba(255,253,214,.25);
  background: rgba(31,31,31,.78);
  backdrop-filter: blur(8px);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}

.view-toggle.is-visible { opacity: 1; pointer-events: auto; }

.view-toggle button {
  width: 33px;
  height: 28px;
  padding: 0;
  border: 0;
  background: transparent;
  color: rgba(255,253,214,.4);
  cursor: pointer;
}

.view-toggle button[aria-pressed='true'] { color: var(--cream); }
.view-toggle svg { width: 13px; height: 13px; fill: currentColor; }

.project-preview {
  position: fixed;
  z-index: 7000;
  left: 0;
  top: 0;
  width: min(38vw, 650px);
  aspect-ratio: 16 / 10;
  pointer-events: none;
  opacity: 0;
  overflow: hidden;
  transform: translate3d(-100vw, -100vh, 0) scale(.97);
  transition: opacity 0.35s ease, transform 0.5s cubic-bezier(.16,1,.3,1);
  will-change: transform, opacity;
}

.project-preview.is-visible { opacity: 0.94; }
.project-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }

.brands-section {
  padding: 50px 16px 130px;
  background: var(--ink);
}

.brands-kicker {
  margin-bottom: 72px;
  text-align: center;
  color: var(--cream);
  text-transform: uppercase;
  /* 85px flat is the target; the min() only pulls it back on viewports
     too narrow to fit the line at that size (below ~1000px). Real desktop
     widths get exactly 85px. */
  font: 500 min(85px, 8.5vw)/0.95 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.03em;
}

.brands-grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 72px 40px;
  align-items: center;
  justify-items: center;
  max-width: 1400px;
  margin: 0 auto;
  padding: 0;
  list-style: none;
}

.brands-grid img {
  width: 100%;
  max-width: 250px;
  height: 92px;
  object-fit: contain;
  transition: transform 0.3s ease;
}

.brands-grid li:hover img { transform: scale(1.06); }

.expertise-section {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 120px 16px;
  background: var(--ink);
}

.expertise-top {
  display: grid;
  grid-template-columns: 2fr 1fr;
  align-items: start;
  gap: 48px;
  margin-bottom: 64px;
}

/* Header matches the home page's .work-intro: big heading left, small
   dimmed index kicker top-right, mark pushed to the bottom-right. */
.expertise-side {
  align-self: stretch;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 12px;
}

.expertise-kicker {
  text-align: right;
  color: rgba(255, 253, 214, 0.42);
  text-transform: uppercase;
  font: 500 13px/1.25 'Neue Montreal', Arial, sans-serif;
  letter-spacing: 0.09em;
}

.expertise-heading {
  max-width: 100%;
  margin: 0;
  text-align: left;
  color: var(--cream);
  text-transform: uppercase;
  font: 500 clamp(36px, 6vw, 85px)/0.9 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.05em;
}

.expertise-mark {
  width: clamp(78px, 7.5vw, 132px);
  height: clamp(78px, 7.5vw, 132px);
  margin-top: auto;
  color: var(--cream);
}

.expertise-item {
  display: grid;
  grid-template-columns: 128px 2.4fr 1fr;
  gap: 32px;
  align-items: center;
  padding: 20px 0;
  border-top: 1px solid var(--line);
}

.expertise-item:last-child { border-bottom: 1px solid var(--line); }

.expertise-item-icon {
  width: 108px;
  height: 108px;
  margin-right: 20px;
  color: var(--cream);
}

.expertise-item-name {
  color: var(--cream);
  text-transform: uppercase;
  font: 500 clamp(70px, 5.2vw, 82px)/0.85 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.03em;
}

.expertise-item-desc {
  max-width: 46ch;
  margin: 0.35em 0 0;
  color: var(--muted);
  text-transform: lowercase;
  font: 400 clamp(14px, 1.05vw, 16px)/1.55 'Neue Montreal', Arial, sans-serif;
  letter-spacing: -0.01em;
}


@media (max-width: 1024px) {
  .hero-statement { left: 4vw; right: 4vw; font-size: clamp(68px, 12vw, 124px); }
  .hero-supporting { width: min(500px, 54vw); }
  .work-showcase { padding-top: 96px; }
  .work-intro { grid-template-columns: 3fr 1fr; }
  .project-collection { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .project-collection.is-list { padding: 0 4vw; }
  .brands-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

@media (max-width: 700px) {
  .site-nav {
    top: calc(env(safe-area-inset-top) + 16px);
    left: 18px;
    right: 18px;
    grid-template-columns: 1fr auto;
    align-items: center;
  }
  /* Replaced by the burger + full-screen panel: as inline items these
     were 24x15px tap targets stacked under the brand. */
  .site-nav__links,
  .site-nav__email { display: none; }
  .nav-burger { display: block; }
  .site-nav a { font-size: 15px; }
  .mobile-menu { display: flex; }
  .hero-stage { min-height: 540px; }
  .hero-statement {
    top: 43%;
    left: 14px;
    right: 14px;
    width: auto;
    font-size: clamp(52px, 15.2vw, 78px);
    line-height: .78;
    letter-spacing: -.065em;
  }
  /* "Across Identity" needs 437px of line at this type size but the measure
     is only 347px at 375px wide, so the pair overflowed the container and
     was clipped by .hero-stage's overflow-x — losing a word of the headline
     outright. (Centred it escaped both edges; once .is-split applies
     space-between it escaped the right.) Wrapping lets the second word drop
     to its own line instead of being cut, which keeps the type filling the
     measure the way the rest of the hero does. row-gap is zeroed because
     .hero-line--mid's 0.22em gap is meant as word spacing on one line and
     would otherwise open a hole in the 0.78 line-height. */
  .hero-line { flex-wrap: wrap; row-gap: 0; }

  .hero-display span:nth-child(1) { margin-right: 2vw; }
  .hero-display span:nth-child(3) { margin-right: 0; }
  .hero-supporting {
    left: 14px;
    bottom: 66px;
    width: calc(100vw - 28px);
    max-width: 480px;
    font-size: 14px;
    line-height: 1.05;
  }
  .hero-scroll-cue {
    left: 14px;
    bottom: 20px;
    transform: none;
    font-size: 10px;
  }
  .hero-selected-link {
    right: 14px;
    bottom: 16px;
    font-size: 14px;
  }
  .work-showcase { min-height: auto; padding: 78px 16px 110px; }
  .work-intro { display: block; margin-bottom: 96px; }
  .work-title { justify-self: start; text-align: left; font-size: clamp(38px, 13vw, 64px); margin-bottom: 18px; }
  .work-kicker-group { justify-self: start; text-align: left; align-items: flex-start; }
  .work-scroll-arrow { margin: clamp(16px, 5vw, 28px) 0 0 0; }
  .project-collection { display: block; min-height: 0; }
  .project-card:nth-child(n) { margin: 0 0 58px; }
  .project-media, .project-card:nth-child(n) .project-media { aspect-ratio: 4 / 3; }
  .project-title { font-size: 22px; }
  .project-category, .project-year { font-size: 9px; }
  .project-collection.is-list { min-height: 580px; padding: 0; }
  .project-collection.is-list .project-card {
    grid-template-columns: minmax(0, 1fr) 58px 34px;
    min-height: 62px;
    margin: 0;
  }
  .project-collection.is-list .project-title { font-size: 17px; }
  .project-collection.is-list .project-category { display: none; }
  .project-collection.is-list .project-year { grid-column: 2; }
  .project-collection.is-list .project-index { grid-column: 3; }
  .project-preview { display: none; }
  .view-toggle { bottom: 14px; }
  .brands-section { padding: 64px 16px; }
  .brands-kicker { font-size: clamp(24px, 9vw, 36px); margin-bottom: 32px; }
  .brands-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 36px 20px; }
  .expertise-section { min-height: auto; display: block; padding: 64px 16px; }
  .expertise-top { grid-template-columns: 1fr; gap: 16px; margin-bottom: 40px; }
  .expertise-side { flex-direction: row; align-items: center; justify-content: space-between; }
  .expertise-kicker { text-align: left; }
  .expertise-mark { width: 44px; height: 44px; }
  .expertise-item { grid-template-columns: 1fr; gap: 10px; }
  .expertise-item-icon { width: 64px; height: 64px; margin-right: 0; }
  .expertise-item-name { font-size: clamp(34px, 12vw, 48px); line-height: 1; }

  .hero-video-fade { height: 38%; }

  /* The grain is a full-viewport layer repainting on a 0.5s step loop
     forever. On desktop that's free; on a phone it keeps the GPU awake
     through every scroll and costs battery for a texture that is barely
     resolvable at this pixel density. The static grain stays. */
  .hero-canvas-wrap::after { animation: none; }

  /* -- touch affordances -------------------------------------------------
     Every hover state on this site is unreachable on a phone, so the
     equivalent feedback has to come from :active — without it taps feel
     inert and the page reads as unresponsive rather than premium. */
  .project-card:active .project-media { transform: scale(0.985); opacity: 0.85; }
  .project-card:active .project-media img { transform: scale(1.03); }
  .hero-selected-link:active { opacity: 0.6; transform: translateY(1px); }
  .view-toggle button:active { opacity: 0.55; }

  /* Bottom-anchored chrome clears the home indicator / gesture bar. */
  .view-toggle { bottom: calc(env(safe-area-inset-bottom) + 16px); }
  .hero-supporting { bottom: calc(env(safe-area-inset-bottom) + 66px); }
  .hero-scroll-cue { bottom: calc(env(safe-area-inset-bottom) + 20px); }
  .hero-selected-link { bottom: calc(env(safe-area-inset-bottom) + 16px); }

  /* The toggle's icons are 13px inside a 33x28 button — comfortably under
     the 44px touch minimum. */
  .view-toggle { height: 44px; }
  .view-toggle button { width: 46px; height: 40px; }
}

/* Touch targets on the nav chrome. Keyed on (hover: none) because the
   constraint is the input device, not the viewport — see the matching
   block in rebuild.css.

   The brand link renders ~15px tall and the menu CTA ~27px, both well
   under the 44px minimum. The brand is expanded with a transparent
   ::after rather than padding: .site-nav is a three-column grid with a
   min-height, so padding on a grid item would grow the bar itself and
   push the whole header down. A positioned overlay adds the hit area
   with zero layout effect. (.site-nav a is already position: relative —
   that's what the underline-wipe spans position against.) */
/* Landscape notch — the bar is the one fixed element that spans the full
   width, so it is the most visible casualty of viewport-fit=cover. */
@supports (padding: max(0px)) {
  .site-nav {
    left: max(18px, env(safe-area-inset-left));
    right: max(18px, env(safe-area-inset-right));
  }
}

@media (hover: none) {
  /* Biased downward rather than centred: the bar sits 14px from the top of
     the viewport, so a box centred on a 15px link would put ~8px of itself
     off-screen and the reachable target back under 44px. */
  .site-nav__brand::after,
  .site-nav__email::after {
    content: '';
    position: absolute;
    top: -4px;
    bottom: -25px;
    left: 0;
    right: 0;
  }

  .mobile-menu-cta {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  .site-nav__brand:active { opacity: 0.6; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .project-preview { display: none; }
}
