@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@300..800&display=swap");

:root {
  --ink: #121212;
  --muted: #8a8a8a;
  --accent: #8a3b82;
  --border: #e6e6e4;
  --bg: #ffffff;
  --bg-soft: #f5f5f3;
  --max: 1180px;
  --gutter: clamp(24px, 3vw, 48px);
  --gutter-wide: clamp(24px, 7vw, 100px);
  --gap: 24px;
  /* Shared cap for the case-study content column (header/body/split-rows)
     and the portfolio grids — text stays at this exact fixed width on
     every screen size, deliberately not the clamp below. */
  --page-max: 1680px;
  /* Same floor as --page-max, but for image rows specifically (img-pair/
     trio/solo, the gallery grid, video embeds) — flat at that same
     1680px up to ~2150px viewport (so laptops look exactly like before),
     then grows on a genuinely large desktop display so images get to run
     bigger instead of just leaving the extra width as plain margin.
     Text intentionally does NOT use this — keeping .text-col/.project-header/
     etc. on the fixed --page-max instead makes text read as a bit more
     inset than the wider images once a screen is big enough for the two
     to diverge. */
  --page-max-wide: clamp(1680px, 78vw, 2300px);
  /* Tighter inset than --gutter-wide specifically for the logo (and the nav,
     which aligns itself off the logo's position at typical widths) — the
     page-wide gutter read as too much margin pinned in the corner. */
  --gutter-logo: clamp(20px, 4vw, 56px);
}

/* Opt-in dark variant for a single case study (`theme: dark` in its
   frontmatter — see base.njk) — matching the near-black background the
   project's own artwork sits on. Every colour in this file already goes
   through these custom properties (--ink/--bg/--muted/--accent/--border/
   --bg-soft; there isn't a single hardcoded hex anywhere else in this
   stylesheet), so redefining them here at a higher-specificity scope is
   enough to re-theme the whole page — header, nav, footer, labels, links,
   included — without touching any individual component rule. The one
   thing custom properties can't reach is the logo, which is a raster image
   (filter:invert(1) further down handles that on its own).
   html.user-dark shares this exact rule rather than its own copy — it's the
   visitor-facing dark-mode toggle (js/theme-toggle.js, last item in the
   nav), reusing the same palette so there's only ever one "dark" to tune. A
   page that already forces theme-dark (its own deliberate design) is
   simply already dark, so the toggle has no visible effect there — it
   isn't meant to flip an intentionally-dark case study back to light. */
body.theme-dark,
html.user-dark {
  --ink: #ffffff;
  --muted: #a8a8a8;
  --accent: #d68fe0;
  --border: rgba(255, 255, 255, 0.15);
  --bg: #1e1e1e;
  --bg-soft: #262626;
}
/* Unconditional for the whole page, unlike .site-logo.invert (which
   parallax.js only toggles on/off while scrolled over a dark hero
   specifically) — this page is dark throughout, so the logo needs to stay
   light the whole way down, not just for the hero's height. */
body.theme-dark .site-logo img,
html.user-dark .site-logo img { filter: invert(1); }

* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; height: 100%; }
body {
  margin: 0;
  min-height: 100%;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--ink);
  font-family: "Outfit", -apple-system, "Helvetica Neue", Arial, sans-serif;
  font-size: 24px;
  line-height: 1.45;
}
img, video { max-width: 100%; display: block; height: auto; }
main { position: relative; z-index: 1; flex: 1 0 auto; }
.site-footer { flex-shrink: 0; }
a { color: inherit; }
h1, h2, h3, h4 {
  font-family: "Outfit", sans-serif;
  font-weight: 400;
  line-height: 1.1;
  margin: 0 0 0.5em;
  text-wrap: balance;
}
p { margin: 0 0 1em; font-weight: 300; }
.wrap { max-width: var(--max); margin: 0 auto; padding: 0 var(--gutter); }

/* header / nav */
/* The logo lives outside .site-header entirely now, as its own permanently
   fixed element — it must stay put even while the nav bar hides on scroll,
   so it can't be a descendant of the thing that translates offscreen. Equal
   top/left inset (both var(--gutter-wide)) pins it to the true corner. */
.site-logo {
  position: fixed;
  top: var(--gutter-logo);
  left: var(--gutter-logo);
  z-index: 41;
  transition: transform 0.3s ease, opacity 0.3s ease;
}
.site-logo img {
  width: 73px; /* 86px, 15% smaller */
  height: auto;
  transition: filter 0.2s ease;
}
/* mix-blend-mode against a position:fixed/sticky ancestor is unreliable
   across browsers (Safari and Chromium isolate it differently), so the
   invert-over-dark-hero effect is done as a plain color swap instead —
   toggled by parallax.js while the header overlaps a dark hero image. */
.site-logo.invert img { filter: invert(1); }

.site-header {
  display: flex;
  align-items: center;
  /* Kept compact — .header-nav-wrap positions and centers itself
     independently (see below), so this padding just needs to give the
     header a sensible height, not do any centering math itself. */
  padding: 22px var(--gutter-wide) 22px 0;
  /* Fixed, not sticky: a class-toggled transform on a position:sticky
     element that's already in its "stuck" state is unreliable — it can
     silently fail to apply in some browsers, which is exactly what made the
     hide-on-scroll bar just sit pinned at the top instead of hiding. Fixed
     doesn't have that failure mode. (Sticky was originally chosen to avoid
     fixed's own compositing-layer isolation breaking mix-blend-mode on the
     logo — but that blend mode was already dropped in favour of the
     filter:invert swap above, so that reason no longer applies.) Every page
     template gives its content at least 200px of top padding already, so
     removing the header from flow needs no compensating spacer. */
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 40;
  background: transparent;
  transition: transform 0.3s ease, opacity 0.3s ease;
}
/* translateY(-100%) alone isn't enough to hide this: it's a percentage of
   .site-header's OWN box, which is only ~44px tall (padding only — the nav
   links live in .header-nav-wrap, an absolutely-positioned child that
   deliberately overflows well below the header's own box to vertically
   center against the logo). So a -100% translate only shifts everything up
   by ~44px, nowhere near enough to clear the nav — it just nudges up and
   sits there, which is exactly the bug this was meant to fix. Opacity +
   pointer-events guarantee it actually disappears regardless. */
.site-header.header-hide { transform: translateY(-100%); opacity: 0; pointer-events: none; }
/* The logo itself never hides on desktop (see .header-hide notes above) —
   hovering it brings the rest of the nav back too, so you don't have to
   scroll up just to get to Work/About/Contact again. Driven by JS
   (js/nav.js's openNav/scheduleCloseNav), not a plain CSS :hover, because
   a :hover-driven version closes the INSTANT the cursor leaves — no good
   for crossing the gap over to the nav links without it vanishing first —
   and a transition-delay to fix that can't tell "closing because hover
   ended" apart from "closing because the page just scrolled down" (both
   are just "header-hide applied, nothing overriding it" to CSS), so it
   ends up delaying the scroll-triggered hide too, which should stay
   instant. nav.js instead adds .nav-sustain immediately on mouseenter and
   removes it after its own short delay on mouseleave — fully independent
   of the scroll listener above, so scroll hide/show keeps the plain,
   undelayed transition from the base .site-header rule. */
.site-header.header-hide.nav-sustain {
  transform: translateY(0);
  opacity: 1;
  pointer-events: auto;
}
/* .header-nav-wrap's own pointer-events:none (below) is what makes the gap
   between the logo and the start of the nav links unhoverable normally —
   fine most of the time, but it'd otherwise leave a dead zone while moving
   the mouse from the logo over to the links. Re-enabling it only while
   .nav-sustain is active turns that gap into one continuous hoverable
   strip for as long as nav.js considers the nav "peeked open". */
.site-header.header-hide.nav-sustain .header-nav-wrap {
  pointer-events: auto;
}

/* The logo is pinned to a fixed gutter-wide from the true left edge always.
   The nav, though, lines up with the content column's edge specifically —
   which is the SAME value as the logo's on any screen up to --page-max, but
   shifts further right once the page is wide enough for the content column
   to start centering itself. That's a deliberate "grid" effect: nav and
   content share an edge; the logo doesn't have to.
   This wrap is positioned absolutely (spanning the header's full true width
   from x:0) rather than sitting in normal flex flow after the logo —
   otherwise its padding would be added on top of the logo's own width
   instead of measured from the true left edge, throwing the alignment off
   by however wide the logo happens to render. pointer-events is disabled on
   the wrap itself (it visually spans over the logo) and re-enabled on the
   nav so the logo stays clickable. */
.header-nav-wrap {
  position: absolute;
  /* Positioned directly at the logo's own vertical center (top inset plus
     half its rendered height) and pulled back up by half its own height —
     independent of the header's padding/height entirely, so getting this
     centered doesn't require inflating the header into a tall mostly-empty
     bar just to make an inset:0 flex-center land in the right place. */
  top: calc(var(--gutter-logo) + 27.5px);
  left: 0;
  right: 0;
  transform: translateY(-50%);
  display: flex;
  pointer-events: none;
  padding-left: max(
    calc(var(--gutter-logo) + 118px),
    calc((100vw - var(--page-max)) / 2 + var(--gutter-wide))
  );
  /* Mirrors the content column's own right edge (the same
     max(gutter, centered-overflow) math every .text-col/.project-header/etc
     box uses for its padding) so .site-nav can stretch to meet it below. */
  padding-right: max(
    var(--gutter-wide),
    calc((100vw - var(--page-max)) / 2 + var(--gutter-wide))
  );
}
.header-nav-wrap .site-nav { pointer-events: auto; }
/* Vertical padding here (rather than just on .header-nav-wrap, which is
   pointer-events:none except during the hover-sustain states above) grows
   the ALWAYS-hoverable hit area around the links themselves — the links'
   own text/line-height alone made for a thin strip that lost hover (and so
   started the close transition) if the mouse drifted even a little above
   or below it while crossing toward them. The extra padding is invisible
   (transparent, no visual size change otherwise) and doesn't need any
   compensating negative margin to stay centred — .header-nav-wrap's own
   translateY(-50%) centering re-measures against this element's actual
   rendered height, growing or shrinking it included. */
.site-nav {
  display: flex;
  flex: 1;
  gap: 32px;
  font-weight: 400;
  font-size: 20px;
  padding: 20px 0;
}
/* Fill-from-left underline: a 1px bar drawn as a background-image rather
   than text-decoration, animated via background-size so it reads as
   "filling in" left-to-right on hover instead of just appearing. A real
   padding-bottom (rather than a negative background-position offset, which
   would just get clipped at the box edge) is what gives the line its gap
   below the text — background-position:bottom then sits right at the new,
   lower edge of the box. */
.site-nav a {
  text-decoration: none;
  padding-bottom: 4px;
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: left bottom;
  background-size: 0% 2px;
  transition: background-size 0.3s ease;
}
.site-nav a:hover,
.site-nav a[aria-current="page"] { background-size: 100% 2px; }
/* Icon button (two circles — one filled, one just a stroke) rather than a
   text link like its nav siblings, so reset the default button chrome and
   center it against their line-height instead of underlining on hover. Both
   circles are currentColor, so the swap from black to white on toggle is
   just the existing --ink flip — no separate dark-mode icon needed. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  /* Pushed to the far end of .site-nav's full-width flex box (see
     flex: 1 on .site-nav above, which stretches it out to the content
     column's right edge), so it lands there with a real gap from Contact
     instead of sitting right after it. */
  margin: 0 0 0 auto;
  cursor: pointer;
  opacity: 0.85;
  transition: opacity 0.15s ease;
}
.theme-toggle:hover { opacity: 1; }
/* Each dot is actually a stacked fill+stroke pair (see nav.njk) so the
   "swap" on hover is a plain opacity crossfade rather than trying to
   transition an SVG fill/stroke attribute directly, which doesn't
   interpolate cleanly between "none" and a colour. */
.theme-toggle .toggle-dot { transition: opacity 0.25s ease; }
.theme-toggle .toggle-dot-left-stroke,
.theme-toggle .toggle-dot-right-fill { opacity: 0; }
.theme-toggle:hover .toggle-dot-left-fill,
.theme-toggle:hover .toggle-dot-right-stroke { opacity: 0; }
.theme-toggle:hover .toggle-dot-left-stroke,
.theme-toggle:hover .toggle-dot-right-fill { opacity: 1; }

/* Desktop: Instagram/LinkedIn/lock stay footer-only, same as before this
   existed — this is purely the mobile full-screen menu's own copy (see
   the @media(max-width:780px) block below for how it actually looks
   there). */
.nav-social { display: none; }

.nav-toggle {
  display: none;
  position: relative;
  z-index: 2;
  flex-shrink: 0;
  width: 32px;
  height: 22px;
  flex-direction: column;
  justify-content: space-between;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}
.nav-toggle span {
  display: block;
  height: 2px;
  background: var(--ink);
  transition: transform 0.2s ease, opacity 0.2s ease;
}

@media (max-width: 780px) {
  /* Now that .header-nav-wrap is positioned absolutely instead of sitting in
     the flex flow, it no longer pushes the toggle to the right edge —
     margin-left:auto does that job directly instead. */
  .nav-toggle { display: flex; margin-left: auto; }
  /* .header-nav-wrap's transform (used to vertically center it against the
     logo on desktop) would otherwise make it the containing block for its
     own position:fixed descendants — trapping the full-screen mobile nav
     inside its own small transformed box instead of sizing against the
     real viewport. Neutralize it here so site-nav's fixed+inset:0 below
     actually covers the whole screen. */
  .header-nav-wrap { position: static; transform: none; }
  /* On desktop the logo stays permanently fixed regardless of scroll — but
     once it starts overlapping page content at narrow widths, it should
     disappear along with the rest of the nav instead of staying pinned. */
  .site-logo.header-hide { transform: translateY(-24px); opacity: 0; pointer-events: none; }
  .site-nav {
    position: fixed;
    z-index: 1;
    inset: 0;
    background: var(--bg);
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: 8px;
    padding: 0 var(--gutter);
    /* Considerably bigger than the old flat 22px — this menu covers the
       whole screen with nothing else competing for attention, so the
       links can afford to read as the main event rather than body-copy
       sized. Clamped (not flat) so it still fits a small/older phone's
       width without wrapping awkwardly. */
    font-size: clamp(32px, 11vw, 44px);
    font-family: "Outfit", sans-serif;
    font-weight: 400;
    transform: translateX(-100%);
    opacity: 0;
    pointer-events: none;
    transition: transform 0.25s ease, opacity 0.2s ease;
  }
  body.nav-open .site-nav {
    transform: translateX(0);
    opacity: 1;
    pointer-events: auto;
  }
  /* The desktop margin-left:auto trick (pushes the toggle to the row's far
     end) would instead pull it over to the cross-axis right edge in the
     mobile column list, out of line with the other links. Extra margin-top
     on top of the tightened 8px gap above — separates it from the nav
     links as its own distinct group, rather than reading as just one more
     item in the same list. */
  .site-nav .theme-toggle { margin: 32px 0 0; }
  /* Mobile-only now: Instagram/LinkedIn/lock live exclusively in the nav
     popup below --- the footer's own copy (shown on desktop, via the
     unconditional .nav-social display:none there) is hidden here instead
     of just duplicated. The extra .site-footer qualifier (rather than
     plain .footer-social) is deliberate: this and the unconditional
     ".footer-social { display: flex }" rule further down the file are
     equal-specificity otherwise, and that one, being later in the file,
     would win the cascade and show them anyway. */
  .site-footer .footer-social { display: none; }
  /* Pinned to the bottom of the full-screen overlay via position:absolute
     (site-nav is itself position:fixed, so this is relative to the whole
     screen) rather than sitting in the centered flex column with the nav
     links — a plain flex child here would either get centered along with
     them or (with a margin-top:auto trick) drag the whole links group up
     to the top to make room, neither of which is "links centered, icons
     at the bottom" independently of each other. */
  .nav-social {
    position: absolute;
    left: var(--gutter);
    bottom: var(--gutter);
    display: flex;
    gap: 24px;
  }
  .nav-social a { color: var(--ink); opacity: 0.8; transition: opacity 0.15s ease; }
  .nav-social a:hover { opacity: 1; }
  .nav-social svg { width: 28px; height: 28px; }
  body.nav-open .nav-toggle span:first-child { transform: translateY(10px) rotate(45deg); }
  body.nav-open .nav-toggle span:last-child { transform: translateY(-10px) rotate(-45deg); }
  /* Third (middle) bar — first/last still form the X same as before (their
     own position within the space-between box is unchanged by adding a
     middle span), this one just fades out rather than taking part in it. */
  body.nav-open .nav-toggle span:nth-child(2) { opacity: 0; }
}

/* home */
.hero {
  /* svh ("small viewport height") overrides the vh fallback in any
     browser that understands it — sized to the viewport with mobile
     browser chrome (the address bar) already accounted for/visible,
     rather than vh's "chrome fully collapsed" height. Without this, the
     hero renders sized for a taller viewport than the one actually
     visible on first load, forcing a scroll of exactly the chrome's
     height to reach the (absolutely-positioned) footer below — on a
     phone that's otherwise short enough for everything to fit on one
     screen. */
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Equal top/bottom padding — the text sits in true vertical centre of the
     viewport instead of being pushed up by a heavier bottom value. */
  padding: 96px var(--gutter-wide);
  max-width: var(--page-max);
  margin: 0 auto;
}
/* No-scroll home: the hero's own box (and the text's centering within it)
   stays byte-for-byte what it was before — untouched above. What changes is
   the footer: instead of sitting in normal flow below the hero (which added
   its height on top of the hero's own 100vh and forced a scrollbar), it's
   pulled out of flow and pinned to the bottom of the viewport, landing in
   the hero's already-generous 16vh bottom padding without nudging the
   (unchanged) centered text. min-height (not a hard height+overflow:hidden
   clip) so that if the headline ever runs long enough to need more than one
   screen, the page grows and scrolls normally instead of silently cutting
   the text off — the footer's position:absolute/bottom:0 below still pins
   it to the bottom of whatever height this resolves to, short or tall. */
body.home { min-height: 100vh; min-height: 100svh; position: relative; }
body.home .site-footer { position: absolute; left: 0; right: 0; bottom: 0; margin-top: 0; }
.hero .eyebrow {
  display: block;
  color: var(--muted);
  font-family: "Outfit", sans-serif;
  font-weight: 400;
  font-size: 22px;
  margin-bottom: 10px;
}
.hero h1 {
  font-size: clamp(2.6rem, 7vw, 5.2rem);
  font-weight: 400;
  letter-spacing: -0.01em;
  /* 2/3 of .hero's own content width — a percentage here resolves against
     that box directly, so it tracks --page-max/--gutter-wide automatically
     rather than needing its own calc() of them. */
  max-width: 66.67%;
}
/* Hover colour-cycle (see js/word-hover.js) — character-level on the
   homepage heading (.hover-char, one span per letter), word-level on
   every other paragraph inside <main> (.hover-word, one span per word).
   Same animation either way: a sweep through saturated colour, landing
   back on the element's normal colour. The keyframes read the colour at
   each stop from --cc0.."5" rather than hardcoding them, because
   js/word-hover.js sets those custom properties to a freshly shuffled
   order of the same 6-colour palette on every hover — a fixed keyframe
   order always reads as "the same sequence", shuffling it per-hover
   doesn't. Desaturated (not fully neon) hex values in that palette, fixed
   rather than --ink/--accent, deliberately: it should read the same
   regardless of light/dark mode, the one place on the site colour isn't
   theme-driven. forwards fill-mode holds that last frame (color:inherit,
   i.e. the element's normal colour) once the animation ends, so
   js/word-hover.js can drop the class immediately on animationend with no
   visible snap-back. The stops themselves get progressively further
   apart (9/12/15/18/21/25 percentage points) rather than evenly spaced —
   the first couple of colours flick past quickly, the last ones (and the
   final settle to the resting colour) get noticeably more time, so the
   cycle reads as easing into its landing rather than ticking through
   evenly. */
@keyframes char-color-cycle {
  0% { color: var(--cc0); }
  9% { color: var(--cc1); }
  21% { color: var(--cc2); }
  36% { color: var(--cc3); }
  54% { color: var(--cc4); }
  75% { color: var(--cc5); }
  100% { color: inherit; }
}
.hover-char.is-cycling, .hover-word.is-cycling { animation: char-color-cycle 1.3s ease-in-out forwards; }

.section-links {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1px;
  background: var(--border);
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}
.section-links a {
  background: var(--bg);
  padding: 40px var(--gutter);
  text-decoration: none;
  font-family: "Outfit", sans-serif;
  font-weight: 700;
  font-size: 1.1rem;
}
.section-links a:hover { background: var(--bg-soft); }
.section-links span {
  display: block;
  font-family: "Outfit", sans-serif;
  font-weight: 400;
  color: var(--muted);
  font-size: 0.85rem;
  margin-top: 6px;
}
@media (max-width: 780px) {
  .section-links { grid-template-columns: 1fr; }
}

/* generic page (about) */
.page {
  max-width: var(--page-max);
  margin: 0 auto;
  /* +100px on top of the old max(14vh, 260px) floor, kept as its own
     calc() term (rather than just raising the 260px) so the extra gap holds
     at every viewport height, not just the ones where the px floor wins. */
  padding: calc(max(14vh, 260px) + 100px) var(--gutter-wide) 10vh;
}
.page h1 { font-size: clamp(2rem, 5vw, 3.2rem); margin-bottom: 0.7em; }
.page p { max-width: 900px; }
.page p { color: var(--ink); }

/* project header (shared by case-study / gallery / video) */
.project-header {
  /* Same +100px treatment as .page above — same base formula, same reason
     to add it as a separate calc() term rather than bumping the floor. */
  padding: calc(max(14vh, 260px) + 100px) var(--gutter) 10vh;
  max-width: 980px;
  margin: 0 auto;
}
.project-header.wide {
  max-width: var(--page-max);
  margin: 0 auto;
  padding-left: var(--gutter-wide);
  padding-right: var(--gutter-wide);
}
.project-header.after-hero { padding-top: 10vh; }
.project-header .eyebrow {
  font-family: "Outfit", sans-serif;
  font-weight: 400;
  color: var(--muted);
  font-size: 0.95rem;
  margin-bottom: 10px;
}
.project-header h1 {
  font-size: clamp(2rem, 5vw, 3.2rem);
  /* A plain px match (not ch — ch scales with h1's own much larger
     font-size, not the paragraph's) for .intro-description/.project-description
     below it: both render at 68ch and body's 24px, which comes out to
     ~920px. So a long title's own line-wrap width never runs wider than
     the intro copy it sits above. */
  max-width: 920px;
}
/* .project-description itself runs narrower still on a gallery/video wide
   header (half the intro-grid-equivalent width, to match the columns of
   the image grid below it — see its own rule above) — match that here too,
   rather than leaving h1 pinned to the wider 920px default meant for
   case-study's full-width .intro-description. :has(.project-description)
   is what actually distinguishes a gallery/video header from a case-study
   one — both share the exact same "project-header wide" classes otherwise.
   Not on .work-header, where .project-description reverts to the same
   ~900px measure the 920px default above already covers. */
.project-header.wide:has(.project-description):not(.work-header) h1 {
  max-width: calc(50% - var(--gap) / 2);
}
/* Bleeds edge to edge (unlike the rest of .project-header, which keeps the
   standard gutter padding) so an optional background colour — set per
   project via topImageBg — can span the full viewport width behind the
   image, matching a source design where the graphic sits on its own
   flat-colour band rather than directly on the page background. */
.project-header-top-image-wrap {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  margin-bottom: 2em;
  min-height: 85vh;
  padding: 6vh var(--gutter-wide);
  display: flex;
  justify-content: center;
  align-items: center;
}
.project-header-top-image { display: block; max-width: 1110px; width: 100%; }
/* Matches the case-study body paragraph size (.text-col > p / .split-text > p
   below) rather than its old 1.1rem (17.6px) — this is the same kind of
   intro copy (Work index, gallery/video project headers), just not routed
   through .text-col, so it needs its own explicit match rather than
   inheriting it. */
.project-description { font-size: 26px; max-width: 68ch; }
/* Matches one column of the .gallery-grid below it exactly — same
   container width (both sit in a max-width:var(--page-max) box with the
   same --gutter-wide padding), same 2-column/1-gap math, so the intro copy
   reads as if it occupies the same grid as the images that follow it. */
.project-header.wide .project-description { max-width: calc(50% - var(--gap) / 2); }
@media (max-width: 640px) {
  /* .gallery-grid itself drops to a single column at this width (see its
     own max-width:640px breakpoint), so the intro copy should too rather
     than staying pinned at a half-width that no longer matches anything. */
  .project-header.wide .project-description { max-width: none; }
  .project-header.wide:has(.project-description):not(.work-header) h1 { max-width: 920px; }
}
/* The Work index intro isn't paired with a grid directly below it the way
   gallery/video headers are (that's the Work grid's filter bar, not a
   matching column rhythm), so the half-width rule above doesn't apply here
   — let it run as wide as the general cap instead. */
.project-header.wide.work-header .project-description { max-width: 900px; }
/* Slightly less bottom padding than the shared .project-header default
   (10vh) specifically above the filter bar — scoped to .work-header so
   gallery/video/case-study headers keep their own spacing. */
.project-header.wide.work-header { padding-bottom: 6vh; }

.label {
  font-family: "Outfit", sans-serif;
  font-weight: 400;
  color: var(--accent);
}
/* Client, sitting right under the headline as one plain line rather than
   a labelled block. Bottom margin is the gap between the header block and
   the intro paragraph below it — bumped up from the original 40px (kept at
   40px on mobile, see its own rule in the mobile block, to stay on the flat
   mobile rhythm). */
.project-client { margin: 20px 0 72px; font-size: 18px; }
/* Role stays paired with the description, in its original spot — no
   "Role" heading any more, just the list, sized back up to read like
   ordinary text rather than a small label. */
.intro-grid {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: var(--gutter-wide);
  padding-bottom: 6vh;
}
@media (max-width: 900px) {
  .intro-grid { grid-template-columns: 1fr; gap: 32px; }
}
.project-role p { margin: 0 0 4px; }
.project-role p:last-child { margin-bottom: 0; }
/* Same size as regular paragraph text (just inherits body's font-size —
   no override) rather than its own smaller intro/meta size. Capped
   narrower than the column it sits in (same 68ch readable-width measure as
   .project-description above) rather than filling the whole 2fr track —
   the leftover track width plus the grid's own gap is what keeps a clear
   gap before the Role column instead of the two running flush together.
   .lead reads as an opener purely through position, not weight or size. */
.intro-description { max-width: 68ch; }
.intro-description p { margin: 0 0 1em; }

.project-hero { position: relative; margin-bottom: 6vh; }
.project-hero > img { width: 100%; }
.project-hero .hero-logo {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(30%, 320px);
  height: auto;
}

/* Parallax hero: the image (and its logo) stay fixed in the viewport while
   the rest of the page scrolls up over it. The spacer reserves the scroll
   distance the fixed hero would otherwise have occupied in normal flow, and
   everything that scrolls over it needs an opaque background to cover it. */
.project-hero.parallax {
  position: fixed;
  inset: 0;
  height: 100vh;
  margin-bottom: 0;
  z-index: 0;
  overflow: hidden;
}
.project-hero.parallax > img.hero-bg {
  width: 100%;
  /* Taller than the container, so the scroll-linked upward translate in
     parallax.js has room to move without exposing an edge underneath —
     kept to the minimum this needs (matched to parallax.js's BG_SPEED;
     see the comment there) rather than any more, since every bit of this
     excess is also extra height object-fit:cover has to crop off just to
     make the box "fit". */
  height: 120%;
  object-fit: cover;
  /* Without this, the oversized box just sits top-aligned in its 100vh
     container (normal block flow, no auto-centering) — object-fit:cover
     only centers the image *within its own box*, so all that extra
     height was overflowing invisibly off the bottom edge, cropping the
     image from the top instead of the centre. Shifting the box up by half
     that overflow (in vh, not %, since percentage margins resolve against
     the container's *width* — the wrong axis entirely here) splits the
     overflow evenly above and below, so it's the top AND bottom that get
     trimmed a little instead of only the bottom. parallax.js's own
     scroll-driven transform: translateY() is a different property, so it
     keeps working unchanged on top of this static centering offset. */
  margin-top: -10vh;
}
.hero-spacer { height: 100vh; }

.project-header, .project-body, .project-nav {
  position: relative;
  z-index: 1;
}
/* Everything that scrolls after the parallax hero needs its own opaque
   background so it visually covers the fixed image as it scrolls up.
   Margins between these blocks (e.g. project-body's margin-bottom) fall
   outside any single element's own painted background, so cover the whole
   flow with one wrapper (.case-content) rather than patching each gap.
   Crucially this wrapper does NOT include .hero-spacer — that needs to stay
   transparent so the fixed hero is actually visible through it; giving the
   background to <main> itself (which contains the spacer too) was the bug
   that painted over the hero almost immediately below the header. Scoped to
   no-halftone (case-study) pages only — everywhere else this must stay
   transparent so the dotted background shows through.
   display:flow-root is load-bearing, not decoration: without .project-nav
   as a last child (a project with no nextProject set), .project-body's own
   22vh bottom margin has nothing to collapse against and escapes through
   .case-content's own bottom edge — standard CSS margin-collapsing — which
   shrinks this wrapper's painted background short of its visual content and
   exposes the fixed hero through that gap. flow-root gives .case-content
   its own block-formatting context, which contains children's margins
   inside its own box instead of letting them leak past it. */
body.no-halftone .case-content {
  background: var(--bg);
  position: relative;
  z-index: 1;
  display: flow-root;
}
/* The margin-top gap on .site-footer sits outside any element's own box —
   on a page with a fixed hero behind everything, that gap has nothing
   painted in it (the hero, as a child of <body>, always paints above
   body's own background regardless of z-index), so the hero shows through.
   Move that space inside footer's own opaque padding instead. */
body.no-halftone .site-footer { background: var(--bg); margin-top: 0; }
body.no-halftone .footer-inner { padding-top: 8vh; }

.project-body { margin-bottom: 22vh; }
.text-col { max-width: var(--page-max); margin: 0 auto; padding: 0 var(--gutter-wide); }
.text-col > p, p.closing-statement.text-col { max-width: 900px; }
/* Whatever the very first thing in the body happens to be (img-full,
   img-solo, img-pair/trio, a bare img-side, img-parallax…) always loses its
   own top margin — the header above it already carries its own bottom
   padding/intro-grid spacing, so a second 8–10em margin stacking on top of
   that read as a much bigger gap than any other gap on the page. Listed
   out per layout (rather than one `.project-body > *:first-child` catch-all)
   because a couple of these — .img-full/.img-side specifically — are
   styled via `.project-body img.img-full` elsewhere, at (0,2,1); a bare
   universal-selector version only reaches (0,2,0) and loses that fight. */
.project-body > img.img-full:first-child,
.project-body > video.img-full:first-child,
.project-body > div.img-full:first-child,
.project-body > img.img-side:first-child,
.project-body > video.img-side:first-child,
.project-body > div.img-solo:first-child,
.project-body > div.img-pair:first-child,
.project-body > div.img-trio:first-child,
.project-body > div.img-parallax:first-child,
.project-body > div.video-embed:first-child,
.project-body > div.lottie-embed:first-child {
  margin-top: 0;
}
.project-body section.text-col { margin-bottom: 22vh; }
.project-body h2 { font-size: 30px; margin-bottom: 0.7em; }
/* Bumped up from the inherited 17px body size, scoped narrowly to the
   actual prose paragraphs in a case study's body — not the header (left
   exactly as it was), and not the special text-col-plain "statement" style
   or .closing-statement, which already have their own larger sizes and
   would otherwise get overridden by this (same specificity, so it'd
   otherwise come down to source order). */
.text-col:not(.text-col-plain) > p,
.split-text > p {
  font-size: 26px;
}
/* A section heading that reads as a chapter break rather than a normal
   in-flow label — centred and considerably larger, same typographic
   language as .text-col-plain's standalone statements. Extra margin-top
   (well past any margin it could collapse with, e.g. a preceding
   .video-embed's 6vh) so a chapter break actually reads as one. */
.project-body h2.section-heading-lg {
  text-align: center;
  font-size: clamp(2rem, 4vw, 3rem);
  max-width: none;
  margin: 18vh auto 1em;
}
/* .text-col-right's own p rule pushes the paragraph right (margin-right:
   100px) to sit under a left/right-split heading — but a section-heading-lg
   heading is centred, not split, so its paragraph should follow suit
   rather than reading as right-aligned under a centred title. Specificity
   here (.text-col + :has + > p) already beats .text-col-right p without
   needing source order or !important. */
.text-col:has(> h2.section-heading-lg) > p {
  text-align: center;
  margin-left: auto;
  margin-right: auto;
}
/* A more modest step up — still an ordinary in-flow, left-aligned heading
   (unlike .section-heading-lg's centred chapter-break treatment), just
   given more presence than the default 1.5rem. */
.project-body h2.section-heading-md { font-size: 2rem; }
.project-body img { margin: 8em 0; }

/* A text-col block with no heading and no images of its own (checked in the
   template) reads as a standalone statement rather than a labelled section —
   set apart with center alignment and a much larger display face instead of
   body copy. */
.text-col-plain { text-align: center; }
/* A solid-colour marker above a section heading (see block.circleColor in
   case-study.njk) — inline-block so it picks up text-align:center from
   its container, same as the heading/paragraph text beneath it. */
.section-circle {
  display: inline-block;
  width: 200px;
  height: 200px;
  border-radius: 50%;
  margin-bottom: 48px;
}
.text-col-plain p {
  margin-left: auto;
  margin-right: auto;
  font-family: "Outfit", sans-serif;
  font-weight: 400;
  font-size: 36px;
  line-height: 1.3;
}

/* A text-only block that still has a heading (so it isn't .text-col-plain)
   — pushed over to sit against the right edge of the page instead of the
   left, at whatever width its paragraph already runs (.text-col > p's own
   900px cap, untouched here). The heading gets the same width cap and
   right-shifted box so it lines up with the paragraph's own left edge,
   rather than spanning the full column with just its text pushed right —
   text within both stays left-aligned throughout; it's the box's position
   that moves, not the text inside it. */
.text-col-right h2 {
  max-width: 900px;
  margin-left: auto;
  margin-right: 100px;
}
.text-col-right p,
.text-col-right .deliverables-list {
  margin-left: auto;
  margin-right: 100px;
}

/* .text-col-center — same box/text centring as .text-col-plain, but at the
   normal body-copy size: .text-col:not(.text-col-plain) > p above already
   gives its paragraph the regular 26px (this isn't .text-col-plain), so
   centring the box and its text is all that's needed here. */
.text-col-center { text-align: center; }
.text-col-center h2 {
  max-width: 900px;
  margin-left: auto;
  margin-right: auto;
}
.text-col-center p,
.text-col-center .deliverables-list {
  margin-left: auto;
  margin-right: auto;
}

.split-row {
  display: flex;
  align-items: center;
  gap: var(--gutter);
  max-width: var(--page-max);
  margin: 0 auto 22vh;
  padding: 0 var(--gutter-wide);
}
.split-row.reverse { flex-direction: row-reverse; }
.split-text { flex: 1 1 0; min-width: 0; }
.split-image { flex: 1 1 0; min-width: 0; }
.split-image img { width: 100%; margin: 0; }
.split-image.small { display: flex; justify-content: center; padding: 4vh; }
.split-image.small img { width: 60%; }
@media (max-width: 780px) {
  .split-row, .split-row.reverse { flex-direction: column; }
}

.project-body img.img-full,
.project-body video.img-full,
.project-body div.img-full {
  width: 100vw;
  max-width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  margin-top: 10em;
  margin-bottom: 10em;
}
/* lottie-web fills its container with its own absolutely-positioned svg
   wrapper, but the container itself (.lottie-embed, the div above) still
   needs its own explicit size — img.aspect's inline aspect-ratio handles
   the height, this handles the width now that div.img-full's own
   max-width:100% (from the global img/video rule) doesn't apply to a div. */
.lottie-embed { width: 100%; overflow: hidden; }
.project-body img.img-side,
.project-body video.img-side {
  max-width: 60%;
  margin-left: auto;
  margin-right: auto;
}
.img-pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gap);
  max-width: var(--page-max-wide);
  margin: 10em auto;
  padding: 0 var(--gutter-wide);
}
.img-pair img { margin: 0; }
/* Same shape as .img-pair, one more column — a real three-across layout
   (not just two pairs stacked) for a project's trailingImages/images, so a
   case study isn't forced to lay everything out in 2s. */
.img-trio {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--gap);
  max-width: var(--page-max-wide);
  margin: 10em auto;
  padding: 0 var(--gutter-wide);
}
.img-trio img { margin: 0; }
@media (max-width: 900px) {
  .img-trio { grid-template-columns: 1fr 1fr; }
}
/* The one-column member of the same family — full width of the standard
   page gutter, unlike img-full (which deliberately bleeds edge-to-edge) or
   img-side (deliberately narrower, 60%, centred). */
.img-solo {
  max-width: var(--page-max-wide);
  margin: 10em auto;
  padding: 0 var(--gutter-wide);
}
.img-solo img, .img-solo video { width: 100%; margin: 0; }
/* The shared 10em top/bottom margin above reads fine as a gap between an
   image group and surrounding text, but when several solo/pair/trio groups
   run back-to-back with no text between them (e.g. a section whose
   trailingImages list is mostly photos — see Photography in
   brighton-folk-book.md), adjacent margins collapse to that same 10em
   between EVERY photo, which stacks up into a lot of dead space. Tighten
   just the internal gaps of such a run (both sides, so they still collapse
   to the smaller value) while leaving the run's own leading/trailing edges
   at the full 10em against whatever text precedes/follows it. Desktop
   only — mobile already flattens all three to a uniform 40px regardless of
   adjacency (see the 640px block below), so there's nothing to tighten
   further there. */
@media (min-width: 641px) {
  .img-block:has(+ .img-block) { margin-bottom: 3em; }
  .img-block + .img-block { margin-top: 3em; }
}
@media (max-width: 640px) {
  .img-pair, .img-trio { grid-template-columns: 1fr; }
  .project-body img.img-side,
  .project-body video.img-side { max-width: 100%; }
}

/* Full-bleed banner with the same scroll-linked drift as the page's own
   hero image (see .project-hero.parallax > img.hero-bg above), just scoped
   to a normal-flow element instead of one pinned to the top of the page.
   Fixed 2:1 crop, image taller than that (130%, same excess-and-recentre
   trick as the hero) so there's room for the drift without exposing an
   edge — parallax.js drives it as a CSS variable rather than setting
   transform directly, so its scroll offset composes with the static
   -50% centering instead of replacing it outright. */
.img-parallax {
  position: relative;
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  aspect-ratio: 2 / 1;
  overflow: hidden;
  margin-top: 10em;
  margin-bottom: 10em;
}
.img-parallax img {
  position: absolute;
  top: 50%;
  left: 0;
  width: 100%;
  height: 130%;
  object-fit: cover;
  transform: translateY(calc(-50% + var(--parallax-offset, 0px)));
}

.closing-statement {
  font-family: "Outfit", sans-serif;
  font-weight: 400;
  font-size: 1.8rem;
  line-height: 1.3;
}

.deliverables-list { list-style: none; padding: 0; margin: 1.5em 0; }
.accordion-item { border-top: 1px solid var(--border); }
.accordion-item:last-child { border-bottom: 1px solid var(--border); }
.accordion-toggle {
  width: 100%;
  padding: 18px 0;
  background: none;
  border: 0;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
  font-family: "Outfit", sans-serif;
  font-weight: 700;
  font-size: 1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.accordion-icon {
  position: relative;
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  margin-left: 16px;
}
.accordion-icon::before,
.accordion-icon::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  background: var(--muted);
  transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1);
}
.accordion-icon::before { width: 12px; height: 2px; transform: translate(-50%, -50%); }
.accordion-icon::after { width: 2px; height: 12px; transform: translate(-50%, -50%); }
.accordion-item.open .accordion-icon::after { transform: translate(-50%, -50%) rotate(90deg) scale(0); }
.accordion-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.35s cubic-bezier(0.65, 0, 0.35, 1);
}
.accordion-item.open .accordion-panel { grid-template-rows: 1fr; }
.accordion-panel-inner { overflow: hidden; }
.accordion-panel p { color: var(--muted); margin: 0 0 18px; font-size: 0.95rem; }

/* image gallery */
.gallery-grid {
  max-width: var(--page-max-wide);
  margin: 0 auto;
  padding: 0 var(--gutter-wide) 8vh;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  column-gap: var(--gap);
  /* Extra breathing room between rows specifically — the column gutter
     stays the standard --gap, this just opens up the vertical rhythm. */
  row-gap: calc(var(--gap) * 2.5);
}
.gallery-item { margin: 0; }
.gallery-item img { width: 100%; border-radius: 2px; display: block; }
.gallery-item.span-2 { grid-column: span 2; }
.gallery-caption {
  margin-top: 10px;
  font-size: 0.85rem;
  color: var(--muted);
}
/* .gallery-split — a two-column group that takes a full grid row on its
   own: one side stacks two images, the other holds a single taller image
   (typically portrait) matching their combined height. Nested grid rather
   than reusing the outer one so the stack's own two cells can share the
   standard --gap independently of the outer row-gap. */
.gallery-split {
  grid-column: span 2;
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: var(--gap);
}
.gallery-split-stack {
  display: grid;
  grid-template-rows: 1fr 1fr;
  row-gap: var(--gap);
}
.gallery-split-side { height: 100%; }
.gallery-split-side img { height: 100%; object-fit: cover; }
@media (max-width: 640px) {
  .gallery-grid { grid-template-columns: 1fr; }
  /* Only one explicit track at this width — left as span 2 it would
     overflow into an implicit column instead of just filling the row. */
  .gallery-item.span-2 { grid-column: span 1; }
  /* The vertical-offset pairing only exists at the 2-column width; at one
     column every image is its own row, so the inline margin-top would just
     push it away from its (non-existent) neighbour for no reason. */
  .gallery-item { margin-top: 0 !important; }
  /* .gallery-split's OWN placement in the outer .gallery-grid (separate
     from its grid-template-columns above, which only lays out its own
     stack+side children) was still "grid-column: span 2" here — with
     only one explicit track in the outer grid at this width, that forced
     an implicit second column into existence to satisfy the span, which
     (being auto-sized, not 1fr) is exactly what was splitting every
     .gallery-item in the whole grid across two very unevenly-sized
     columns instead of one. */
  .gallery-split { grid-column: span 1; grid-template-columns: 1fr; row-gap: var(--gap); }
  .gallery-split-side { height: auto; }
  .gallery-split-side img { height: auto; object-fit: initial; }
}

/* Category filter bar — see js/work-filter.js. Plain pill buttons; .is-active
   marks the current filter (js toggles it, no page reload). */
.work-filter {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  max-width: var(--page-max);
  margin: 0 auto;
  padding: 0 var(--gutter-wide) 6vh;
}
.work-filter-btn {
  font: inherit;
  font-family: "Outfit", sans-serif;
  font-size: 0.95rem;
  background: none;
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 8px 18px;
  color: var(--muted);
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}
.work-filter-btn:hover { color: var(--ink); border-color: var(--ink); }
.work-filter-btn.is-active { color: var(--bg); background: var(--ink); border-color: var(--ink); }

/* Work index grid — deliberately not the same rules as .gallery-grid above
   (that one lists every image within a single project; this one lists every
   project). A real 2-column CSS grid rather than multi-column: hiding an
   item via .is-hidden (the filter bar) still reflows the rest for free —
   grid auto-placement just skips display:none items the same way it would
   skip a removed array element — but a grid, unlike multi-column, also has
   an actual "row" a lone trailing item can be addressed within. That's what
   js/work-filter.js's relayout() uses: when a run of 2-up items (the
   stretch between/after .work-item-full breaks) ends up odd, it sets
   grid-column:2 on the last one so it reads as right-aligned instead of
   stranded on the left. grid-column:1/-1 on .work-item-full gives a project
   its own full-width row the same native way. */
.work-grid {
  max-width: var(--page-max);
  margin: 0 auto;
  padding: 0 var(--gutter-wide) 10vh;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gutter-wide) var(--gutter);
}
.work-item {
  display: block;
  text-decoration: none;
}
/* The clipping frame: fixed aspect-ratio and overflow:hidden so every
   thumbnail crops to a consistent square shape regardless of the source
   image's own proportions, and so the hover zoom below has a fixed frame to
   zoom "within" instead of just growing the image past its own edges.
   thumbAspect (inline, per project) overrides this when set. */
.work-thumb {
  display: block;
  overflow: hidden;
  border-radius: 2px;
}
.work-thumb img {
  aspect-ratio: 1 / 1;
  width: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.5s ease;
}
.work-item:hover .work-thumb img { transform: scale(1.08); }
.work-thumb .video-embed {
  width: 100%;
  max-width: none;
  aspect-ratio: 4 / 3;
  margin: 0;
}
/* Wraps title + tags on one line (flex-wrap so narrow cards/many tags can
   still drop to a second line instead of overflowing) — margin-top lives
   here now rather than on .work-title itself, since this row is the thing
   actually sitting below the thumbnail. space-between pins the title to
   the left and pushes the tags all the way to the right edge (matching
   the thumbnail above), with the gap between them being whatever space is
   left over, rather than a fixed small gap right against the title text. */
.work-title-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 16px;
}
.work-title {
  /* inline-block (rather than block) so the width-based fill-underline
     below tracks the title text itself, not the full card width. */
  display: inline-block;
  font-family: "Outfit", sans-serif;
  font-weight: 400;
  font-size: 1.4rem;
  color: var(--ink);
  padding-bottom: 4px;
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: left bottom;
  background-size: 0% 2px;
  transition: background-size 0.3s ease;
}
.work-item:hover .work-title { background-size: 100% 2px; }
/* Category labels beside the title — plain text (no pill chrome), read as
   a comma-separated list rather than buttons. Hidden via opacity/transform
   rather than display or max-height so the space is always reserved:
   nothing in the grid shifts when they fade in on hover, it's just
   invisible the rest of the time. */
.work-tags {
  display: flex;
  flex-wrap: wrap;
  opacity: 0;
  transform: translateX(-4px);
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.work-item:hover .work-tags { opacity: 1; transform: translateX(0); }
.work-tag {
  font-family: "Outfit", sans-serif;
  font-size: 0.9rem;
  color: var(--muted);
  padding: 7px 0;
  white-space: nowrap;
}
.work-tag:not(:last-child)::after { content: "/"; margin: 0 6px; }
.work-item-full {
  grid-column: 1 / -1;
}
.work-item-full .work-thumb img { aspect-ratio: 16 / 9; }
.work-item-full .work-title { font-size: 1.6rem; }
/* Set by js/work-filter.js when a project doesn't match the active
   category — removing it from flow (rather than just hiding visually) is
   what lets grid auto-placement actually close the gap. */
.work-item.is-hidden { display: none; }
/* Set by js/work-filter.js on the one item that ends up alone (odd run),
   alongside a grid-column:1/-1 span — the same full-width row a
   .work-item-full gets, rather than a stranded single half-column cell —
   then narrowed here to 2/3 of that full width rather than filling it like
   a real .work-item-full would. No extra alignment rule needed: once the
   item has an explicit width narrower than its (now full-width) grid cell,
   it naturally sits at the cell's start (left) edge rather than stretching
   to fill it. Desktop/tablet only — at the single-column mobile width
   below, the lone-item logic is neutralised entirely
   (.work-item { grid-column: 1 !important }), so this would just orphan it
   at 2/3 width there instead of filling the single column like every other
   item. */
@media (min-width: 641px) {
  .work-item-lone { width: 66.6667%; }
  /* Square (the default .work-thumb img aspect-ratio) would scale the
     thumb's height up right along with this item's own extra width,
     towering over every other item's thumb in the grid — a lone item
     should read as "narrower", not "a different, taller shape". 4:3
     instead gives it roughly the same height as a normal 1:1 thumb at a
     normal (half-width) column: this item's width is 2/3 of the FULL
     grid width (see js/work-filter.js), so a normal column's width is
     *roughly* 3/4 of that (ignoring the column-gap, which is small next
     to the grid's overall width) — and 4:3 is exactly the ratio that
     makes height = width * 3/4. */
  .work-item-lone .work-thumb img { aspect-ratio: 4 / 3; }
}
@media (max-width: 640px) {
  .work-grid { grid-template-columns: 1fr; }
  /* The lone-item right-align only makes sense with two columns to choose
     between — at one column it would just orphan the item in a phantom
     second track. relayout() in js/work-filter.js only runs above this
     breakpoint, but belt-and-braces this in case a resize leaves a stale
     inline grid-column behind. */
  .work-item { grid-column: 1 !important; }
}

/* video embed (and its placeholder state) */
.video-embed {
  width: calc(100% - 2 * var(--gutter-wide));
  max-width: calc(var(--page-max-wide) - 2 * var(--gutter-wide));
  margin: 0 auto 6vh;
  aspect-ratio: 16 / 9;
  border-radius: 2px;
  overflow: hidden;
}
/* The dashed border / grey fill / centred play icon only make sense for the
   "no videoUrl yet" placeholder state (video.njk's {% else %} branch,
   case-study.njk's block.videoUrl block never renders without one) — a
   real embedded iframe was inheriting all of this too since both states
   shared the one .video-embed class, so a live video sat inside what read
   as a broken/unstyled placeholder frame. video.placeholder is only added
   by the template when there's no videoUrl. */
.video-embed.video-placeholder {
  background: var(--bg-soft);
  border: 1px dashed var(--border);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 10px;
  color: var(--muted);
  text-align: center;
  padding: 24px;
}
.video-embed .play-icon {
  width: 56px; height: 56px;
  border-radius: 50%;
  border: 2px solid var(--muted);
  display: flex; align-items: center; justify-content: center;
}
.video-embed iframe { width: 100%; height: 100%; border: 0; }

/* prev/next */
.project-nav {
  padding: var(--gutter);
  display: flex;
  justify-content: space-between;
  gap: var(--gutter);
}
.project-nav a { text-decoration: none; }
.project-nav-next { text-align: right; margin-left: auto; }
.project-nav .label {
  display: block;
  font-family: "Outfit", sans-serif;
  color: var(--muted);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-bottom: 6px;
}
.project-nav h2 { font-size: 1.6rem; }
@media (max-width: 640px) {
  /* Stays a row (not stacked) even at this width — each side just gets a
     bounded flex-basis (min-width:0 is what actually lets text-overflow
     below do its job; flex items default to min-width:auto, which blocks
     shrinking/ellipsis below their own text's intrinsic width). */
  .project-nav { gap: 16px; }
  .project-nav-prev, .project-nav-next { flex: 1 1 0; min-width: 0; }
  /* -webkit-line-clamp (not white-space:nowrap) — wrapping up to 2 lines
     is fine; the ellipsis should only kick in for whatever's left over
     past that, not on every title regardless of length. min-width:0 above
     is still what lets this actually clip rather than overflow. */
  .project-nav h2 {
    font-size: 1rem;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    /* The "…" glyph itself is tied to text-overflow in practice, even
       though line-clamp is a -webkit-box feature, not text-overflow's
       normal single-line one — without this the 3rd+ line still gets cut
       off correctly, just silently, with no ellipsis actually drawn. */
    text-overflow: ellipsis;
  }
}

/* footer */
.site-footer { margin-top: 4vh; background: transparent; position: relative; z-index: 1; }
.footer-inner {
  padding: 4vh var(--gutter);
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 24px;
  flex-wrap: wrap;
}
.footer-name { font-size: 1.1rem; margin: 0; font-family: "Outfit", sans-serif; }
.footer-right {
  display: flex;
  align-items: center;
  gap: 20px;
}
.footer-social {
  display: flex;
  gap: 16px;
  align-items: center;
}
.footer-social a { color: var(--ink); display: flex; opacity: 0.8; transition: opacity 0.15s ease; }
.footer-social a:hover { opacity: 1; }
.footer-copyright { font-size: 0.85rem; color: var(--muted); }

/* contact page — intro on the left, form on the right. Nested inside .page,
   which already supplies the shared max-width/centering/top-padding, so
   .contact-grid only has to split the two columns. */
.contact-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gutter-wide);
  align-items: start;
}
@media (max-width: 900px) {
  .contact-grid { grid-template-columns: 1fr; gap: 32px; }
}
/* Extra breathing room on the intro column's own inner edge, on top of the
   grid's shared gap — the paragraph text was running right up against
   that gap with nothing of its own beyond it. Desktop/tablet only: at the
   900px breakpoint above, the two columns stack to full width each, where
   a fixed right padding would just eat into text width for no reason. */
@media (min-width: 901px) {
  .contact-intro { padding-right: var(--gutter); }
}
/* Gap below the intro paragraph, before the Email/Phone block. */
.contact-intro > p { margin-bottom: 36px; }

/* Email/Phone, shown under the intro paragraph as a small 2-column block —
   matches the original site's contact page rather than repeating the same
   details again down in the form. max-content columns (rather than a fixed
   max-width with an even 1fr/1fr split) size each column to its own
   label/value, so neither is squeezed narrower than its content needs —
   that fixed split was letting the longer Email value's column win the
   free space at Phone's expense, forcing the phone number to wrap. */
.contact-meta {
  display: grid;
  grid-template-columns: max-content max-content;
  gap: var(--gutter);
  margin-top: 36px;
}
.contact-meta-label {
  display: block;
  font-family: "Outfit", sans-serif;
  font-weight: 700;
  font-size: 0.85rem;
  margin-bottom: 6px;
}
.contact-meta-item p { margin: 0; font-size: 20px; }

.contact-form { padding-bottom: 8vh; }
.contact-form .form-row, .contact-form .btn { max-width: 560px; }
.form-row { margin-bottom: 20px; }
.form-row label {
  display: block;
  font-family: "Outfit", sans-serif;
  font-weight: 700;
  font-size: 1.1rem;
  margin-bottom: 6px;
}
.form-row input, .form-row textarea {
  width: 100%;
  padding: 12px 0;
  border: none;
  border-bottom: 1px solid var(--border);
  border-radius: 0;
  font: inherit;
  font-weight: 300;
  background: none;
}
/* Lower opacity on top of --muted (rather than a separate hardcoded colour)
   so it stays faint relative to --muted in both themes automatically. */
.form-row input::placeholder, .form-row textarea::placeholder { color: var(--muted); opacity: 0.5; }
.form-row textarea { min-height: 140px; resize: vertical; }
.form-subrow {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
}
@media (max-width: 480px) {
  .form-subrow { grid-template-columns: 1fr; }
}
.required { font-weight: 400; color: var(--muted); }
.btn {
  display: inline-block;
  background: var(--ink);
  color: var(--bg);
  border: 0;
  padding: 14px 28px;
  font-family: "Outfit", sans-serif;
  font-weight: 700;
  text-transform: uppercase;
  border-radius: 999px;
  cursor: pointer;
  font-size: 0.95rem;
}
.btn:hover { opacity: 0.85; }
.form-note { color: var(--muted); font-size: 0.9rem; margin-top: 14px; }

/* Custom password screen in front of a Basic Auth-protected case study —
   see js/password-gate.js and src/_includes/password-gate.njk. Reuses
   .form-row/.btn (same field/button look as the contact form) rather than
   defining its own, just capped to a sensible width for a single field. */
.password-gate-form { max-width: 420px; margin-top: 10px; }
.password-gate-form .btn { margin-top: 8px; }
.gate-error { color: #c0392b; font-size: 0.9rem; margin-top: 14px; }

/* scroll reveal */
.reveal {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 0.7s ease, transform 0.7s ease;
}
.reveal.is-visible { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
}

/* page transition wipe — a single continuous upward sweep. Default state
   is fully covering (translateY(0)). .is-entering parks it offscreen below,
   so the cover animation that follows (removing the class) slides up into
   view from there instead of reversing downward. .is-open sends it offscreen
   above, so opening also reads bottom-to-top, continuing the same motion. */
.page-transition {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: var(--ink); /* inverse of the page background — black in light mode, white in dark */
  /* 0.42s — 30% faster than the original 0.6s. Keep in lockstep with the
     setTimeout in js/page-transition.js, which waits out this exact
     duration before actually navigating. */
  transition: transform 0.42s cubic-bezier(0.65, 0, 0.35, 1);
  pointer-events: none; /* purely visual — never swallow clicks to the page underneath */
}
.page-transition.is-entering { transform: translateY(100%); }
.page-transition.is-open { transform: translateY(-100%); }
@media (prefers-reduced-motion: reduce) {
  .page-transition { display: none; }
}

/* --- Mobile: type scale, vertical rhythm, and gutter consistency ---
   The desktop clamp()/calc() values above don't scale down far enough on
   phones — a clamp() just pins to its own flat minimum bound once the
   vw-based middle term drops below it, and the calc(max(14vh,260px)+100px)
   top-padding formula bottoms out at a flat ~360px on any phone-height
   viewport regardless of how short the screen actually is. Flattening
   everything here to one explicit, shared mobile scale — rather than
   patching each clamp()/calc() individually — is what makes the sizing and
   spacing actually consistent from section to section. */
@media (max-width: 640px) {
  /* --gutter-wide is only ever used for the same role as --gutter (a
     section's left/right page margin) — equalising them is what fixes the
     inconsistent-looking margins between templates that happened to pick
     one variable over the other. */
  :root { --gutter-wide: var(--gutter); }

  body { font-size: 18px; }
  /* Same 73px the logo's always been, just too big relative to everything
     else once the rest of the chrome (nav text, hamburger) scales down for
     a phone screen. */
  .site-logo img { width: 52px; }
  .hero h1 { font-size: 2rem; max-width: none; }
  .hero .eyebrow { font-size: 16px; }
  .page h1, .project-header h1 { font-size: 1.8rem; }
  .project-description { font-size: 18px; }
  .project-client { margin-bottom: 40px; }
  .text-col:not(.text-col-plain) > p,
  .split-text > p { font-size: 18px; }
  .closing-statement { font-size: 1.4rem; }
  .project-body h2 { font-size: 22px; }
  /* The desktop-only 10vh chapter-break margin-top (added above) would
     blow past the shared 40px mobile rhythm below otherwise. */
  .project-body h2.section-heading-lg { margin-top: 40px; }
  /* Text-only rows (no accompanying image) run full width on mobile —
     .text-col-right's desktop margin-right:100px push (meant to line it
     up under a right-aligned heading on a wide screen) just ate a huge
     chunk of an already-narrow phone screen instead. */
  .text-col-right h2,
  .text-col-right p,
  .text-col-right .deliverables-list {
    max-width: none;
    margin-right: 0;
  }
  /* Side-by-side max-content columns (Email/Phone) don't wrap — they just
     keep demanding their own full content width each, which together can
     run wider than the screen. Stacked is explicitly fine here. */
  .contact-meta { grid-template-columns: 1fr; }
  /* Category filter pills: one row, horizontally scrollable, instead of
     wrapping to several rows and eating a lot of vertical space before
     the grid even starts. flex-shrink:0 + white-space:nowrap on the
     buttons stop them from being squeezed/wrapped internally as the row
     scrolls. */
  /* Scrollable, but the scrollbar track/thumb itself stays hidden — still
     fully scrollable by touch/drag, just without the bar visibly sitting
     under the pills the whole time. Both properties are needed (no single
     standard one covers every engine): scrollbar-width for Firefox, the
     ::-webkit-scrollbar pseudo-element for Chrome/Safari. */
  .work-filter {
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .work-filter::-webkit-scrollbar { display: none; }
  .work-filter-btn { flex-shrink: 0; white-space: nowrap; }

  /* Vertical rhythm — one flat scale instead of each section's own
     vh/calc() figure, which is what read as "way too big and
     inconsistent" between sections on a phone-height viewport. */
  .hero { padding: 90px var(--gutter) 60px; }
  .page, .project-header { padding: 120px var(--gutter) 60px; }
  .project-header.after-hero { padding-top: 48px; }
  /* .intro-grid's own desktop padding-bottom is 6vh — on a short phone
     viewport that's still a real ~40-50px, which on top of
     project-header's own 60px bottom padding (right outside it) stacked
     into ~100-135px total before the body starts, way past the flat
     40px rhythm everything else on mobile uses. project-header's 60px
     alone is already enough separation. */
  .intro-grid { padding-bottom: 0; }
  .intro-description { padding-bottom: 0; }
  .project-hero { margin-bottom: 40px; }
  .project-body,
  .project-body section.text-col { margin-bottom: 40px; }
  .contact-form { padding-bottom: 48px; }

  /* The case-study hero banner reads as oversized taking the full screen
     on a phone — scale it, and the spacer reserving scroll space for its
     fixed/parallax positioning, down together so they still match. 52vh —
     20% shorter than the original 65vh. */
  .project-hero.parallax, .hero-spacer { height: 52vh; }

  /* Image groups within a case study's body: a flat, consistent 40px
     between any two images, or an image group and the text/heading next
     to it — the desktop figures (10em group margins, 8em on a bare img,
     22vh between sections) are each their own size and read as "random"
     next to each other on a phone; one shared number fixes that. */
  .img-pair, .img-trio, .img-solo,
  .img-parallax, .split-row,
  .video-embed {
    margin: 40px auto;
  }
  /* Higher specificity than the others above (.project-body img.img-full
     on desktop) so it needs matching, not just later source order, to win. */
  .project-body img.img-full,
  .project-body video.img-full,
  .project-body div.img-full { margin-top: 40px; margin-bottom: 40px; }
  .project-header-top-image-wrap { margin-bottom: 40px; padding: 40px var(--gutter); }

  /* Work grid thumbnails: square across the board on mobile, overriding
     the full-width 16:9 crop, the video-placeholder's 4:3 box, and any
     per-project thumbAspect inline style (hence !important — it otherwise
     beats the class rule on specificity alone). */
  .work-thumb img,
  .work-thumb .video-embed {
    aspect-ratio: 1 / 1 !important;
  }
}
