/* ==========================================================================
   RK Design — Homepage redesign (from Figma: Richard-Portfolio-Site)
   Hero keyframe:   node 129-1194
   Scrolled keyframe: node 129-1352 (pinned hero fades to reveal line-art bg)
   Card hover states: 128-1012 / 128-1041 / 129-1106 / 128-1068
   Fonts: Lexend 300/400/500/700. Accent red: #ED3E3A.
   ========================================================================== */

:root {
  --color-text: #000000;
  --color-text-body: #191919;
  --color-accent: #ed3e3a;
  --glass-bg: rgba(255, 228, 223, 0.6);
  --glass-border: rgba(255, 255, 255, 0.45);
  --content-max: 1314px;
  --gutter: clamp(20px, 4vw, 60px);
  --radius-lg: 40px;
  --radius-pill: 100px;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}

* { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* clip, not hidden — overflow-x: hidden on html/body is a well-known
     Chrome/Firefox gotcha that silently disables position: sticky for
     every descendant (it turns html into a scroll container, which
     redefines what "sticky within the viewport" even means). clip
     still prevents horizontal overflow/scrollbars without that side
     effect, since it doesn't establish a scroll container. #metrics'
     new sticky dock-below-nav behavior (see its own comment) is what
     surfaced this — nothing on the site used position: sticky before. */
  overflow-x: clip;
  /* So #metrics/#work anchor links (and the browser restoring a #hash on
     load) always land correctly below the fixed nav, recalculated fresh
     against whatever the current layout height is — not a stale scroll
     position from before a padding/height change. */
  scroll-padding-top: 100px;
}

body {
  margin: 0;
  /* clip, not hidden — see html's own rule above for why. */
  overflow-x: clip;
  /* .line-art-fixed (and each page's own equivalent fixed backdrop)
     covers the full viewport at all times during normal scrolling, so
     this is never visible then — but a rubber-band overscroll bounce
     past the very bottom of the page can briefly reveal genuine body
     background beneath the fixed layer and the footer's own black
     background. Matches the footer (and .page-content's sticky-footer
     fix, for the separate case of a browser tall enough that content
     doesn't reach the bottom on its own) rather than showing plain
     white there. */
  background: #000;
  color: var(--color-text);
  font-family: "Lexend", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-weight: 300;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }

.glass {
  background: var(--glass-bg);
  backdrop-filter: blur(28px) saturate(160%);
  -webkit-backdrop-filter: blur(28px) saturate(160%);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-lg);
}

/* ---------- Sticky nav (always on top, never fades) ---------- */

.site-nav-wrap {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20px var(--gutter) 0;
  pointer-events: none;
}

.site-nav-wrap .site-nav { pointer-events: auto; }

.site-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  width: 100%;
  max-width: 900px;
  padding: 14px 34px;
  border-radius: var(--radius-pill);
}

.nav-logo { position: relative; display: flex; align-items: center; }
.nav-logo img { height: 25px; width: auto; }

/* Logo/LinkedIn marks are flat-color SVGs (fill="black"), so recoloring
   them to the accent red on hover can't be done with a CSS color/filter
   trick — instead a second, pre-colored copy of the same SVG sits
   stacked exactly on top via position:absolute and crossfades in. */
.nav-logo-hover,
.nav-icon-hover {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.2s var(--ease-out);
}

.nav-logo:hover .nav-logo-hover,
.nav-logo:focus-visible .nav-logo-hover,
.nav-logo:active .nav-logo-hover,
.nav-linkedin:hover .nav-icon-hover,
.nav-linkedin:focus-visible .nav-icon-hover,
.nav-linkedin:active .nav-icon-hover,
.nav-mobile-linkedin:hover .nav-icon-hover,
.nav-mobile-linkedin:focus-visible .nav-icon-hover,
.nav-mobile-linkedin:active .nav-icon-hover {
  opacity: 1;
}

.nav-links {
  display: flex;
  align-items: center;
  gap: 34px;
  font-size: 18px;
  color: var(--color-text);
}

.nav-links a {
  opacity: 0.85;
  transition: opacity 0.2s var(--ease-out), color 0.2s var(--ease-out);
}

.nav-links a:hover,
.nav-links a:focus-visible,
.nav-links a:active,
.nav-links a[aria-current="page"],
.nav-links a.is-current-section {
  opacity: 1;
  color: var(--color-accent);
}

.nav-links .nav-linkedin { position: relative; display: flex; }
.nav-links .nav-linkedin img { width: 34px; height: 34px; }

/* Settings gear: owner-only, so it starts hidden in markup on every page
   and js/main.js reveals it after confirming role "owner" via /api/whoami
   — a plain currentColor stroke icon (not a pre-colored-copy crossfade
   like the flat-fill LinkedIn/logo marks above) since it's drawn as
   line art rather than a flat brand shape. */
.nav-settings {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  color: var(--color-text);
  opacity: 0.85;
  transition: opacity 0.2s var(--ease-out), color 0.2s var(--ease-out), transform 0.3s var(--ease-out);
}

.nav-settings:hover,
.nav-settings:focus-visible {
  opacity: 1;
  color: var(--color-accent);
  transform: rotate(30deg);
}

.nav-settings svg { width: 22px; height: 22px; }
.nav-settings[hidden] { display: none; }

.nav-mobile-settings {
  display: flex;
  align-items: center;
  gap: 10px;
}
.nav-mobile-settings svg { width: 20px; height: 20px; }
.nav-mobile-settings[hidden] { display: none; }

/* Hamburger toggle — hidden on desktop, shown on mobile in its place */
.nav-hamburger {
  display: none;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 5px;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
  flex-shrink: 0;
}

.nav-hamburger span {
  display: block;
  width: 22px;
  height: 2px;
  background: var(--color-text);
  border-radius: 2px;
  transition: transform 0.25s var(--ease-out), opacity 0.2s var(--ease-out);
}

.nav-hamburger[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.nav-hamburger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav-hamburger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }

/* Mobile dropdown panel — hidden until toggled open */
.nav-mobile-panel {
  display: none;
  flex-direction: column;
  align-items: stretch;
  gap: 4px;
  width: 100%;
  max-width: 900px;
  margin-top: 10px;
  padding: 16px 28px;
  pointer-events: auto;
}

.nav-mobile-panel.is-open { display: flex; }

.nav-mobile-panel a {
  padding: 10px 4px;
  font-size: 17px;
  color: var(--color-text);
  border-bottom: 1px solid rgba(0, 0, 0, 0.08);
  transition: color 0.2s var(--ease-out);
}

.nav-mobile-panel a:last-child { border-bottom: 0; }

.nav-mobile-panel a:hover,
.nav-mobile-panel a:focus-visible,
.nav-mobile-panel a:active,
.nav-mobile-panel a[aria-current="page"],
.nav-mobile-panel a.is-current-section {
  color: var(--color-accent);
}

.nav-mobile-linkedin {
  position: relative;
  display: flex !important;
  align-items: center;
  gap: 10px;
}

.nav-mobile-linkedin img { width: 24px; height: 24px; }

/* The shared .nav-icon-hover rule stretches inset:0 to fill its
   positioned ancestor — fine for the desktop icon-only link, but this
   one also has "LinkedIn" text after the icon, so inset:0 here would
   stretch across the text too. Pin it to just the icon's own 24×24
   footprint (the first flex child, vertically centered) instead. */
.nav-mobile-linkedin .nav-icon-hover {
  top: 50%;
  left: 0;
  width: 24px;
  height: 24px;
  transform: translateY(-50%);
}

@media (max-width: 700px) {
  .nav-links { display: none; }
  .nav-hamburger { display: flex; }
}

/* ---------- Fixed line-art backdrop ----------
   Always present, pinned behind the whole page from the very start. The
   opaque hero photo covers it initially; as the hero scrolls away normally
   (no pinning, no fading — it just leaves like any other section), this
   layer is revealed underneath, and stays visible through the translucent
   glass Metrics/Work cards until the opaque footer finally covers it. */

.line-art-fixed {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  background: linear-gradient(135deg, #f59ca0 0%, #ffcfa3 100%);
}

.line-art-fixed img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 60% 20%;
}

/* Per-project background photo, faded in behind the line-art (via the
   overlay blend + 60% opacity from the Figma spec, so the warm gradient
   still shows through) while that project is hovered. Tablet/desktop only. */
.project-bg-photo {
  opacity: 0;
  mix-blend-mode: overlay;
  transition: opacity 0.6s var(--ease-out);
}

.line-art-lines {
  transition: opacity 0.6s var(--ease-out);
}

/* Desktop-only interactive replacement for .line-art-lines (homepage) and
   the plain gradient (contact page) — see js/line-art.js, which injects
   this into any .js-line-art-host and no-ops entirely on touch/coarse
   pointers, leaving the static image/gradient as the only background
   there. pointer-events: none so it never intercepts clicks on whatever
   sits above it. */
.line-art-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
  transition: opacity 0.6s var(--ease-out);
}

/* Per-project background-photo swap on hover was removed per feedback
   (read as jarring) — the wave line-art now stays showing at its normal
   opacity for every hover state, on every project. .project-bg-photo
   elements remain in the markup (still at their default opacity: 0) in
   case this is revisited later. */

/* ---------- Hero: normal-flow section, scrolls with the page ---------- */

.hero {
  position: relative;
  /* Explicit z-index (not just position: relative) because this is
     tied with .page-content's own z-index otherwise — .page-content
     comes later in the DOM, but empirically *this* won that tie, hiding
     #metrics (which overlaps up into this section's own bottom portion
     — see #metrics' own negative-margin comment) behind .hero-inner's
     content instead of in front of it. .page-content is deliberately
     kept higher still (see its own comment) so that tie can't recur. */
  z-index: 6;
  height: 100vh;
  /* iOS Safari's 100vh assumes the address-bar chrome is fully collapsed,
     so while it's actually showing, the real visible viewport is shorter
     than 100vh — and since this section is bottom-anchored (flex-end
     below), that shortfall pushes the intro card's own top edge above the
     real visible top and behind the fixed nav. 100dvh tracks the chrome's
     actual current state instead. */
  height: 100dvh;
  overflow: hidden;
  /* Matches the photo's own right-edge tone — a safety net so any stray
     sub-pixel rendering gap blends in invisibly instead of showing a seam. */
  background-color: #e78f76;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}

/* A real <img> with object-fit instead of a CSS background-image — percentage
   sizing here correctly respects the actual content box (no 100vw-vs-
   scrollbar mismatch), so it reliably reaches every edge. */
.hero-bg-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center center;
  z-index: 0;
}

.hero-inner {
  position: relative;
  z-index: 1;
  max-width: 1920px;
  width: 100%;
  margin: 0 auto;
  /* Nav pill's own rendered height (~82px from the top of the viewport to
     its bottom edge) + a 56px gap, matching the 56px gap below the card.
     Bottom padding has two jobs: a flat 220px permanently reserves room
     for #metrics, which overlaps up into exactly that space (see
     .content-col's own negative margin-top, same 220px) so the global
     metrics row sits inside the hero itself, just below the logo
     cascade, instead of below the fold. On top of that, since .hero is
     flush-bottom (justify-content: flex-end below), past 969px tall —
     the reference height this was tuned against — extra viewport height
     needs to go somewhere. Split it evenly between top and bottom
     padding (half each) instead of dumping it all below, so the whole
     portrait/card/logo/metrics cluster stays vertically centered in the
     hero on tall screens rather than drifting toward the top. Below
     969px both max(...) terms are exactly 0, so only the fixed 138px
     nav clearance and 220px metrics reservation apply and nothing here
     changes. */
  padding: calc(138px + max(0px, 100dvh - 969px) / 2) var(--gutter) calc(220px + max(0px, 100dvh - 969px) / 2);
}

.hero-row {
  position: relative;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding-bottom: 0;
}

.hero-portrait {
  position: relative;
  z-index: 2;
  width: clamp(220px, 24vw, 400px);
  flex-shrink: 0;
  margin-right: clamp(-48px, -4vw, -20px);
  margin-bottom: clamp(-160px, -12vw, -110px);
  /* Scale up from the bottom-center anchor so the layout position (and the
     flush-bottom alignment) doesn't shift — only the visual size grows. */
  transform: translateX(-16px) scale(1.18);
  transform-origin: bottom center;
}

.intro-card {
  position: relative;
  z-index: 1;
  width: min(806px, 100%);
  padding: clamp(28px, 4vw, 60px) clamp(24px, 4vw, 62px) clamp(36px, 5vw, 62px);
}

.intro-card h1 {
  font-weight: 300;
  font-size: clamp(28px, 3.4vw, 50px);
  letter-spacing: 0.05em;
  line-height: 1.25;
  margin: 0 0 20px;
}

.intro-card .intro-lede {
  font-weight: 300;
  font-size: clamp(17px, 1.8vw, 24px);
  line-height: 1.4;
  letter-spacing: 0.02em;
  margin: 0 0 28px;
}

.intro-card .intro-lede strong { font-weight: 700; }

.intro-divider {
  width: 46px;
  height: 4px;
  background: var(--color-accent);
  border: 0;
  margin: 0 0 22px;
}

.intro-card p.intro-body {
  font-weight: 300;
  font-size: 16px;
  line-height: 1.58;
  letter-spacing: 0.07em;
  color: var(--color-text-body);
  margin: 0;
}

/* Desktop-width but short window (a laptop with a lot of browser chrome,
   say) — the portrait + card + logo cascade's combined natural height
   can exceed a short 100dvh, and since the section clips (overflow:
   hidden) and bottom-anchors (flex-end) its content, the shortfall
   pushed the portrait's head (and the top of the intro card) up above
   the visible area and behind the fixed nav instead of just being
   shorter. Letting the section grow past the viewport here — same fix
   as the mobile block below, just triggered by height instead of
   width — means the portrait/card are never clipped; the page simply
   scrolls a little to reveal what didn't fit. Metrics' own overlap
   into the hero (.content-col's margin-top, .hero-inner's padding-
   bottom) is untouched here, unlike the mobile block, since that
   treatment still applies at this width.

   969px, not some smaller "obviously short" number — that's the exact
   height this whole hero was tuned against everywhere else (.hero-inner
   and .content-col's own formulas both reference it), so it's precisely
   the crossover point: at 969 content fits exactly (this rule is a
   no-op there), and below it, without this, the same clipping starts —
   a first attempt at 750px still clipped the portrait's head well
   above that. */
@media (max-height: 969px) and (min-width: 801px) {
  .hero { height: auto; min-height: 100vh; min-height: 100dvh; }
}

@media (max-width: 800px) {
  .hero-portrait { display: none; }
  .intro-card { margin: 0 auto; }
  /* Let the section grow past the viewport instead of clipping (via the
     overflow: hidden above) if the card + logo bar still don't fit in the
     available height on a short phone screen — auto-growing here is what
     actually guarantees the card never gets clipped behind the fixed nav,
     with the extra top padding below giving it clearance beyond that. */
  .hero { height: auto; min-height: 100vh; min-height: 100dvh; }
  /* height:auto above means .hero already grows to fit content instead
     of clipping it, so the tall-viewport padding-bottom trick (desktop
     .hero-inner, see its own comment) isn't needed and is reset here. */
  .hero-inner { padding-top: 180px; padding-bottom: 0; }
  /* !important: .content-col's own base rule (much further down the
     file) sets the margin shorthand — margin-top: -220px included — as
     one unconditional, equal-specificity declaration. Being later in
     source order, that shorthand was winning the cascade tie and
     silently overwriting this margin-top back to -220px even on
     mobile, pulling #metrics up to overlap the hero and clip the intro
     card's own text behind it instead of sitting flush below it. */
  .content-col { margin-top: 0 !important; }
  /* The whole docked-shelf treatment (and the "Let's Build!"/gap-shrink
     sequence that runs alongside it, main.js) is desktop-only (matching
     the case-study hero's own side-by-side-vs-stacked breakpoint) —
     metrics just flows normally on mobile, 5 numbers only, with #work
     following immediately after at its own natural spacing. */
  #metrics { position: static; top: auto; }
  #work { margin-top: 0; }
}

.project-link {
  position: relative;
  /* Plain flow by default — title and description just read left to
     right in one line, same as every other (non-hover-enabled) project
     row. Flex/space-between is only turned on below while actually
     hovered/focused, so it doesn't force the description into a
     right-justified column the rest of the time. */
  display: block;
  gap: 10px;
  margin: -6px -8px;
  padding: 6px 8px;
  border-radius: 8px;
  transition: background-color 0.2s var(--ease-out);
}

.project-link:hover,
.project-link:focus-visible {
  background: rgba(255, 255, 255, 0.12);
}

.project-cta {
  display: none;
  flex-shrink: 0;
  align-items: center;
  gap: 4px;
  background: rgba(255, 255, 255, 0.85);
  color: #000;
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.03em;
  padding: 5px 10px;
  border-radius: 999px;
  white-space: nowrap;
}

@media (hover: hover) and (pointer: fine) {
  .project-link:hover,
  .project-link:focus-visible {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }

  .project-link:hover .project-desc,
  .project-link:focus-visible .project-desc {
    display: none;
  }

  .project-link:hover .project-cta,
  .project-link:focus-visible .project-cta {
    display: inline-flex;
  }

  /* Flex items default to a content-based min-width, which can push a
     long project name past the card's edge once the row switches to
     flex/space-between on hover. Letting it shrink and ellipsize instead
     keeps the card's own overflow:hidden from ever clipping mid-glyph or
     forcing horizontal scroll. */
  .project-link:hover strong,
  .project-link:focus-visible strong {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}

/* Logo bar — same width as the nav pill. A clean 56px gap below the intro
   card; the portrait (pulled down independently, see .hero-portrait) passes
   behind it via z-index rather than the bar overlapping via negative margin. */

.logo-bar-wrap {
  position: relative;
  z-index: 3;
  max-width: 900px;
  width: 100%;
  margin: 56px auto 0;
  padding: 0 var(--gutter) clamp(20px, 2.6vw, 32px);
}

.logo-bar {
  background: #000;
  border-radius: var(--radius-pill);
  /* Nav pill's content box is 34px tall (its LinkedIn icon) + 14px top/bottom
     padding = 62px total. Logos stay at their original 25px height (not
     enlarged) — match that same 62px total by padding vertically instead. */
  padding: 18.5px 34px;
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: center;
  gap: clamp(18px, 2.6vw, 30px);
}

/* Standard logos get flattened to pure white by the filter, then opacity
   brings that down to ~#C8C8C8 against the solid black pill
   (255 * 0.78 ≈ 200, i.e. #c8c8c8). */
/* Between phone and full desktop width, all 6 reels plus their gaps no
   longer fit .logo-bar-wrap's own (viewport-constrained, up to its
   900px max) width — flex-wrap: nowrap keeps them from wrapping to a
   second row, so instead they overflowed the pill's own rounded edges,
   right off the edge of the viewport at this narrower range. Hiding 2
   of the 3 already-mobile-hidden reels here brings it down to a
   comfortable 4 that actually fit, without needing the *further* cut to
   3 phones get (that's still gated behind the narrower max-width: 600px
   below, unaffected by this). */
@media (max-width: 820px) {
  .logo-reel.hide-narrow { display: none; }
}

/* On phones, show only the first 3 reels (smaller too) so the pill never
   overflows its own rounded edges. Padding matches the nav pill's own
   ~62px total height (34px content + 14px top/bottom) per the same
   62px target used above, so the two pills read as a matched pair. */
@media (max-width: 600px) {
  .logo-reel.hide-mobile { display: none; }
  /* Widened from 22px per feedback (not enough breathing room around the
     first/last logo) — but the 3 visible reels' own rotations are each
     merged with a hidden reel's pair on mobile (see js/main.js's own
     comment on that), so at any moment one of 4 logos can be showing
     per slot, not just 2. The *widest* possible combination (its own
     widest logo from each of the 3 slots, all landing at once) already
     grazed this pill's own edges at the old, tighter padding — widening
     it further without also shrinking the logos (see the img height
     override below) would have made that overflow worse, not better. */
  .logo-bar { padding: 21px 30px; gap: 12px; }
}

.logo-bar img {
  height: 23.75px;
  width: auto;
  filter: brightness(0) invert(1);
  opacity: 0.78;
}

/* Logos with meaningful two-tone detail (a cutout letter, a play triangle)
   are pre-colored directly in the SVG/PNG (#C8C8C8-ish shape, black detail)
   — the brightness/invert filter would flatten that detail to invisible,
   and the opacity trick above only works for flat single-tone icons. */
.logo-bar img.logo-precolored { filter: none; opacity: 1; }

/* Each logo sits in its own fixed-height "reel" — a mask (overflow:hidden)
   exactly one logo tall, with a track holding that logo stacked directly
   above its swap partner. main.js appends a 3rd frame (a clone of the
   first logo) to the track and always animates it downward one frame at
   a time — A, then B, then a repeat of A — snapping back to the real A
   with no transition once the repeat lands, so the new logo always
   slides in from the top and the old one always exits the bottom,
   however many times it repeats. transform itself is set inline by
   main.js (it has to measure each reel's actual pixel height, which
   differs at the mobile breakpoint below); this transition rule is what
   makes those inline changes animate instead of jumping. */
.logo-reel {
  height: 23.75px;
  overflow: hidden;
  /* .logo-bar is flex-wrap: nowrap, so without this the widest reels
     (whichever pair contains the widest logo) were being flex-shrunk
     to fit, capping that logo's width below its natural 25px-tall size
     via the global img{max-width:100%} rule — a second, smaller source
     of the same squishing .logo-reel-track > img's align-self fixes. */
  flex-shrink: 0;
}

.logo-reel-track {
  display: flex;
  flex-direction: column;
  transition: transform 0.3s var(--ease-out);
}

.logo-reel-track > img {
  height: 23.75px;
  flex-shrink: 0;
  display: block;
  /* .logo-reel-track is flex-direction: column, so align-items' default
     "stretch" was forcing every logo to the SAME width as its swap
     partner (width: auto on .logo-bar img above doesn't count as a
     "definite size" for stretch purposes, so it didn't prevent this) —
     stretching whichever of the pair is narrower into the wider one's
     aspect ratio. This lets each logo keep its own natural width. */
  align-self: flex-start;
}

/* These img-height overrides must stay in this later media query (not
   the earlier one near .logo-bar's own mobile rule) — an equal-specificity
   selector defined earlier in the cascade loses to the unconditional
   ".logo-reel-track > img" rule above regardless of which one's media
   query actually matches, so placing it before that base rule silently
   never applied on phones and let a 25px-tall image overflow an 18px
   mask, clipping the bottom of every logo. */
@media (max-width: 600px) {
  /* Shrunk from 18/20px alongside the wider padding above — needed to
     keep the widest possible logo combination (see that rule's own
     comment) fitting inside the pill with room to spare, not just
     scraping by. */
  .logo-reel,
  .logo-reel.logo-grow-reel {
    height: 16px;
  }
  .logo-reel.logo-grow-reel { height: 18px; }
  .logo-reel-track > img { height: 16px; }
  .logo-reel.logo-grow-reel .logo-reel-track > img { height: 18px; }
}

/* ---------- Below-the-fold content: transparent, so the fixed line-art
   backdrop shows through behind it and through the translucent glass cards ---------- */

.page-content {
  position: relative;
  /* Higher than .hero's own z-index (6) — .page-content, not
     .content-col, is .hero's actual sibling/competitor here (.content-
     col is nested *inside* page-content, so its own z-index — bumped to
     7 for this same reason — only ever mattered relative to its
     siblings within page-content, never against .hero directly, the
     same one-level-too-low mistake .content-col's own z-index bump had
     already run into once). #metrics sitting inside .content-col
     inside this sat behind .hero's own .hero-inner wherever the two
     spatially overlap (which is everywhere .content-col's negative
     margin pulls it up into), hiding it completely. */
  z-index: 8;
  background: transparent;
  /* No longer forced to a full extra viewport below the hero (that was
     to guarantee the hero's hard 100vh photo could never show through a
     gap) — .content-col now deliberately overlaps up into the hero's
     own bottom portion (see its own comment), so that guarantee isn't
     needed here anymore; normal block flow is what lets the overlap
     land right after the hero instead of a viewport below it.

     display:flex + min-height + .site-footer's own margin-top:auto is
     just a sticky-footer: on a browser tall enough that hero+content
     don't reach the bottom of the window, this pushes the footer (and
     its solid black background) down to actually reach the bottom
     instead of floating with a gap beneath it — .line-art-fixed is a
     fixed, viewport-covering layer, so any such gap would otherwise
     show its gradient/line-art through below the footer. No effect at
     all on the far more common case where content already exceeds one
     viewport. */
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
}

.content-col {
  position: relative;
  /* This being a positioned element with its own z-index makes it a
     stacking context, which caps *everything inside it* (including
     #metrics' own z-index: 5) at this value when compared against
     elements outside it, regardless of any of their own z-indexes —
     #metrics' 5 only ever matters relative to its siblings within this
     same context (#work, #reviews, etc.). The actual fix for #metrics
     rendering behind .hero-inner lives on .page-content instead (see
     that rule's own comment) — .page-content, not this, is .hero's real
     sibling/competitor in the stacking order. This is kept a bit above
     .hero's own z-index (6) anyway, mostly to avoid re-introducing that
     same kind of tie locally if anything here ever needs its own
     z-index bumped. */
  z-index: 7;
  /* Explicit width (not just max-width) — content whose preferred width
     changes per hover state (the Metrics label text) otherwise sizes
     Chrome's shrink-wrap to that content instead of stretching to fill,
     so swapping to a project with shorter metric labels (Solex, MySudo,
     Grow) visibly narrowed and re-centered this entire block — dragging
     the Work panel with it and shifting project links out from under
     the cursor mid-hover (the "glitch"/cursor-drop bug). width:100%
     (capped by max-width below) makes the size definite and constant
     regardless of which project's labels are showing. */
  width: 100%;
  max-width: var(--content-max);
  margin: calc(-220px - max(0px, 100dvh - 969px) / 2) auto 0;
  /* Pulled up to overlap the hero's own bottom portion, landing just
     below the logo cascade — same "shelf overlaps the photo above it"
     trick the case-study pages use (.case-hero-wrap's negative
     margin-top), just against the hero photo instead of a separate
     hero-photo section. Exactly mirrors .hero-inner's own padding-bottom
     formula below (same 220px flat term + half the same max(0,
     100dvh-969px) growth term, since .hero-inner now splits that growth
     term evenly between its top and bottom padding to keep the hero
     cluster vertically centered) — they have to move together, or the
     logo cascade (pinned by .hero-inner's formula) and the metrics row
     (pinned by this one) drift apart into a growing gap on tall
     viewports, which is exactly what happened when this only had the
     flat 220px term.
     Reset to 0 on mobile below, alongside that same reservation, since
     there's no room to overlap
     into on a phone-sized hero. No top padding anymore (that used to be
     nav clearance for when this was the first thing below a fully-
     scrolled-away hero) — #metrics' own sticky `top` offset below
     handles nav clearance once it actually docks there. */
  padding: 0 var(--gutter) clamp(24px, 3vw, 36px);
  /* flex-column, not grid — a CSS Grid container gives each item its own
     row track as that item's containing block, which for a sticky child
     (#metrics) meant almost no room to actually stick within (the track
     is only as tall as #metrics' own content) and it just scrolled
     normally instead. Flex doesn't partition space that way, so
     #metrics can stick across the full height of .content-col like it's
     meant to. */
  display: flex;
  flex-direction: column;
  gap: clamp(16px, 2vw, 24px);
}

#metrics,
#work {
  scroll-margin-top: 100px;
}

/* Static gap between the shelf and #work — no JS involved any more (see
   the checkpoint branch checkpoint-pill-freeze-animation for the
   earlier version that animated this shut on scroll). Desktop only —
   mobile has no docked shelf to leave room for (see the 800px
   breakpoint, which resets this to 0). */
@media (min-width: 800px) {
  #work {
    margin-top: 20px;
  }
}

/* A bit more clearance than #work/#metrics — REVIEWS has no docked
   shelf sitting above it to visually separate it from the nav, so the
   same 100px left its heading landing flush against the nav pill with
   no breathing room. */
#reviews {
  scroll-margin-top: 140px;
}

/* Global metrics dock below the nav while scrolling through this
   section, instead of scrolling away with the hero — releasing
   naturally once .content-col (metrics + work, nothing else) scrolls
   past, since that's this element's sticky containing block. Docks
   tight against the nav pill (a small clearance, not the taller gap an
   earlier version used) per feedback that it should lock up closer to
   it. Fixed height always — just the 5 global stat cards, nothing else
   ever shares this shelf (see the checkpoint branch pre-work-display-v2
   for the earlier version, where it also expanded to reveal a
   carousel). */
#metrics {
  position: sticky;
  top: clamp(80px, 9vw, 96px);
  z-index: 5;
}

/* Wraps only #metrics (see index.html's own comment) so its sticky
   containing block is short instead of spanning all the way through
   #work and #reviews — that's what makes it release (undock) well
   before #work arrives, instead of staying pinned while #work/
   #reviews scroll past underneath it.

   An explicit min-height, not auto-from-content: a parent whose own
   height depends on its sticky child's height turns out to silently
   break the child's stickiness entirely in this browser (confirmed by
   testing — swapping this for padding-bottom, which relies on that
   auto-sizing, made #metrics stop sticking at all and just scroll
   normally). The scroll room actually available while stuck is this
   min-height *minus* #metrics' own rendered height (~163px, 5 stat
   cards + padding) — just enough here for #metrics to visibly lock up
   for a beat before releasing naturally as the page scrolls on. There's
   no scroll-driven sequence reserving room here any more (see the
   checkpoint branch checkpoint-pill-freeze-animation for the earlier
   version, where "Let's Build!" faded/grew/stretched/docked through a
   much larger version of this same reserved space). Kept tight on
   purpose — per earlier feedback, reserved-but-not-yet-scrolled-through
   space reads as "too much background, not enough content" on a tall
   window. */
.metrics-sticky-region {
  /* 220px would exactly cancel .content-col's own -220px base margin
     (see .lets-build-banner's own negative margin-top, which cancels
     the *rest* of the offset that creates — .content-col's flex `gap`
     and its own tall-viewport growth term — the same way): that's the
     minimum this could be and still land the banner flush at the
     hero's own bottom edge. The extra 100px past that (320 total) is
     deliberate scroll-stuck room, per feedback — #metrics stays docked
     long enough for the banner to fully scroll past behind it (see
     .lets-build-banner's own z-index-free stacking under #metrics'
     z-index: 5) before letting go, rather than releasing right as the
     banner arrives. */
  min-height: 320px;
}

/* Placed after the base rule above on purpose — equal specificity, so
   an earlier max-width: 800px block would otherwise lose this cascade
   tie to that later, unconditional rule regardless of which one's
   media query actually matches (a mobile browser was still getting
   the desktop min-height here, leaving a large dead gap before #work,
   until this moved below it). #metrics is position: static on mobile
   (see that same breakpoint), so none of the "room to stick" reasoning
   above applies there at all. */
@media (max-width: 800px) {
  .metrics-sticky-region { min-height: 0; }
}

.section-glass {
  padding: clamp(22px, 3.2vw, 32px) clamp(20px, 3vw, 40px) clamp(29px, 3.6vw, 38px);
}

/* No heading here (e.g. Metrics) — tighten the top padding so removing
   the title actually frees up space instead of leaving a gap. */
.section-glass-no-title {
  padding-top: clamp(14px, 1.8vw, 20px);
  padding-bottom: clamp(14px, 1.8vw, 20px);
}

/* Work panel: no divider under the heading anymore, and the whitespace
   around the (unchanged-height) cards is compressed so the panel wraps
   tightly around them instead of the cards themselves changing size. */
#work.section-glass {
  padding: clamp(12px, 1.6vw, 18px) clamp(20px, 3vw, 40px) clamp(12px, 1.6vw, 18px);
}

/* Tightened to match #work.section-glass's own top padding above — pulls
   the heading up closer to the top of its shelf instead of sitting in
   the base .section-glass padding (22-32px), which read as noticeably
   more top-heavy next to WORK's tighter treatment. */
#reviews.section-glass {
  padding-top: clamp(12px, 1.6vw, 18px);
}

/* Positioned so .reviews-hover-shelf below (an absolute-positioned
   sibling) anchors to *this* — the carousel's own footprint — rather
   than to #work as a whole, which would let the shelf cover the WORK
   tiles above the carousel too. */
.work-carousel-wrap {
  position: relative;
  margin-top: clamp(16px, 2vw, 24px);
  /* .reviews-hover-shelf (an absolute-positioned child, so it doesn't
     contribute to this wrap's own auto height on its own) renders
     taller than the carousel underneath it — same slot height
     (clamp(160px, 24vh, 420px)) plus its own padding on top of that
     (clamp(20px, 2.5vw, 28px), both sides) — so without this, WORK's
     glass card sized itself to the shorter carousel alone and the
     shelf spilled out past its own bottom edge the instant a project
     was hovered. Duplicating both clamps here (rather than a flat
     number) keeps this sized exactly to the shelf at any viewport. */
  min-height: calc(clamp(160px, 24vh, 420px) + 2 * clamp(20px, 2.5vw, 28px));
}

/* Ambient carousel: a continuous, never-stopping left-to-right scroll
   through every device-mockup project (js/main.js builds .work-carousel-
   track with two back-to-back copies of the full project list — the
   animation below only ever needs to slide exactly one copy's width
   before it can snap back to 0% with the *second* copy sitting exactly
   where the first one started, which is what makes the loop invisible).
   Reusing the same slot height as .reviews-hover-shelf .project-view-bar
   (clamp(160px, 24vh, 420px), see that rule) is deliberate — it's what
   lets the hover overlay below cover this without any visible height
   change the instant it appears. */
.work-carousel {
  overflow: hidden;
  /* Without this, the clip mask itself is a sharp rectangle — invisible
     where there's nothing behind it to show the cut, but every slot
     sliding across it gets clipped along that same hard-cornered edge
     at the left/right ends of this box, most noticeably right at its
     top/bottom corners where a rounded slot's own curve meets a square
     one. Has to match .reviews-hover-shelf's own radius (--radius-lg)
     exactly, not just be "rounded" — a smaller radius here (18px, to
     match the slots' own pill radius, was tried first) rounds this
     mask's corner *less* than the shelf's own corner sitting right on
     top of it, leaving a sliver at each corner where the shelf has
     already curved away (transparent there) but this hadn't yet, and
     whatever's sliding past underneath shows through in that gap. */
  border-radius: var(--radius-lg);
}

/* Sampling this constantly-moving content through the shelf's own
   backdrop-filter blur (see .reviews-hover-shelf) is what caused the
   glitching reported in its glass effect right as it appears — pausing
   the animation for as long as the shelf covering it is active removes
   the moving target entirely. Scoped to the wrap (not .work-carousel-
   track directly) since that's the lowest common ancestor :has() can
   check the shelf's sibling .is-active class from. */
.work-carousel-wrap:has(.reviews-hover-shelf.is-active) .work-carousel-track {
  animation-play-state: paused;
}

.work-carousel-track {
  display: flex;
  width: max-content;
  gap: clamp(12px, 1.5vw, 20px);
  /* 90s, not 45s — doubled per feedback that it read too fast (same
     linear speed, half as much distance covered per second). */
  animation: work-carousel-scroll 90s linear infinite;
}

/* --work-carousel-shift is set (in px) by main.js right after it builds
   the track — see that IIFE's own comment for why a fixed px shift
   replaced a bare -50%: Firefox specifically ran the %-based version at
   a small fraction of its real speed. The 50% fallback here only
   covers the brief window before that JS line runs. */
@keyframes work-carousel-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(calc(-1 * var(--work-carousel-shift, 50%))); }
}

@media (prefers-reduced-motion: reduce) {
  .work-carousel-track {
    animation: none;
  }
}

.work-carousel-slot {
  position: relative;
  flex: 0 0 clamp(220px, 24vw, 340px);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: clamp(8px, 1vw, 12px) clamp(12px, 1.5vw, 20px);
  height: clamp(160px, 24vh, 420px);
}

/* The glass pill itself — sized to half the slot's own height and
   anchored to the bottom, rather than filling the whole slot. The
   device image above (sized exactly as before, still bound by the
   *slot's* full height) now reads as extending up past the pill's own
   top edge instead of being boxed in by it on all sides.
   --radius-lg (40px) is tuned for the *full-height* glass cards it's
   used on elsewhere — at this pill's own (much shorter) height, that
   same radius reads as a bulging capsule with its top corners visibly
   ballooning outward past the sides, so this uses a smaller radius
   scaled to the pill instead. */
.work-carousel-slot::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 50%;
  border-radius: 18px;
  background: var(--glass-bg);
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  border: 1px solid var(--glass-border);
  z-index: 0;
}

.work-carousel-slot-img-wrap {
  position: relative;
  z-index: 1;
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  width: 100%;
}

.work-carousel-slot img {
  display: block;
  margin: 0 auto;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
}

.work-carousel-slot-caption {
  position: relative;
  z-index: 1;
  flex: 0 0 auto;
  text-align: center;
  font-weight: 500;
  font-size: 13px;
  color: var(--color-text);
}

.work-carousel-slot-caption span {
  display: block;
  font-weight: 300;
  font-size: 12px;
  color: var(--color-text-body);
  opacity: 0.75;
}

/* Below this height, .work-carousel-slot's own clamp(160-420px) leaves
   too little of the tile for the device image to actually read —
   name/year sitting in their own dedicated space underneath eats
   further into it. So here the caption becomes a glass "lower third"
   overlapping the bottom of the image instead — same glass treatment as
   the tile itself — freeing the whole tile height for the image, at the
   cost of the caption sitting over it rather than below it (fine at
   this size; not needed once the tile has more room to spare). */
@media (max-height: 1040px) {
  .work-carousel-slot {
    position: relative;
    padding: 0;
    overflow: hidden;
  }

  .work-carousel-slot-img-wrap {
    position: absolute;
    inset: 0;
  }

  .work-carousel-slot-caption {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 8px clamp(12px, 1.5vw, 20px) 10px;
    background: var(--glass-bg);
    backdrop-filter: blur(20px) saturate(160%);
    -webkit-backdrop-filter: blur(20px) saturate(160%);
  }
}

@media (max-width: 800px) {
  .work-carousel-wrap {
    display: none;
  }
}

/* Glass overlay over the carousel, shown only while hovering a WORK
   project (see js/main.js's reviews-hover-shelf IIFE for the node-
   reparenting that fills this in — used to cover METHODS instead,
   before the carousel existed here for it to cover). Invisible and
   reserves no layout space by default — nothing here to look at with no
   project hovered, so there is no permanent shelf sitting over the
   carousel the rest of the time. */
.reviews-hover-shelf {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: clamp(12px, 1.5vw, 20px);
  padding: clamp(20px, 2.5vw, 28px) clamp(20px, 3vw, 40px);
  border-radius: var(--radius-lg);
  background: var(--glass-bg);
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  border-bottom: 1px solid var(--glass-border);
  box-shadow: 7px 11px 26px rgba(162, 98, 110, 0.6);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.25s var(--ease-out), visibility 0s linear 0.25s;
}

.reviews-hover-shelf.is-active {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.25s var(--ease-out);
}

/* Space above the "How I Lead" copy block and between it and the
   REVIEWS eyebrow that now sits above the carousel — mirrors
   .case-subsection's own top margin on case-study pages. */
.how-i-lead {
  margin-bottom: clamp(28px, 3vw, 40px);
}

/* Matches .case-section > p's own column width so it lines up with the
   paragraphs around it instead of running edge-to-edge of the (much
   wider) glass card. */
.how-i-lead-photo {
  display: block;
  width: 100%;
  max-width: 780px;
  height: auto;
  margin: 4px auto 22px;
  border-radius: var(--radius-lg);
}

.reviews-eyebrow {
  margin-bottom: clamp(20px, 2.5vw, 28px);
  /* Mathematically centered (verified), but the word's own letter
     shapes read as sitting slightly right of METHODS/How I Lead above
     it — a small optical nudge left, not a real alignment fix. */
  transform: translateX(-3px);
}

/* Keeps this short closing line left-aligned to match the longer
   paragraphs above it once they've wrapped past 3 lines (see main.js's
   ResizeObserver-driven .js-long-copy toggle) — it's short enough to
   never cross that 3-line threshold itself, so without this override it
   would render centered while its neighbors read left-aligned.
   !important beats that toggle regardless of which class it lands on. */
.how-i-lead p.case-force-left {
  text-align: left !important;
}

/* Layout for whichever preview node (see js/main.js) is currently
   re-parented in here from .preview-templates — flex sizing, since this
   shelf is a plain flex row. */
.reviews-hover-shelf .project-view-bar {
  flex: 0 0 42%;
  /* Unlike the docked shelf above, there's no taller row-2 track (and no
     already-glass-covered row 1 above it) for the mockup to safely
     overflow into here — it would just spill, uncontained, onto the
     review cards below the shelf. Grow the shelf itself to the image's
     full height instead of clipping it back down to .metric-card's
     119px, and clip anything that still doesn't fit within that. */
  height: clamp(160px, 24vh, 420px);
  overflow: hidden;
}

.reviews-hover-shelf .metrics-preview.metrics-grid {
  flex: 1 1 auto;
  margin-top: 0;
  align-self: auto;
}

.reviews-hover-shelf .project-gallery-preview {
  width: 100%;
  margin-top: 0;
}

#work.section-glass h2,
#reviews.section-glass h2 {
  font-size: clamp(18px, 2vw, 24px);
  margin-bottom: 10px;
}

/* Extra room below REVIEWS' heading specifically — pulling the top
   padding in above tightened the gap above the heading, but the cards
   below it then read as cramped against it without a bit more space
   here too. */
#reviews.section-glass h2 {
  margin-bottom: 28px;
  /* Same optical nudge as .reviews-eyebrow above, for the same reason —
     mathematically centered, reads as slightly off to the eye. */
  transform: translateX(-3px);
}

/* "Let's Build!" banner: static, no animation, no scroll-driven anything
   — see the checkpoint branch checkpoint-pill-freeze-animation for the
   earlier scroll-scrubbed version (fade/grow/stretch/dock), removed per
   feedback in favor of this. Sits in normal document flow (see
   index.html) right after the metric shelf's own reserved space and
   directly above #work, full-bleed to the actual viewport edges despite
   living inside .content-col's own centered, max-width column — the
   standard "break out of a centered container" trick (100vw width,
   negative margins pulling it back to center) rather than restructuring
   the DOM to get it outside .content-col entirely. html/body's own
   overflow-x: clip (see that rule's comment) is what keeps this from
   ever creating an actual horizontal scrollbar. */
.lets-build-banner {
  width: 100vw;
  /* Three terms, each canceling a different part of what sits between
     .content-col's own top and this (see that rule's own comment, and
     .metrics-sticky-region's): the first cancels .content-col's flex
     `gap` (clamp(16px, 2vw, 24px), duplicated here on purpose); the
     second cancels the *growth* term in .content-col's own margin-top
     (max(0px, 100dvh - 969px) / 2, also duplicated), which otherwise
     only shows up on windows taller than 969px and grows the gap here
     right along with the window; the third cancels the 100px of extra
     stuck-scroll room .metrics-sticky-region's own min-height reserves
     *beyond* what would otherwise land this flush (320px there, 220px
     needed — see that rule's own comment). Without that third term, this
     would still touch the photo at rest, but only after scrolling an
     extra 100px past where it used to — this keeps it touching
     immediately, with #metrics simply staying docked over top of it for
     that same 100px instead. Together, this lands touching the hero
     photo's bottom edge at *any* window height, not just short ones. */
  margin-top: calc(-1 * clamp(16px, 2vw, 24px) + max(0px, 100dvh - 969px) / 2 - 100px);
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  padding: clamp(14px, 1.8vw, 20px) var(--gutter);
  text-align: center;
  /* .glass (applied in the markup) gives it the same background/blur/
     border every other glass panel uses; a flat edge-to-edge bar reads
     better without the rounded corners that utility also adds. */
  border-radius: 0;
  border-left: none;
  border-right: none;
}

/* Placed after the base rule above on purpose (same cascade-tie reason
   as .metrics-sticky-region's own max-width: 800px override) — an
   earlier media query would otherwise lose to the unconditional
   margin-top rule above regardless of viewport.

   #metrics is position: static on mobile/tablet (no sticky docking,
   see that breakpoint) and .metrics-sticky-region collapses to
   min-height: 0 there too, so this base rule's three-term cancellation
   math (all tuned for the desktop docked-shelf geometry) doesn't apply
   at all — left in place, it just pulled this banner ~120px up behind
   #metrics (hidden by its z-index) and dragged #work's own heading up
   underneath the last metric card right along with it.

   order: -1 moves this to the front of .content-col's flex children
   (.content-col is display: flex; flex-direction: column — see that
   rule), ahead of .metrics-sticky-region, so it reads as linked to the
   hero photo the way the user asked for on these sizes, rather than
   sitting behind the docked metrics like it does on desktop. margin-
   top: 0 is enough on its own to land it flush against the photo here
   — .content-col's own margin-top is forced to 0 on mobile too (see
   that rule's own !important override), so there's no overlap/gap math
   left to cancel once this is first in flow. */
@media (max-width: 800px) {
  .lets-build-banner {
    order: -1;
    margin-top: 0;
  }
}

.lets-build-banner p {
  margin: 0;
  /* Matches WORK/METHODS' own heading size exactly — #work.section-glass
     h2 (further up) overrides the general .section-glass h2 size
     smaller, so this follows that override, not the generic one.
     Letter-spacing matches the generic heading tracking too. */
  font-size: clamp(18px, 2vw, 24px);
  font-weight: 300;
  letter-spacing: 0.065em;
  color: #000;
}

.section-glass h2 {
  text-align: center;
  font-weight: 300;
  font-size: clamp(22px, 2.4vw, 32px);
  /* Was 0.13em — reduced 50% globally, per feedback that section
     headings (case-study "The Approach" style headers included, via
     .case-section h2's own inherited letter-spacing) read too tracked-out. */
  letter-spacing: 0.065em;
  margin: 0 0 20px;
}

.section-divider {
  width: 46px;
  height: 4px;
  background: var(--color-accent);
  border: 0;
  margin: 0 auto clamp(24px, 3vw, 36px);
}

/* Metrics grid — big stat + caption, glass cards */

/* Global metrics — the only thing #metrics ever shows now, so this is
   just a plain 5-up row, no shared grid with anything else. */
.metrics-grid {
  flex: 1;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: clamp(12px, 1.5vw, 20px);
}

/* Project preview bar: a device mockup shown in the lower hover shelf
   (see .reviews-hover-shelf .project-view-bar in style.css, and
   moveIntoShelf in js/main.js) while its matching project link is
   hovered in the Work section. Parked, hidden, in .preview-templates
   the rest of the time — no opacity/visibility fade of its own needed
   here, since .preview-templates[hidden] and the shelf's own fade
   already cover showing/hiding it entirely. 5 of 12 columns (~42%)
   matches the original Figma ratio for this bar relative to its
   accompanying stats. Height scales with the browser window's height
   (see clamp below) instead of a fixed size — taller window, taller
   mockup — with its own min/max so it never gets too small or too
   large. */
.project-view-bar {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  position: relative;
  /* Matches .metric-card's own height exactly — same shelf height as
     the stats sitting beside it (see .project-view-mockup below for how
     the actual device image stays large regardless). */
  height: 119px;
  overflow: visible;
  /* Matches .metric-card's own radius, for the same reason. */
  border-radius: 24px;
  background: var(--glass-bg);
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  border: 1px solid var(--glass-border);
}

/* Fixed 617×361 aspect ratio (the spec for every project mockup going
   forward) — scales down to fit a narrower/shorter bar, but is capped
   at its native size (via each image's own inline max-width) so it
   never upscales/blurs on a wide or tall one. Plain shrink-to-fit
   (max-width/max-height with auto width/height) rather than a forced
   size, so it keeps each mockup's real aspect ratio no matter which of
   the bar's two dimensions ends up the limiting one.

   max-height is its own clamp, not tied to .project-view-bar's own
   (much shorter, metric-card-matched) height — the bar overflows
   visibly (overflow: visible above) rather than being a viewport for
   the image, so the image keeps growing with window height same as
   before, simply poking up past the top of its shorter shelf. */
.project-view-mockup {
  display: block;
  margin: 0 auto;
  max-width: 100%;
  max-height: clamp(160px, 24vh, 420px);
  width: auto;
  height: auto;
}

/* Height-capped variant (e.g. Solex's taller monitor shot): an explicit
   height (set inline per-image), not tied to the bar's own small height
   — position: absolute lets it overflow past the bar regardless. Width
   follows proportionally via auto. */
.project-view-mockup-hcap {
  width: auto;
}

/* Shown instead of .metrics-default's 5 numbers, in the lower hover
   shelf, while a product/marketing project is hovered (see
   .reviews-hover-shelf .metrics-preview.metrics-grid in style.css).
   Two classes here beats the single-class .metrics-grid rule above on
   specificity (not source order), so this reliably wins regardless of
   where either rule sits in the file. */
.metrics-preview.metrics-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}

/* Photography/Illustration hover preview: a big cover image plus two
   smaller ones stacked beside it — shown in the lower hover shelf (see
   .reviews-hover-shelf .project-gallery-preview) while a photography/
   illustration project is hovered, since neither a device mockup nor a
   metrics grid makes sense for a photo/illustration gallery. Parked,
   hidden, in .preview-templates the rest of the time. */
.project-gallery-preview {
  display: flex;
  gap: clamp(8px, 1vw, 14px);
  align-items: flex-end;
}

.project-gallery-preview img,
.project-gallery-preview video {
  border-radius: var(--radius-lg);
  object-fit: cover;
  display: block;
}

.gallery-preview-cover {
  /* Height now scales with the browser window's height (like
     .project-view-bar's mockup) instead of a width-based clamp — width
     narrowed to 55% so the freed-up space goes to .gallery-preview-pair. */
  width: 55%;
  height: clamp(160px, 24vh, 420px);
}

.gallery-preview-pair {
  width: 43%;
  display: flex;
  gap: clamp(8px, 1vw, 14px);
  /* Same height as .gallery-preview-cover — all three tiles read as one
     even row instead of the pair sitting noticeably shorter beside it. */
  height: clamp(160px, 24vh, 420px);
}

.gallery-preview-pair img,
.gallery-preview-pair video {
  flex: 1 1 0;
  min-width: 0;
  width: 100%;
  height: 100%;
}

/* These two covers center-crop badly at this aspect ratio — San
   Francisco loses the bridge towers off the top, UtahJS's raptor loses
   its head — so pin the crop to the top of the source image instead,
   losing height off the bottom rather than off the top. */
.project-gallery-preview[data-preview-for="san-francisco"] .gallery-preview-cover,
.project-gallery-preview[data-preview-for="utahjs"] .gallery-preview-cover {
  object-position: top;
}

/* Below 900px: 3-over-2 instead of a uniform 2-column wrap, so the 5
   default cards never leave a lone orphan in a row by itself. A plain
   .metrics-grid rule here would also hit the 3-card hover-preview grids,
   but .metrics-preview.metrics-grid's own grid-template-columns (two
   classes) already outranks this everywhere, so scoping to
   .metrics-default specifically is just for clarity, not correctness. */
@media (max-width: 900px) {
  .metrics-default.metrics-grid {
    grid-template-columns: repeat(6, 1fr);
  }
  .metrics-default.metrics-grid .metric-card:nth-child(-n+3) {
    grid-column: span 2;
  }
  .metrics-default.metrics-grid .metric-card:nth-child(n+4) {
    grid-column: span 3;
  }
}

/* Phones specifically (not tablet, which keeps the 3-over-2 layout above):
   a 2-per-row card is still too narrow for labels like "ADDITIONAL
   MONTHLY REVENUE" to fit without clipping, so stack every default card
   in a single column instead. */
@media (max-width: 700px) {
  .metrics-default.metrics-grid {
    grid-template-columns: 1fr;
  }
  .metrics-default.metrics-grid .metric-card:nth-child(-n+3),
  .metrics-default.metrics-grid .metric-card:nth-child(n+4) {
    grid-column: span 1;
  }
}

.metric-card {
  background: var(--glass-bg);
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  border: 1px solid var(--glass-border);
  border-radius: 24px;
  /* Fixed (not min-) height — every metrics row, default or any project
     preview, must render at exactly the same height no matter how long
     that project's label text is. Otherwise a shorter label lets the row
     shrink on hover, which shifts the Work section (and the hovered link
     itself) out from under a stationary cursor, ending the hover, which
     reverts the row and shifts it back — an infinite flicker loop. 119px
     (~20% shorter than the original 148px) still gives enough headroom
     for the longest known label ("ROI ACROSS ALL COMPANIES THAT I'VE
     ASSISTED", now shortened to "ROI ACROSS COMPANIES") to wrap without
     overflowing now that it can no longer grow the card taller itself —
     every other label on the homepage and case-study pages was verified
     to fit at this height too.
     min-height: 0 + overflow: hidden override flexbox's default implicit
     min-height: auto on flex items — without this, a card can still grow
     past its explicit height to fit long label text despite the height
     rule above, since browsers treat that auto min-height as a floor. */
  padding: 20px 16px 16px;
  height: 119px;
  min-height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* flex-start (not center) so every stat number sits the same distance
     from the top, regardless of how many lines its own label wraps to —
     that's what keeps all the numbers on one shared baseline. */
  justify-content: flex-start;
  text-align: center;
  gap: 6px;
}

.metric-card .metric-stat {
  margin: 0;
  font-weight: 700;
  font-size: clamp(22px, 2.4vw, 30px);
  color: var(--color-accent);
  line-height: 1;
}

.metric-card .metric-label {
  margin: 0;
  font-weight: 400;
  font-size: 13px;
  line-height: 1.3;
  letter-spacing: 0.04em;
  color: var(--color-text);
}

/* Below the desktop hover-preview breakpoint (see the 800px gate above),
   there's no hover state to protect a fixed height against — so let the
   row/cards size to their own content instead of staying locked at the
   148px desktop height. Without this, wrapping to 2 or 1 columns here
   needs 2-3 rows of cards, but the row's box stayed capped at a single
   row's height (148px) and the extra rows just overflowed underneath it,
   invisible and unclickable, with the Work section rendering on top of
   them. Auto-height also removes the empty space a short label (e.g.
   "USERS") left below itself at the fixed height once cards get wider
   than one column. Desktop (>900px) is untouched. */
@media (max-width: 900px) {
  .metric-card { height: auto; }
}

/* ---------- Work grid: liquid-glass expanding cards ---------- */

.work-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: clamp(12px, 1.5vw, 20px);
}

@media (max-width: 900px) {
  .work-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 520px) {
  .work-grid { grid-template-columns: 1fr; }
}

/* Below the 900px breakpoint there's no hover, only tap (.is-open) —
   and the card's fixed square aspect-ratio, sized for a *closed* tile,
   leaves the opened project list only ~85px tall against ~400px of
   actual content, so all but one or two links sit behind an awkward
   internal scroll. Opened, the card instead spans the full row width
   and grows tall enough for its longest list (Illustration & Games,
   7 items) so every link is visible without scrolling; the other
   card(s) in that row reflow below it. */
@media (max-width: 900px) {
  .work-card.is-open {
    grid-column: 1 / -1;
    aspect-ratio: auto;
    min-height: 460px;
  }
}

.work-card {
  position: relative;
  border-radius: 28px;
  aspect-ratio: 284 / 249;
  overflow: hidden;
}

.work-card > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.6s var(--ease-out);
}

.work-card:hover > img,
.work-card.is-open > img {
  transform: scale(1.06);
}

/* Positioning wrapper only — invisible at rest (the visible pill is now
   .work-card-pill itself, sized/styled to match the "View Study" button
   spec) and becomes the dark expanded panel on hover. */
.work-card-glass {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 62%;
  height: 44px;
  border-radius: 999px;
  background: transparent;
  border: 1px solid transparent;
  overflow: hidden;
  transition: width 0.5s var(--ease-out), height 0.5s var(--ease-out),
    border-radius 0.5s var(--ease-out), background 0.5s var(--ease-out),
    border-color 0.5s var(--ease-out);
}

.work-card:hover .work-card-glass,
.work-card.is-open .work-card-glass {
  width: 100%;
  height: 100%;
  border-radius: 28px;
  background: rgba(20, 16, 16, 0.55);
  border-color: var(--glass-border);
}

/* Front-facing category pill — matches the "View Study" button spec
   (.project-cta) exactly: same font size, padding, and background. */
.work-card-pill {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.85);
  color: #000;
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.03em;
  padding: 5px 10px;
  border-radius: 999px;
  white-space: nowrap;
  opacity: 1;
  transition: opacity 0.35s var(--ease-out);
  transition-delay: 0.15s;
}

.work-card:hover .work-card-pill,
.work-card.is-open .work-card-pill {
  opacity: 0;
  transition-delay: 0s;
}

.work-card-detail {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: 18px 20px;
  opacity: 0;
  transition: opacity 0.35s var(--ease-out);
  transition-delay: 0s;
}

.work-card:hover .work-card-detail,
.work-card.is-open .work-card-detail {
  opacity: 1;
  transition-delay: 0.2s;
}

.work-card-detail h3 {
  color: #fff;
  font-weight: 500;
  font-size: 12px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin: 0 0 8px;
}

/* Divider removed to free up room for the hover list. */
.work-card-detail .work-card-divider {
  width: 28px;
  height: 3px;
  background: var(--color-accent);
  border: 0;
  margin: 0 0 10px;
  flex-shrink: 0;
}

.work-card-detail ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 6px;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
}

/* display:block is the shared default for every row here (Marketing/
   Photography/Illustration's plain links rely on it). Product's rows
   additionally carry .project-link, whose own :hover/:focus-visible rule
   (specificity 0,2,0) beats this (0,1,2) to switch just the active row to
   flex/space-between — see .project-link above. */
.work-card-detail li a {
  display: block;
  color: rgba(255, 255, 255, 0.85);
  font-size: 12px;
  line-height: 1.4;
  letter-spacing: 0.02em;
  transition: color 0.18s var(--ease-out);
}

.work-card-detail li a strong {
  font-weight: 700;
  color: #fff;
  transition: color 0.18s var(--ease-out);
}

.work-card-detail li a:hover,
.work-card-detail li a:focus-visible,
.work-card-detail li a:hover strong,
.work-card-detail li a:focus-visible strong {
  color: var(--color-accent);
}

/* Cards go full-width on mobile (single column), so there's plenty of room
   to size the list text up for legibility instead of keeping the cramped
   desktop-grid sizing. */
@media (max-width: 600px) {
  .work-card-detail { padding: 26px 26px; gap: 4px; }
  .work-card-detail h3 { font-size: 22px; margin-bottom: 14px; }
  .work-card-detail .work-card-divider { width: 40px; height: 4px; margin-bottom: 20px; }
  .work-card-detail ul { gap: 16px; }
  .work-card-detail li a { font-size: 18.5px; line-height: 1.4; }
}

/* ---------- Footer ---------- */

.site-footer {
  text-align: center;
  padding: 14px var(--gutter) 18px;
  font-size: 14px;
  color: rgba(255, 255, 255, 0.7);
  background: #000;
  position: relative;
  z-index: 2;
  /* Only does anything on the homepage, where .page-content is a flex
     column (see its own comment) — pushes the footer to the bottom of
     a tall browser window instead of leaving a gap beneath it. A plain
     block-flow margin-top:auto (every other page) just resolves to 0,
     so this is a no-op everywhere else. */
  margin-top: auto;
}

.site-footer a { font-weight: 600; color: #fff; }
.site-footer a:hover { color: var(--color-accent); }

/* ---------- Contact page ----------
   A single centered glass card on .case-page-bg's gradient (same fixed-
   gradient-behind-the-page trick every case study uses) — headshot,
   headline, intro copy, then the form itself. No backend on this static
   site, so the form hands off to a mailto: link (see the .js-contact-
   form handler in main.js) rather than posting anywhere. */
.contact-page {
  position: relative;
  z-index: 1;
  max-width: 900px;
  margin: 0 auto;
  padding: clamp(120px, 16vw, 170px) var(--gutter) clamp(60px, 8vw, 100px);
}

.contact-card {
  /* No padding here — .contact-banner needs to sit flush against the
     card's own left/top/right edges, so all the padding lives on
     .contact-card-body instead. overflow:hidden clips the banner's
     own (sharp) top corners to the card's rounded ones for free. */
  overflow: hidden;
  text-align: center;
}

.contact-banner {
  display: block;
  width: 100%;
  height: auto;
}

.contact-card-body {
  padding: clamp(32px, 5vw, 56px) clamp(24px, 5vw, 60px);
}

.contact-card h1 {
  font-weight: 300;
  font-size: clamp(28px, 3.1vw, 40px);
  letter-spacing: 0.03em;
  white-space: nowrap;
  margin: 0 0 16px;
}

.contact-intro {
  font-weight: 300;
  font-size: clamp(16px, 1.6vw, 19px);
  line-height: 1.6;
  color: var(--color-text-body);
  max-width: 560px;
  margin: 0 auto 36px;
}

.contact-intro a {
  color: var(--color-accent);
  font-weight: 500;
}

.contact-form {
  display: flex;
  flex-direction: column;
  gap: 20px;
  text-align: left;
}

/* Web3Forms spam honeypot: invisible to a real visitor, but a bot
   filling every field on the page fills this too, which flags the
   submission as spam server-side. display:none (not type="hidden")
   is the documented approach — some bots skip actual hidden inputs. */
.contact-honeypot { display: none; }

.contact-field {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.contact-field label {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--color-text-body);
}

.contact-field input,
.contact-field textarea {
  font-family: inherit;
  font-size: 16px;
  font-weight: 300;
  padding: 14px 18px;
  border-radius: 16px;
  border: 1px solid rgba(0, 0, 0, 0.15);
  background: #fdf6ee;
  color: var(--color-text);
  resize: vertical;
}

.contact-field textarea { min-height: 140px; }

/* Chrome paints its own light-blue/yellow fill behind an autofilled
   input (the name/email fields here almost always are), overriding our
   background color outright. The only way to override that back is
   this oversized inset box-shadow trick — a plain background-color
   declaration has no effect on the autofill state at all. */
.contact-field input:-webkit-autofill,
.contact-field input:-webkit-autofill:hover,
.contact-field input:-webkit-autofill:focus,
.contact-field input:-webkit-autofill:active {
  -webkit-text-fill-color: var(--color-text);
  box-shadow: 0 0 0 1000px #fdf6ee inset;
  -webkit-box-shadow: 0 0 0 1000px #fdf6ee inset;
  transition: background-color 5000s ease-in-out 0s;
}

.contact-field input:focus,
.contact-field textarea:focus {
  outline: none;
  border-color: #000;
  box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.12);
}

.contact-submit {
  align-self: center;
  margin-top: 8px;
  padding: 14px 44px;
  border-radius: var(--radius-pill);
  border: none;
  background: #000;
  color: #fff;
  font-weight: 500;
  font-size: 15px;
  letter-spacing: 0.03em;
  cursor: pointer;
  transition: background 0.3s var(--ease-out), transform 0.2s var(--ease-out);
}

.contact-submit:hover { background: var(--color-accent); transform: translateY(-1px); }

.contact-confirm {
  text-align: center;
  margin: 4px 0 0;
  font-weight: 500;
  color: var(--color-accent);
}

.contact-confirm[hidden] { display: none; }

/* ---------- Case study pages: photo hero + lightbox ----------
   Shared template for the product case studies (Solex first, more of
   these to follow). A full-bleed photo band hero — mockup + glass text
   panel, borrowed from the Solex marketing site's own layout — followed
   by a consistent run of glass sections. Independent of the older
   .placeholder-wrap page below (used for case studies not started yet). */

.case-page {
  position: relative;
  z-index: 1;
  /* A dedicated, wider cap than the site's shared --content-max
     (1314px) — case-study pages have a wide photo/mockup hero above
     this (.case-hero-outer, now 1960px) and looked visibly narrower by
     comparison on large screens. Body text inside each section still
     wraps at its own 780px cap either way, so this just gives the
     metrics row and glass cards more room, not longer text lines. */
  max-width: 1600px;
  margin: 0 auto;
  padding: 0 var(--gutter);
  display: grid;
  gap: clamp(16px, 2vw, 24px);
}

/* Wider than .case-page on purpose — the hero row (shelf + mockup + text
   card) gets more breathing room and a bigger mockup than the Problem/
   Approach/etc. sections below, which stay at the site's normal content
   width. Sits between .case-hero-photo and .case-page as its own
   sibling rather than inside .case-page, specifically so it isn't
   capped at --content-max. */
.case-hero-outer {
  position: relative;
  z-index: 2;
  /* Was 1600px — raised so the hero row keeps scaling up on large
     monitors instead of maxing out at laptop width (see the matching
     760/510/720/-280 ceilings below, all re-targeted to the same
     ~2000px reference so the whole hero scales as one unit). */
  max-width: 1960px;
  margin: 0 auto;
  padding: 0 var(--gutter);
  /* Separates the dots from Metrics below — .case-page has no top
     padding/gap before its first child, so without this the dots would
     read as glued to Metrics instead of to the hero above them. */
  margin-bottom: clamp(28px, 4vw, 48px);
}

.case-hero-photo {
  position: relative;
  z-index: 1;
  overflow: hidden;
  /* Just the photo now — title/date/description moved into
     .case-hero-text, nested with the mockup on the glass shelf below
     (see .case-hero-wrap). Height is a flat clamp rather than padding
     driven by text, since there's no text here to size around anymore.
     Ceiling raised from 520px to 760px (same 38vw rate, just carried
     out to a ~2000px reference instead of ~1368px) — keep in sync with
     .gallery-hero and .case-hero-sticky-nav's top offset, which repeat
     this exact clamp on purpose (see their own comments). */
  height: clamp(360px, 38vw, 760px);
}

/* --case-photo-pos lets individual pages re-aim the crop (e.g. shifted
   down to keep a face out of the fold) without a one-off selector per
   project — set alongside the color vars on that page's <body style>. */
.case-hero-photo-bg,
.case-hero-photo-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--case-photo-pos, center);
  z-index: 0;
}

.case-hero-photo-scrim {
  position: absolute;
  inset: 0;
  background: rgba(10, 12, 20, 0.4);
  z-index: 1;
}

/* Positioning context for the shelf (absolute, behind) and panel (flow,
   in front) — see individual rules below. Pulled up over the photo by
   roughly half its height, so the mockup + text card visibly cover
   about half the photo rather than just grazing its bottom edge. */
.case-hero-wrap {
  position: relative;
  z-index: 2;
  /* Was a flat -195px — now scales with the same photo/shelf ceiling
     raise above (still exactly -195px at 1400px, the point the desktop
     side-by-side layout kicks in, growing to -280px by ~2000px so the
     overlap stays proportional instead of freezing early). */
  margin-top: clamp(-280px, -14vw, -195px);
}

/* Wide glass shelf the mockup + text card are nested into — same pink
   glass color used everywhere else on the site (--glass-bg, overridden
   per project — see the body-level custom properties on each page).
   Fixed height (not a % of the wrap) anchored to the wrap's bottom, so
   it reliably extends a bit past the panel's own bottom edge (set via
   .case-hero-panel's margin-bottom below) rather than clipping flush
   against it — that's the "sliver of panel still showing" look. */
.case-hero-shelf {
  position: absolute;
  z-index: 0;
  left: 0;
  right: 0;
  bottom: 0;
  /* Shorter than the mockup/wrap it sits behind on purpose — the wrap's
     own height (and the mockup + card's fixed base position) doesn't
     depend on this at all, so shrinking it just reveals more of the
     photo above where the mockup now floats without shelf backing. */
  /* Ceiling raised from 357px to 510px, same rate/reference as
     .case-hero-photo above. */
  height: clamp(255px, 25.5vw, 510px);
  background: var(--glass-bg);
  /* Mild frosted blur — lighter than the text card's own blur, since
     this panel is much bigger and sits directly over the photo; too
     strong here would blur out the photo underneath the mockup too. */
  backdrop-filter: blur(10px) saturate(140%);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  border-radius: var(--radius-lg);
  /* Same GPU-layer pinning as .case-hero-text — this sits even closer
     to Zefr's autoplaying mockup video, so it's just as prone to the
     backdrop-filter flicker without it. */
  will-change: backdrop-filter, transform;
  transform: translateZ(0);
}

.case-hero-panel {
  position: relative;
  z-index: 1;
  /* Only the mockup is a flow child now — the text card is positioned
     absolutely off of it (see .case-hero-text) so the panel's height,
     and therefore the shelf/mockup/dots position beneath it, tracks the
     mockup alone and never shifts with copy length. */
  display: flex;
  justify-content: flex-start;
  gap: 20px;
  padding: 0 clamp(24px, 4vw, 60px);
}

.case-hero-mockup {
  flex: 0 0 62%;
  position: relative;
  display: block;
  width: 62%;
  /* Shifted left ~10% of its own width, bleeding into the panel's left
     padding toward .case-hero-outer's edge — the card's width (and its
     ability to overlap the mockup's right edge, see .case-hero-text)
     is what keeps the two from crowding each other now, not the
     mockup's own size. */
  margin-left: -6.2%;
  border: 0;
  background: none;
  padding: 0;
}

/* Only clickable (and only shows the play button) on pages with a video
   to trigger — see js/main.js. A plain <div> mockup on pages without one. */
.case-hero-mockup.js-video-trigger {
  cursor: pointer;
}

.case-hero-mockup img,
.case-hero-mockup video {
  width: 100%;
  display: block;
  filter: drop-shadow(0 30px 50px rgba(0, 0, 0, 0.35));
}

/* Screen content laid on top of a transparent-background laptop frame
   (see zefr.html) instead of baked into one video — MP4/H.264 has no
   alpha channel, so baking the frame in would mean either cropping the
   UI to match the screen's aspect ratio or flattening the frame's
   transparency to solid black. Positioned by percentage over the
   frame image's own screen cutout; object-fit:contain keeps the
   recording's full, uncropped width and letterboxes above/below with
   the frame's own black screen fill showing through — no padding math
   needed in the video itself. */
.case-hero-mockup-screen {
  position: absolute;
  left: 16.53%;
  top: 14.3%;
  width: 67.21%;
  height: 71.85%;
  object-fit: contain;
  border-radius: 10px;
}

/* Solid black circle + white arrow, matching the site's other black-
   turns-red hover treatment (nav links, logo, LinkedIn icon) rather
   than the glass treatment used elsewhere — reused everywhere a play
   button appears (hero mockups and .case-video-play-btn alike). */
.case-hero-play {
  position: absolute;
  top: 42%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background: #000;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
  transition: transform 0.25s var(--ease-out), background-color 0.2s var(--ease-out);
}

.case-hero-play::after {
  content: "";
  width: 0;
  height: 0;
  margin-left: 4px;
  border-style: solid;
  border-width: 11px 0 11px 18px;
  border-color: transparent transparent transparent #fff;
}

.case-hero-mockup:hover .case-hero-play,
.case-hero-mockup:focus-visible .case-hero-play,
.case-video-play-btn:hover .case-hero-play,
.case-video-play-btn:focus-visible .case-hero-play {
  transform: translate(-50%, -50%) scale(1.08);
  background: var(--color-accent);
}

/* Frosted glass, same family as .glass but a more opaque/whiter tint so
   the text stays readable over a busy photo — not the flat, unblurred
   translucency it had before (backdrop-filter is what actually makes it
   read as "frosted" rather than just a plain low-opacity fill).
   --case-card-bg is overridden per project alongside --glass-bg and the
   gradient vars (see each page's <body style>).

   Positioned absolutely off .case-hero-panel (bottom-right) rather than
   sitting in the flex row with the mockup — that's what lets it run
   wider (right up to, or slightly over, the mockup's own edge) and grow
   upward with longer copy (Solex, HireVue) without ever pushing the
   shelf/mockup/dots down. z-index above the mockup so an overlap reads
   as the card sitting on top of the device image, not under it. */
.case-hero-text {
  position: absolute;
  z-index: 2;
  right: clamp(24px, 4vw, 60px);
  bottom: 40px;
  /* Ceiling raised from 612px to 720px, same rate/reference as the
     photo/shelf/wrap ceilings above. */
  width: clamp(396px, 36vw, 720px);
  background: var(--case-card-bg, rgba(255, 250, 248, 0.75));
  backdrop-filter: blur(24px) saturate(160%);
  -webkit-backdrop-filter: blur(24px) saturate(160%);
  border: 1px solid rgba(255, 255, 255, 0.5);
  border-radius: var(--radius-lg);
  box-shadow: 0 30px 60px rgba(0, 0, 0, 0.2);
  padding: clamp(24px, 3vw, 40px);
  display: flex;
  flex-direction: column;
  /* Keeps this card's blur on its own stable GPU layer — without it,
     Chrome can drop/reapply the backdrop-filter (a visible flicker) on
     pages with an autoplaying <video> nearby (Zefr's hero mockup),
     since a hover/scroll repaint elsewhere was forcing the blur layer
     to recompute against the video's constantly-updating one. */
  will-change: backdrop-filter, transform;
  transform: translateZ(0);
}

.case-hero-card-eyebrow {
  display: flex;
  align-items: center;
  gap: 6px;
  font-weight: 400;
  font-size: 18px;
  color: var(--color-accent);
  margin: 0 0 10px;
}

/* CSS-drawn triangle rather than a Unicode glyph — the down-pointing
   triangle character renders as a generic dot in Lexend. */
.case-hero-card-caret {
  display: inline-block;
  width: 0;
  height: 0;
  margin-top: 3px;
  border-style: solid;
  border-width: 6px 4px 0 4px;
  border-color: var(--color-accent) transparent transparent transparent;
}

.case-hero-card-title {
  font-weight: 300;
  font-size: clamp(32px, 4vw, 50px);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--color-text);
  margin: 0 0 6px;
}

.case-hero-card-date {
  font-weight: 300;
  font-size: clamp(20px, 2.4vw, 32px);
  color: var(--color-text);
  margin: 0 0 14px;
}

.case-hero-card-divider {
  width: 24px;
  height: 3px;
  background: var(--color-accent);
  border: 0;
  margin: 0 0 16px;
}

.case-hero-card-subhead {
  font-weight: 500;
  font-size: 18px;
  line-height: 1.35;
  color: var(--color-text);
  margin: 0 0 14px;
}

.case-hero-card-desc {
  font-weight: 300;
  font-size: 14px;
  line-height: 1.7;
  color: var(--color-text-body);
  margin: 0;
}

/* Prev/next project — lives outside the card and the hero entirely (see
   the markup right before </body> on each case-study page) so it's
   fixed to the viewport and usable at any scroll position, not just
   near the hero. Right edge, anchored a fixed gap below the hero
   photo's own bottom edge (same clamp() as .case-hero-photo's height)
   rather than centered on the viewport — that kept drifting up over
   the photo itself on shorter viewports. Landing on the shelf/card
   area below the photo is fine; the photo itself is what it needs to
   clear, and this formula holds at every breakpoint since the photo's
   height doesn't change with the hero's side-by-side/stacked layout. */
.case-hero-sticky-nav {
  position: fixed;
  z-index: 40;
  top: calc(clamp(360px, 38vw, 760px) + 64px);
  right: clamp(20px, 3vw, 48px);
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 10px;
}

/* Tablet and mobile: the side-by-side hero's photo height formula
   above doesn't leave a good landing spot once the layout stacks, so
   these go back to a simple bottom-right dock instead. */
@media (max-width: 1400px) {
  .case-hero-sticky-nav {
    top: auto;
    bottom: 10%;
  }
}

.case-hero-arrow {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: #000;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  line-height: 1;
  transition: background-color 0.2s var(--ease-out), transform 0.2s var(--ease-out);
}

/* Once you've cycled through every project in a category, the arrow
   that would otherwise just loop back to that category's own first
   project instead grows into a pill naming the NEXT category — the
   other arrow (still with more projects to go) stays the plain circle.
   At rest it stays a plain 44px circle identical to a normal arrow
   (label hidden); only on hover/focus does it widen into a pill and
   reveal the category name, keeping the collapsed state visually
   clean. Combined selector (not a bare .case-hero-arrow-category rule)
   so this reliably beats .case-hero-arrow's width/border-radius/
   justify-content regardless of source order. Width (not max-width)
   is what transitions, since animating to/from a "min()" value doesn't
   read as smoothly as a flat target the way a genuine circle-to-pill
   move should. */
.case-hero-arrow.case-hero-arrow-category {
  width: 44px;
  padding: 0;
  border-radius: 50%;
  justify-content: center;
  gap: 0;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  white-space: nowrap;
  overflow: hidden;
  transition: width 0.25s var(--ease-out), padding 0.25s var(--ease-out),
    border-radius 0.25s var(--ease-out), background-color 0.2s var(--ease-out),
    transform 0.2s var(--ease-out);
}

.case-hero-arrow.case-hero-arrow-category:hover,
.case-hero-arrow.case-hero-arrow-category:focus-visible {
  width: min(272px, calc(100vw - 40px));
  padding: 0 20px;
  border-radius: 22px;
  justify-content: flex-start;
  gap: 8px;
}

.case-hero-arrow-category-label {
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 0;
  opacity: 0;
  transition: max-width 0.25s var(--ease-out), opacity 0.15s var(--ease-out);
}

.case-hero-arrow.case-hero-arrow-category:hover .case-hero-arrow-category-label,
.case-hero-arrow.case-hero-arrow-category:focus-visible .case-hero-arrow-category-label {
  max-width: 220px;
  opacity: 1;
}

.case-hero-arrow-category-chevron {
  font-size: 18px;
  line-height: 1;
  flex: 0 0 auto;
}

.case-hero-arrow:hover,
.case-hero-arrow:focus-visible {
  background: var(--color-accent);
  transform: scale(1.06);
}

/* Six-project pager below the shelf — jumps directly to any product
   case study, not just the adjacent one (that's what the arrows are for).
   Sits close to the shelf above (small top margin); .case-hero-outer's
   own bottom margin is what pushes the Metrics section further away
   below, so the dots read as belonging to the hero, not to Metrics. */
.case-hero-dots {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 8px;
  margin: 10px 0 0;
}

.case-hero-dot {
  display: block;
  width: 24px;
  height: 8px;
  border-radius: 999px;
  background: rgba(237, 62, 58, 0.4);
  transition: background-color 0.2s var(--ease-out), width 0.2s var(--ease-out);
}

a.case-hero-dot:hover {
  background: rgba(237, 62, 58, 0.7);
}

.case-hero-dot.is-current {
  width: 56px;
  background: var(--color-accent);
}

/* Raised well above a typical tablet width on purpose: the side-by-side
   layout's shelf/overlap math (.case-hero-wrap's margin-top vs. the
   shelf's own height, both viewport-relative) only balances out above
   ~1350px — narrower than that, the shelf and mockup fall short of the
   photo's bottom edge and leave an awkward gap above the device. It's
   safer to switch to the stacked layout early than to chase that math. */
@media (max-width: 1400px) {
  .case-hero-wrap { margin-top: -90px; }
  .case-hero-shelf { height: 90%; }
  .case-hero-panel { flex-direction: column; align-items: center; padding: 0 clamp(16px, 4vw, 32px); margin-bottom: 24px; }
  .case-hero-mockup { flex-basis: auto; width: 100%; margin-left: 0; }
  /* Back into normal flow here (full width, stacked below the mockup) —
     the absolute/overlapping positioning above is a desktop-only trick
     for the side-by-side layout. */
  .case-hero-text { position: static; flex-basis: auto; width: 100%; margin-top: 24px; margin-bottom: 40px; }
}

/* Fixed gradient behind the whole page (no squiggle line-art image —
   just the color, unlike the homepage's version), so the metric cards
   below can float directly on it instead of sitting in a glass panel.
   Colors default to Solex's; every other project overrides these two
   custom properties on its own <body> to get its own Figma-specified
   gradient (see the per-project <body style> attributes). */
.case-page-bg {
  position: fixed;
  inset: 0;
  z-index: 0;
  background-color: #fff;
}

.case-page-bg::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(var(--case-grad-angle, 10deg), var(--case-grad-start, #ee4c52) 0%, var(--case-grad-end, #ffa859) 100%);
  opacity: var(--case-grad-opacity, 0.55);
}

/* Metrics row: reuses .metric-card's exact glass styling from the
   homepage, just laid out 3-up in a plain static grid, floating directly
   on .case-page-bg (no enclosing glass panel) rather than in a
   .section-glass card like the other sections. */
.case-metrics {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: clamp(12px, 1.5vw, 20px);
  margin: 10px 0;
}

@media (max-width: 700px) {
  .case-metrics { grid-template-columns: 1fr; }
}

/* Hero entrance: the shelf, mockup, copy card, dots, and metrics rise up
   and fade in the moment a case-study page loads — a staggered wave
   (each ~70ms behind the last) rather than everything landing at once.
   Runs once automatically on load; no scroll trigger needed since these
   all sit above the fold already.

   .case-hero-shelf and .case-hero-text each already carry a static
   translateZ(0) (see their own comments) to pin them to their own GPU
   layer and avoid a backdrop-filter flicker next to an autoplaying
   mockup video — animating `transform` on those two has to end on that
   same translateZ(0), not just translateY(0), or the pinning is lost
   the moment the animation completes. */
@keyframes hero-rise-in {
  from { opacity: 0; transform: translateY(48px); }
  to   { opacity: 1; transform: translateY(0); }
}

@keyframes hero-rise-in-pinned {
  from { opacity: 0; transform: translateY(48px) translateZ(0); }
  to   { opacity: 1; transform: translateY(0) translateZ(0); }
}

.case-hero-shelf,
.case-hero-mockup,
.case-hero-text,
.case-hero-dots,
.case-metrics {
  animation-duration: 0.8s;
  animation-timing-function: var(--ease-out);
  animation-fill-mode: both;
}

.case-hero-shelf { animation-name: hero-rise-in-pinned; }
.case-hero-mockup { animation-name: hero-rise-in; animation-delay: 0.07s; }
.case-hero-text { animation-name: hero-rise-in-pinned; animation-delay: 0.14s; }
.case-hero-dots { animation-name: hero-rise-in; animation-delay: 0.21s; }
.case-metrics { animation-name: hero-rise-in; animation-delay: 0.28s; }

@media (prefers-reduced-motion: reduce) {
  .case-hero-shelf,
  .case-hero-mockup,
  .case-hero-text,
  .case-hero-dots,
  .case-metrics {
    animation: none;
  }
}

/* Generic content section: The Problem / The Approach / The Solution / Outcome */
/* The eyebrow is itself a <p>, so .case-section p below (same specificity
   tier, one extra element) would otherwise win and silently override this
   rule's color/alignment regardless of source order — hence the repeated
   class here to outrank it on specificity, not just declare later. */
.case-section-eyebrow.case-section-eyebrow {
  font-size: 14px;
  font-weight: 600;
  /* Was 0.03em — reduced 50% alongside .section-glass h2 above. */
  letter-spacing: 0.015em;
  text-align: center;
  color: var(--color-accent);
  max-width: none;
  margin: 0 auto 14px;
}

.case-section h2 {
  font-weight: 500;
  font-size: clamp(22px, 2.6vw, 32px);
  margin: 0 0 18px;
}

.case-section > p {
  font-weight: 300;
  font-size: 16px;
  line-height: 1.75;
  color: var(--color-text-body);
  max-width: 780px;
  margin: 0 auto 18px;
  text-align: center;
}

.case-section > p:last-child { margin-bottom: 0; }

/* Experiment: long, centered paragraphs get harder to read line-to-line
   the more they wrap — .js-long-copy is added by main.js once a
   paragraph renders past 3 lines, switching it to left-aligned while
   the block itself (width/centering) stays exactly as-is. */
.case-section > p.js-long-copy { text-align: left; }

.case-subsection {
  margin-top: clamp(28px, 3vw, 40px);
}

.case-subsection:first-of-type { margin-top: clamp(36px, 4vw, 52px); }

.case-subsection > h3 {
  font-weight: 500;
  font-size: 18px;
  margin: 0 0 6px;
  text-align: center;
}

.case-subsection > p {
  font-size: 15px;
  margin: 0 auto 16px;
  text-align: center;
}

/* Nested "card within a card" text treatment — a heading + paragraph
   (whether a top-level .case-section's own intro, or a .case-subsection's)
   gets its own centered white/glass card sitting inside the section's
   outer glass panel, matching the Figma MySudo redesign. Opt-in per page
   via this class rather than folding into .case-section/.case-subsection
   directly, so every other case-study page's existing plain-text layout
   is untouched. */
.case-subsection-card {
  background: var(--case-card-bg, rgba(255, 255, 255, 0.75));
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  border-radius: var(--radius-lg);
  padding: clamp(22px, 3vw, 32px) clamp(24px, 4vw, 44px);
  text-align: center;
}

.case-subsection-card h2,
.case-subsection-card h3 { margin: 0 0 12px; }

.case-subsection-card p {
  max-width: 700px;
  margin: 0 auto;
}

.case-subsection-card p + p { margin-top: 16px; }

/* Centered copy inside these cards reads fine on desktop's wider column,
   but per feedback it doesn't on mobile/tablet, where the same long
   paragraphs (and often-just-as-long headings, e.g. h2.is-body-size)
   wrap across many more, much shorter lines and get hard to track line-
   to-line. Unlike .case-section > p directly (see .js-long-copy above),
   nothing here already scales alignment to length, so this just always
   goes left below the tablet/desktop split, matching that breakpoint
   used everywhere else in this file. Overrides two different rules: the
   card's own inherited text-align (this rule) for its paragraphs, and
   .section-glass h2's explicit text-align (next rule) for its own
   heading — both are equal-specificity ties with this being later in
   source, not overridden via new specificity. */
@media (max-width: 800px) {
  .case-subsection-card { text-align: left; }
  .case-subsection-card h2,
  .case-subsection-card h3 { text-align: left; }
}

/* A short, punchy headline rather than a full sentence carried at
   headline weight/size — for spots where the source copy is a whole
   paragraph and reads as a wall of bold text if left at the default
   h2 size. */
.case-subsection-card h2.is-compact {
  font-size: clamp(20px, 2.2vw, 26px);
}

/* Matches the source Figma spec exactly for a couple of blocks where the
   "headline" is really just the opening sentence of one continuous
   18px/0-tracking paragraph, not a distinct display-size heading. */
.case-subsection-card h2.is-body-size {
  font-size: 18px;
  font-weight: 500;
  letter-spacing: 0;
  line-height: 1.6;
  margin-bottom: 16px;
}

/* Lets a card's paragraphs run the full width of the card (matching its
   own heading's width) instead of the shared, narrower 700px measure —
   for any text block that sits above its media rather than beside it,
   where the extra-narrow column was just making it wrap taller than it
   needed to. */
.case-subsection-card.is-wide p { max-width: none; }

/* A left column of two stacked images sitting on the same row as one
   taller image to its right — e.g. two supporting screenshots stacked
   beside one larger reference image, per Figma. */
.case-media-col-stack {
  display: flex;
  flex-direction: column;
  gap: clamp(14px, 2vw, 20px);
}

.case-media-col-stack img { border-radius: 20px; display: block; width: 100%; }

/* The Problem's own layout, per Figma: a photo alongside its text card
   rather than the plain full-width paragraph every other section uses.
   Text stays left-aligned here (unlike every other .case-subsection-card)
   since it's reading next to an image, not centered on its own. */
/* An explicit height (not just content-driven via align-items: stretch)
   — that was the plan originally, but in practice the image's own
   percentage height falls back to its intrinsic aspect ratio for row-
   sizing purposes, not to the card's shorter content, so the row was
   still coming out as tall as the photo's own natural (portrait,
   fairly elongated) crop regardless. An explicit height sidesteps that
   entirely: both the image and the card now stretch to match *this*,
   not each other. Halved from the ~944px that produced, per feedback
   that the whole section read as too tall relative to how little copy
   sits beside the photo. */
.case-problem-split {
  display: grid;
  grid-template-columns: minmax(240px, 45%) 1fr;
  gap: clamp(16px, 2.5vw, 28px);
  align-items: stretch;
  height: clamp(300px, 30vw, 470px);
}

/* object-fit: cover crops to whatever this ends up being (see
   .case-problem-split's own height above) rather than the row sizing
   to the photo's natural aspect ratio. Reverts to natural, uncropped
   sizing on mobile (see the 700px breakpoint below), where the two
   stack instead of sitting side by side and there's no shared row
   height to match. */
.case-problem-split img {
  width: 100%;
  height: 100%;
  /* Without this, the image's implicit auto-minimum size (a grid item's
     default) falls back to its own intrinsic aspect ratio rather than
     0 — overflowing past .case-problem-split's own explicit height
     above instead of actually cropping down to fit it, silently
     undoing that height cap entirely. */
  min-height: 0;
  object-fit: cover;
  border-radius: var(--radius-lg);
  display: block;
}

.case-problem-split .case-subsection-card {
  text-align: left;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 0;
}

/* .case-subsection-card's own h2 rule doesn't set text-align at all
   (relying on inheritance from its own centered base rule normally),
   but .section-glass h2 elsewhere matches first — a direct rule always
   wins over an inherited value regardless of specificity — leaving the
   heading centered despite the card around it correctly going left. */
.case-problem-split .case-subsection-card h2 { text-align: left; }

.case-problem-split .case-subsection-card p { margin: 0 0 12px; max-width: none; }
.case-problem-split .case-subsection-card p:last-child { margin-bottom: 0; }

@media (max-width: 700px) {
  .case-problem-split { grid-template-columns: 1fr; height: auto; }
  .case-problem-split img { height: auto; min-height: 220px; object-fit: initial; }
}

/* Text-and-image side by side inside a single shared white card (rather
   than two separate cards) — per Figma, the whole "Understanding the
   Audience" block is one white frame, text left, image right. */
.case-persona-split {
  display: grid;
  grid-template-columns: 1fr minmax(280px, 48%);
  gap: clamp(20px, 3vw, 36px);
  align-items: center;
  text-align: left;
}

.case-persona-split h3 { margin: 0 0 12px; }
.case-persona-split p { max-width: none; margin: 0 0 12px; }
.case-persona-split p:last-child { margin-bottom: 0; }

.case-persona-split img {
  width: 100%;
  height: auto;
  border-radius: var(--radius-lg);
  display: block;
}

@media (max-width: 800px) {
  .case-persona-split { grid-template-columns: 1fr; }
}

/* Equal-height media rows: a few spots in the MySudo redesign show
   several images/videos of very different native aspect ratios cropped
   to a shared row height (object-fit: cover) rather than each keeping
   its own natural size like .case-media-row normally does elsewhere.
   Opt in per row with an inline --row-h custom property. */
.case-media-row-crop { align-items: stretch; }

.case-media-row-crop img,
.case-media-row-crop video {
  height: var(--row-h, 320px);
  width: 100%;
  object-fit: cover;
}

/* Testimonial cards — real quotes pulled from actual feedback, rebuilt
   in the site's own card language rather than embedding the original
   screenshots. Two-up on desktop, stacked on mobile. */
.testimonial-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: clamp(16px, 2.5vw, 24px);
}

/* HireVue and MySudo specifically — exactly 3 testimonials, so one even
   row reads better than the default 2-up (2 then a lone third). Same
   specificity as .testimonial-grid itself (two classes vs one), so the
   two responsive overrides below need to match that compound selector
   too, rather than losing to it at every width. */
.testimonial-grid.testimonial-grid-3col {
  grid-template-columns: repeat(3, 1fr);
}

@media (max-width: 1100px) {
  .testimonial-grid.testimonial-grid-3col {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* MySudo's own Brittany Keller card: spans the full width of the row
   edge-to-edge (true grid-column span) since she's alone in the last
   row there. The homepage doesn't need an equivalent — see
   .testimonial-carousel below, where she's simply alone in her own
   slide instead. */
.testimonial-card-full {
  grid-column: 1 / -1;
}

/* Homepage REVIEWS carousel: replaces a dense 13-card grid with 2 cards
   per slide (7 slides — Brittany Keller's long one, last, alone in a
   single-column slide) and a dot-per-slide indicator, so what's on
   screen at once never exceeds a pair of cards. A literal dot-per-
   testimonial indicator (13 dots) would have been busier than the grid
   it replaced; a dot per *slide* (7) isn't.

   Crossfade, not a sliding track — an earlier version moved a flex row
   via transform: translateX() with overflow: hidden clipping the rest,
   the usual way to build this. In practice, whichever slide had just
   been translated out of view kept rendering anyway, spilling past the
   carousel's own edge instead of being clipped — a Chromium compositing
   quirk around overflow: hidden not reliably clipping a translated,
   backdrop-filter'd child (the testimonial cards all use backdrop-
   filter for their glass look). Stacking every slide with
   position: absolute and crossfading opacity sidesteps the clipping
   question entirely: a hidden slide is opacity: 0 regardless of where
   it sits, clipped or not. */
.testimonial-carousel {
  position: relative;
}

/* Fixed height (JS sets it once, to the tallest of the six normal 2-card
   slides — see main.js) rather than resizing per slide. Besides looking
   steadier, this keeps the prev/next arrows — vertically centered
   against this track — from shifting position every time the content
   height changed, which was causing mis-clicks between them. */
.testimonial-track {
  position: relative;
}

.testimonial-slide {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: clamp(16px, 2.5vw, 24px);
  align-items: stretch;
  /* Right padding reserves room for the stacked arrow buttons (see
     .testimonial-arrow below) so card text never runs up against them. */
  padding: 4px clamp(60px, 8vw, 80px) 4px 4px;
  position: absolute;
  inset: 0;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out), visibility 0s linear 0.35s;
}

.testimonial-slide.is-single {
  grid-template-columns: 1fr;
}

/* The active slide switches to normal flow (position: relative) and
   fills the track's own fixed height, so its two cards (align-items:
   stretch above) both stretch to match that height exactly — not just
   to each other, to the same height every other slide uses too. */
.testimonial-slide.is-active {
  display: grid;
  position: relative;
  height: 100%;
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out);
}

/* Same visual language as .case-hero-arrow (the case-study prev/next
   dock), just positioned inline against this carousel instead of
   fixed to the viewport. */
.testimonial-arrow {
  appearance: none;
  border: none;
  padding: 0;
  cursor: pointer;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: #000;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  line-height: 1;
  flex-shrink: 0;
  transition: background-color 0.2s var(--ease-out);
}

.testimonial-arrow:hover,
.testimonial-arrow:focus-visible {
  background: var(--color-accent);
}

/* Both arrows stacked on the right side rather than flanking opposite
   edges — next (›) on top, prev (‹) directly below it. */
.testimonial-arrow.testimonial-arrow-prev,
.testimonial-arrow.testimonial-arrow-next {
  position: absolute;
  right: -14px;
  z-index: 2;
}

.testimonial-arrow-next { top: calc(50% - 26px); }
.testimonial-arrow-prev { top: calc(50% + 26px); }

.testimonial-dots {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 8px;
  margin: 20px 0 0;
}

.testimonial-dot {
  appearance: none;
  border: none;
  padding: 0;
  cursor: pointer;
  display: block;
  width: 24px;
  height: 8px;
  border-radius: 999px;
  background: rgba(237, 62, 58, 0.4);
  transition: background-color 0.2s var(--ease-out), width 0.2s var(--ease-out);
}

.testimonial-dot:hover {
  background: rgba(237, 62, 58, 0.7);
}

.testimonial-dot.is-current {
  width: 56px;
  background: var(--color-accent);
}

@media (max-width: 700px) {
  /* Below 700px, an individual card pages one at a time instead of a
     2-up pair — two cards stacked in one "slide" meant scrolling past
     both before ever reaching the arrows/dots below the track (see
     js/main.js, which switches to a flattened per-card list at this
     breakpoint instead of the per-slide one desktop uses). The slide
     itself stops being the toggled unit and is forced permanently
     visible here — its only remaining job is grid-sizing the single
     card inside it during the JS height measurement. */
  .testimonial-slide {
    grid-template-columns: 1fr;
    padding: 4px;
    opacity: 1 !important;
    visibility: visible !important;
    pointer-events: none !important;
  }

  .testimonial-card {
    position: absolute;
    inset: 0;
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out), visibility 0s linear 0.35s;
  }

  .testimonial-card.is-active {
    position: relative;
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
    transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out);
  }

  /* The card now spans the full track width (no reserved right-hand
     column for arrows, since there's only ever one card in view to
     reserve space for) — the arrows sit slightly over its corner
     instead, landing in the card's own bottom padding rather than its
     text. Anchored from the bottom rather than nudged down from
     center — each card is sized to its own content on mobile (see
     main.js), so a percentage-based center offset landed fine on a
     normal-length quote but drifted mid-paragraph on Brittany Keller's
     much longer one. Bottom-anchoring stays clear of the text on every
     card regardless of length, the same way it already did by
     coincidence for shorter ones: the date line sits bottom-left while
     these sit bottom-right, so they don't collide even this close. */
  .testimonial-arrow-prev,
  .testimonial-arrow-next {
    right: -6px;
    top: auto;
  }

  .testimonial-arrow-prev { bottom: 6px; }
  .testimonial-arrow-next { bottom: 58px; }

  .testimonial-dots {
    flex-wrap: wrap;
    row-gap: 8px;
    gap: 6px;
  }

  .testimonial-dot {
    width: 8px;
  }

  .testimonial-dot.is-current {
    width: 24px;
  }
}

.testimonial-card {
  background: var(--case-card-bg, rgba(255, 255, 255, 0.75));
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  border-radius: var(--radius-lg);
  padding: clamp(20px, 2.5vw, 28px);
  /* Only matters on the homepage carousel, where every slide (including
     Brittany Keller's, by far the longest) is locked to the same fixed
     shelf height — content that doesn't fit scrolls internally instead
     of overflowing or getting clipped. A no-op everywhere else, since
     nothing constrains a case-study testimonial card's height. */
  overflow-y: auto;
}

.testimonial-head {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 6px;
}

.testimonial-avatar {
  width: 54px;
  height: 54px;
  border-radius: 50%;
  flex-shrink: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--color-accent);
  color: #fff;
  font-weight: 600;
  font-size: 14px;
}

.testimonial-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.testimonial-name {
  font-weight: 700;
  font-size: 19px;
  margin: 0;
}

.testimonial-title {
  font-weight: 300;
  font-size: 13px;
  color: var(--color-text-body);
  margin: -15px 0 14px;
  margin-left: 66px;
}

.testimonial-quote {
  font-weight: 300;
  font-size: 14px;
  line-height: 1.65;
  color: var(--color-text-body);
  margin: 0 0 14px;
}

.testimonial-date {
  font-size: 12px;
  color: var(--color-text-body);
  opacity: 0.7;
  margin: 0;
}

/* A short caption sitting directly under a photo/screenshot, giving it
   context the alt text alone wouldn't surface to a sighted reader. */
.case-media-caption {
  font-size: 13px;
  font-style: italic;
  color: var(--color-text-body);
  opacity: 0.75;
  text-align: center;
  margin: 10px 0 0;
}

/* A small logo badge overlapping the corner of a screenshot — e.g. the
   Claude logo over the SKILL.md capture, crediting the tool without a
   caption. */
.case-media-badge-wrap {
  position: relative;
  display: block;
  width: 100%;
}

.case-media-badge {
  position: absolute;
  top: -22px;
  left: -22px;
  width: clamp(56px, 8vw, 84px);
  height: clamp(56px, 8vw, 84px);
  border-radius: 22%;
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
}

@media (max-width: 700px) {
  .testimonial-grid,
  .testimonial-grid.testimonial-grid-3col { grid-template-columns: 1fr; }
  /* The 150% overflow trick above needs the empty second column to
     spill into — there isn't one here (single column already), so it
     would just overflow the viewport instead. */
  .testimonial-card-wide { width: 100%; }
}

/* Persona cards — a research deck's persona slide. Each avatar is a
   stock photo loosely matched to the persona's age/vibe, ringed in a
   project color via the two --persona-a/-b variables set alongside a
   page's other case-* custom properties. */
.persona-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: clamp(16px, 2.5vw, 28px);
  margin-top: 16px;
}

.persona-card {
  background: var(--case-card-bg, rgba(255, 255, 255, 0.75));
  border: 1px solid rgba(255, 255, 255, 0.5);
  border-radius: var(--radius-lg);
  padding: clamp(20px, 2.5vw, 28px);
  text-align: center;
}

.persona-avatar {
  width: 88px;
  height: 88px;
  margin: 0 auto 16px;
  border-radius: 50%;
  overflow: hidden;
  display: block;
}

.persona-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.persona-avatar-a { box-shadow: 0 0 0 3px var(--persona-a, #4B5D6A); }
.persona-avatar-b { box-shadow: 0 0 0 3px var(--persona-b, #C8102E); }

.persona-name {
  font-weight: 500;
  font-size: 17px;
  margin: 0 0 6px;
}

.persona-goal {
  font-weight: 300;
  font-style: italic;
  font-size: 14px;
  color: var(--color-text-body);
  margin: 0 0 16px;
}

.persona-bullets {
  text-align: left;
  font-size: 13px;
  font-weight: 300;
  line-height: 1.6;
  color: var(--color-text-body);
  margin: 0;
  padding-left: 18px;
}

.persona-bullets li { margin-bottom: 8px; }

@media (max-width: 700px) {
  .persona-grid { grid-template-columns: 1fr; }
}

/* "View More Work" footer band — a case-study-page-only reuse of the
   homepage's .work-card component. Runs edge-to-edge of the viewport
   (a plain sibling of .case-page, not nested inside its max-width), a
   deliberate contrast with every other section on the page, which sits
   in an inset, rounded glass card. Unlike the homepage, there's no
   metrics-bar/photo-preview to swap on hover here, so each link's own
   :hover (turn red, reveal the "View Study" button — see .project-link
   and .work-card-detail li a) is the whole interaction; the category
   matching the current page is rendered permanently open via .is-open
   rather than waiting for a hover/tap. */
.view-more-work-band {
  position: relative;
  z-index: 1;
  margin-top: clamp(28px, 4vw, 48px);
  padding: clamp(40px, 6vw, 64px) var(--gutter) clamp(72px, 9vw, 110px);
  background: rgba(248, 252, 255, 0.6);
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  border-top: 2px solid rgba(140, 165, 182, 0.5);
}

.view-more-work-title {
  text-align: center;
  color: var(--color-accent);
  font-weight: 500;
  font-size: clamp(20px, 2.2vw, 26px);
  margin: 0 0 clamp(20px, 3vw, 32px);
}

.view-more-grid {
  /* Matches .case-page's own widened cap above, so the WORK band lines
     up with the rest of the page instead of reading narrower. */
  max-width: 1600px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: clamp(12px, 1.5vw, 20px);
}

/* A "you are here" indicator for the current page's own row, inside
   whichever category list it belongs to (Product or Marketing) — a real
   link like every other row (so it stays consistent/navigable), just
   permanently styled the way every other row only looks on hover. */
.work-card-detail li a.is-current-page,
.work-card-detail li a.is-current-page strong {
  color: var(--color-accent);
}

@media (max-width: 900px) {
  .view-more-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 600px) {
  .view-more-grid { grid-template-columns: 1fr; }
}

.case-placeholder-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: clamp(14px, 2vw, 20px);
  margin-top: 16px;
}

/* Fixed column counts (not auto-fit) for spots that need a guaranteed
   row split regardless of viewport width — e.g. Grow's Key Screens
   section keeps its 2 original screenshots on one row and the 3 new
   product clips on their own row underneath, instead of however many
   fit per row at a given width. */
.case-placeholder-grid-2up { grid-template-columns: repeat(2, 1fr); }
.case-placeholder-grid-3up { grid-template-columns: repeat(3, 1fr); }

@media (max-width: 700px) {
  .case-placeholder-grid-2up,
  .case-placeholder-grid-3up { grid-template-columns: 1fr; }
}

.case-placeholder-block {
  aspect-ratio: 4 / 3;
  border: 1.5px dashed rgba(0, 0, 0, 0.25);
  border-radius: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 20px;
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.02em;
  color: rgba(0, 0, 0, 0.45);
  background: rgba(255, 255, 255, 0.35);
}

/* Real photos dropped straight into .case-placeholder-grid (e.g. Grow's
   actual screenshots) instead of a dashed "add image" placeholder. */
.case-placeholder-grid img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 20px;
  display: block;
}

/* Behance/video embeds inside a case section — 16:9, matching the
   rounded-card language used everywhere else on these pages. */
.case-video-embed {
  margin-top: 16px;
  border-radius: 20px;
  overflow: hidden;
  aspect-ratio: 16 / 9;
}

.case-video-embed iframe,
.case-video-embed video {
  width: 100%;
  height: 100%;
  border: 0;
  object-fit: cover;
  display: block;
}

/* Video/gif clips dropped into .case-placeholder-grid alongside real
   photos (e.g. Grow's product-demo clips) — same treatment as
   .case-placeholder-grid img so they sit in the grid consistently. */
.case-placeholder-grid video {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 20px;
  display: block;
}

/* A column of rows, each holding one or two media items at their own
   native pixel size — unlike .case-placeholder-grid above, nothing here
   forces a 4:3 crop or stretches an item to fill its column.
   max-width:100% only ever scales an item DOWN to fit a narrower row;
   it never scales one up past its native size. Use .case-media-row on
   its own for a full-width single item, or add -2up to pair two. */
.case-media-stack {
  display: flex;
  flex-direction: column;
  gap: clamp(14px, 2vw, 20px);
  margin-top: 16px;
}

.case-media-row {
  display: grid;
  grid-template-columns: 1fr;
  justify-items: center;
  gap: clamp(14px, 2vw, 20px);
}

.case-media-row-2up {
  grid-template-columns: repeat(2, 1fr);
}

.case-media-row-3up {
  grid-template-columns: repeat(3, 1fr);
}

.case-media-row video,
.case-media-row img {
  max-width: 100%;
  height: auto;
  border-radius: 20px;
  display: block;
}

/* Signals it's clickable to restart once paused, since there's no play
   button overlay to convey that. */
.js-pause-at-time { cursor: pointer; }

/* Some browsers redraw a paused/ended <video> back to its first frame
   instead of holding the last one. To guarantee it stays frozen on the
   real final frame everywhere, the pause-at-time handler snapshots the
   frame onto this same-size canvas and swaps to showing that instead —
   see the .js-pause-at-time handler in main.js. */
.case-video-freeze-frame {
  position: relative;
  display: block;
  cursor: pointer;
}

.case-video-freeze-frame video,
.case-video-freeze-frame canvas {
  width: 100%;
  height: auto;
  border-radius: 20px;
  display: block;
}

.case-video-freeze-frame canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: none;
}

.case-video-freeze-frame.is-frozen video { visibility: hidden; }
.case-video-freeze-frame.is-frozen canvas { display: block; }

@media (max-width: 700px) {
  .case-media-row-2up,
  .case-media-row-3up { grid-template-columns: 1fr; }
}

/* Two media items matched to the SAME height (rather than the same
   width like .case-media-row-2up's equal grid columns) — a wider-
   aspect image then naturally reads larger, e.g. pairing a taller
   booth photo with a wide screen-and-signage photo and letting the
   screen read bigger without cropping either one. Combined selector
   (not a bare .case-media-row-match-height img) so this reliably
   beats .case-media-row img's height:auto regardless of source order. */
.case-media-row.case-media-row-match-height {
  display: flex;
  align-items: stretch;
  justify-content: center;
  flex-wrap: wrap;
}

.case-media-row.case-media-row-match-height img {
  height: clamp(240px, 32vw, 420px);
  width: auto;
  max-width: 100%;
}

@media (max-width: 700px) {
  .case-media-row.case-media-row-match-height img {
    height: auto;
    width: 100%;
  }
}

/* A larger "main" image with a smaller, secondary image pushed to the
   far right of the same row — e.g. the Chipotle registration screens
   next to the much smaller self-scheduling email screenshot. The main
   image shrinks (min-width:0 overrides the flex default of never
   shrinking below its intrinsic size) so both stay on one row instead
   of the second one wrapping underneath. */
.case-media-row-push-right {
  display: flex;
  align-items: flex-end;
  gap: clamp(14px, 2vw, 20px);
}

.case-media-row-push-right > img:first-child {
  flex: 1 1 auto;
  min-width: 0;
  width: auto;
}

.case-media-row-push-right > img:last-child {
  flex: 0 0 auto;
}

@media (max-width: 700px) {
  .case-media-row-push-right { flex-wrap: wrap; justify-content: center; }
  .case-media-row-push-right > img:first-child { flex: 1 1 100%; }
}

/* Click-to-play, sound-on local video (as opposed to the autoplay/muted
   background-style clips elsewhere on this page) — a poster + play
   button until clicked, then native controls take over. See the
   .js-local-video handler in main.js. */
.case-video-embed.js-local-video {
  position: relative;
}

.case-video-play-btn {
  position: absolute;
  inset: 0;
  width: 100%;
  border: 0;
  padding: 0;
  background: rgba(10, 12, 20, 0.25);
  cursor: pointer;
}

.case-video-play-btn .case-hero-play {
  top: 50%;
}

/* Shown once a .js-local-video's play button has been clicked (see
   main.js) — lets a visitor back out to the poster/play-button state
   without reloading the page. */
.case-video-close-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 2;
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  background: rgba(10, 12, 20, 0.6);
  color: #fff;
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.2s var(--ease-out);
}

.case-video-close-btn:hover,
.case-video-close-btn:focus-visible {
  background: rgba(10, 12, 20, 0.85);
}

/* Without this, the explicit display:flex above wins over the browser's
   own [hidden]{display:none} rule (author styles beat UA styles
   regardless of specificity) — the close button stays visible even
   right after a fresh page load, before the video's ever been played. */
.case-video-close-btn[hidden] { display: none; }

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

.case-outcome p {
  max-width: 620px;
  margin-left: auto;
  margin-right: auto;
}

.case-back-link {
  position: relative;
  z-index: 1;
  text-align: center;
  padding: clamp(20px, 3vw, 32px) var(--gutter) clamp(40px, 5vw, 60px);
}

.case-back-link a {
  font-weight: 500;
  color: var(--color-accent);
}

/* ---------- Video lightbox (shared by every case study with a video) ---------- */

.video-lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}

.video-lightbox[hidden] { display: none; }

.video-lightbox-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(10, 10, 10, 0.85);
}

.video-lightbox-panel {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 960px;
}

.video-lightbox-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
  border-radius: 12px;
  overflow: hidden;
}

.video-lightbox-frame iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

.video-lightbox-close {
  position: absolute;
  top: -44px;
  right: 0;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.4);
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
}

.video-lightbox-close:hover { background: rgba(255, 255, 255, 0.2); }

/* Click-to-expand image/gif lightbox — same dark-backdrop pattern as
   the video lightbox above, but full-screen (the content itself, not a
   fixed 16:9 frame) since a diagram's real detail is the point. */
.js-image-lightbox-trigger {
  cursor: zoom-in;
  border: 0;
  padding: 0;
  background: none;
  display: block;
  width: 100%;
}

.image-lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}

.image-lightbox[hidden] { display: none; }

.image-lightbox-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(10, 10, 10, 0.9);
}

.image-lightbox-panel {
  position: relative;
  z-index: 1;
  max-width: 96vw;
  max-height: 92vh;
}

.image-lightbox-panel img {
  display: block;
  max-width: 96vw;
  max-height: 92vh;
  width: auto;
  height: auto;
  border-radius: 12px;
}

.image-lightbox-close {
  position: absolute;
  top: -44px;
  right: 0;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.4);
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
}

.image-lightbox-close:hover { background: rgba(255, 255, 255, 0.2); }

/* Prev/next: same fixed bottom-right dock + .case-hero-arrow styling
   as a gallery page's own sticky nav, just anchored to the viewport
   corner instead of the hero height (the lightbox floats above
   everything at a fixed z-index regardless of scroll position). */
.image-lightbox-nav {
  position: fixed;
  z-index: 101;
  right: clamp(20px, 3vw, 48px);
  bottom: clamp(20px, 4vw, 64px);
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.image-lightbox-counter {
  position: fixed;
  z-index: 101;
  left: clamp(20px, 3vw, 48px);
  top: clamp(20px, 3vw, 48px);
  padding: 6px 14px;
  border-radius: var(--radius-pill);
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.02em;
}

/* ---------- Placeholder case-study pages ---------- */

.placeholder-wrap {
  max-width: 720px;
  margin: 0 auto;
  padding: clamp(140px, 18vw, 220px) var(--gutter) 120px;
  text-align: center;
}

.placeholder-wrap .placeholder-eyebrow {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-accent);
  margin: 0 0 14px;
}

.placeholder-wrap h1 {
  font-weight: 300;
  font-size: clamp(28px, 4vw, 44px);
  margin: 0 0 20px;
}

.placeholder-wrap p {
  font-weight: 300;
  font-size: 17px;
  line-height: 1.7;
  color: var(--color-text-body);
  margin: 0 0 36px;
}

.placeholder-wrap .placeholder-back {
  display: inline-block;
  font-weight: 500;
  color: var(--color-accent);
}

/* ---------- Photography galleries ----------
   A full-bleed cover photo with the gallery title over it (reusing the
   same photo-band + scrim as a case study's hero, just taller and with
   a title instead of a mockup/text-card shelf below), followed by a
   Pinterest-style masonry grid — no case-study scaffolding (no device
   mockup, no metrics, no cards) since this is just a photo set. */
.gallery-hero {
  /* Matches .case-hero-photo's own height exactly (rather than its own
     taller clamp) so the sticky-nav arrows land at the same fixed
     screen position on every case-study/gallery page — Photography and
     Illustration & Games galleries used to run taller here, which made
     the arrows visibly jump vertically when the new cross-category
     pill carried you from one category into the next. */
  height: clamp(360px, 38vw, 760px);
  display: flex;
  align-items: center;
  justify-content: center;
}

.gallery-hero-title {
  position: relative;
  z-index: 2;
  color: #fff;
  font-weight: 300;
  font-size: clamp(32px, 6vw, 64px);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-align: center;
  text-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
  margin: 0;
  padding: 0 var(--gutter);
}

.gallery-page {
  /* Without this, the grid is unpositioned (static/z-index:auto), which
     paints *behind* .case-page-bg despite coming later in the DOM —
     a positioned element (even at z-index:0, like .case-page-bg) always
     paints above normal-flow content in the same stacking context,
     regardless of source order. Every other section on this page
     (.case-hero-photo, .view-more-work-band, .site-footer) already sets
     its own position+z-index for this exact reason; this was the one
     piece that didn't, so it was the one that vanished. */
  position: relative;
  z-index: 1;
  max-width: var(--content-max);
  margin: 0 auto;
  padding: clamp(28px, 4vw, 48px) var(--gutter) clamp(60px, 8vw, 100px);
}

/* Keeps each project's images visually together as its own labeled
   block/masonry-run, rather than one continuous grid mixing projects. */
.gallery-project-block { margin-bottom: clamp(48px, 6vw, 80px); }
.gallery-project-block:last-child { margin-bottom: 0; }

.gallery-project-heading {
  text-align: center;
  font-weight: 300;
  font-size: clamp(22px, 2.6vw, 30px);
  letter-spacing: 0.04em;
  margin: 0 0 clamp(20px, 3vw, 32px);
  color: var(--color-text);
}

.gallery-grid {
  columns: 3;
  column-gap: clamp(14px, 2vw, 22px);
}

.gallery-item {
  break-inside: avoid;
  margin-bottom: clamp(14px, 2vw, 22px);
}

.gallery-item img {
  width: 100%;
  display: block;
  border-radius: 14px;
  transition: transform 0.4s var(--ease-out);
}

.gallery-item .js-image-lightbox-trigger:hover img { transform: scale(1.02); }

@media (max-width: 900px) {
  .gallery-grid { columns: 2; }
}

@media (max-width: 560px) {
  .gallery-grid { columns: 1; }
}

/* .gallery-sticky-nav no longer needs its own top: override here —
   now that .gallery-hero's height matches .case-hero-photo's exactly,
   the base .case-hero-sticky-nav rule already lands in the right spot
   on gallery pages too. Class kept on the markup in case a future
   gallery-specific tweak needs a hook again. */

/* ---------- iOS Games gallery: album covers + the richer game lightbox ----------
   A gallery-item can be a plain image/video trigger (see .js-game-trigger
   below) or a "chapter cover" standing in for a whole set of images —
   .gallery-album-badge is the small pill on that cover signaling there's
   a set behind it. Clicking any trigger opens .game-lightbox: unlike the
   plain .image-lightbox used elsewhere, this one also carries a project
   title + description, and can page through a whole array of images
   (or show a single embedded video iframe) — see the .js-game-trigger
   handler in main.js. */
.js-game-trigger {
  position: relative;
  display: block;
  width: 100%;
  border: 0;
  padding: 0;
  background: none;
  cursor: zoom-in;
}

.gallery-album-badge {
  position: absolute;
  right: 12px;
  bottom: 12px;
  padding: 5px 12px;
  border-radius: var(--radius-pill);
  background: rgba(10, 12, 20, 0.65);
  color: #fff;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.03em;
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

.gallery-video-badge {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 12, 20, 0.25);
  /* Matches .gallery-item img's own radius — without this, the badge
     (a sibling of the img, not clipped by its rounding) shows its own
     square corners sitting on top of the image's rounded ones. More
     visible on a bright poster (like Hess Chopper's) since the
     mismatched sharp corner reads clearly against the light image. */
  border-radius: 14px;
}

.gallery-video-badge span {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.9);
  display: flex;
  align-items: center;
  justify-content: center;
}

.gallery-video-badge span::before {
  content: "";
  border-style: solid;
  border-width: 10px 0 10px 16px;
  border-color: transparent transparent transparent #000;
  margin-left: 4px;
}

/* ---------- Gallery intro blurb + "the website" showcase ----------
   Used on gallery pages that need a short paragraph of context under
   the hero title (e.g. Company Portraits - Endigit) before the photo
   grid — most galleries skip this and go straight from hero to grid. */
.gallery-blurb {
  max-width: 680px;
  margin: 0 auto clamp(32px, 5vw, 56px);
  font-weight: 300;
  font-size: 17px;
  line-height: 1.7;
  color: var(--color-text-body);
  text-align: center;
}

.gallery-grid--single {
  columns: 1;
}

.gallery-grid--single .gallery-item img {
  border-radius: var(--radius-lg);
}

/* Lets one masonry item break out of its column and span the full
   width of a .gallery-grid (e.g. a wide panoramic drawing that would
   otherwise render as a tall sliver in a single column). */
.gallery-item--span-all {
  column-span: all;
}

/* A single embedded video sitting below a gallery-grid within its own
   .gallery-project-block (e.g. a process timelapse after a painting's
   photos) — same aspect-ratio-wrapper technique as .game-lightbox-iframe-wrap. */
.gallery-video-embed {
  position: relative;
  width: 100%;
  max-width: 800px;
  margin: clamp(20px, 3vw, 32px) auto 0;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-lg);
  overflow: hidden;
}

.gallery-video-embed iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

/* ---------- Portrait reveal grid (Endigit) ----------
   Each portrait sits in its default/grey state; hovering (mouse) or
   tapping (touch) crossfades to the "blue" state, same opacity-swap
   technique as .nav-logo-default/.nav-logo-hover. Hovering off (or a
   second tap on touch, via .is-revealed in main.js) reverts it. */
.portrait-item {
  position: relative;
  border-radius: 14px;
  overflow: hidden;
  cursor: pointer;
}

.portrait-item img {
  display: block;
  width: 100%;
}

.portrait-photo-blue {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.3s var(--ease-out);
}

.portrait-item:hover .portrait-photo-blue,
.portrait-item.is-revealed .portrait-photo-blue {
  opacity: 1;
}

.game-lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}

.game-lightbox[hidden] { display: none; }

.game-lightbox-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(10, 10, 10, 0.9);
}

.game-lightbox-panel {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 1140px;
  max-height: 92vh;
  display: flex;
  flex-direction: column;
  background: #111;
  border-radius: 16px;
  overflow: hidden;
}

.game-lightbox-close {
  position: absolute;
  top: 14px;
  right: 14px;
  z-index: 2;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.4);
  background: rgba(0, 0, 0, 0.4);
  color: #fff;
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
}

.game-lightbox-close:hover { background: rgba(255, 255, 255, 0.2); }

.game-lightbox-media {
  position: relative;
  background: #000;
  display: flex;
  /* Bottom-anchored (flush with the divider below) rather than
     centered — a device-mockup image with a lot of built-in empty
     padding around it was otherwise landing close enough to the top
     to crowd the close button on one side while leaving a gap above
     the divider on the other, depending on its own aspect ratio.
     padding-top guarantees clearance from the close button regardless
     of how tall any given image renders. */
  align-items: flex-end;
  justify-content: center;
  min-height: 240px;
  max-height: 70vh;
  padding-top: 60px;
}

.game-lightbox-media img {
  display: block;
  width: 100%;
  max-height: 60vh;
  object-fit: contain;
}

/* .game-lightbox-media img and .game-lightbox-video both set their own
   display:block (a class-selector rule), which is the same specificity
   as the browser's own [hidden]{display:none} default — so whichever is
   declared later in the stylesheet wins, regardless of which element
   actually has the hidden attribute set. Toggling .hidden in JS was
   silently doing nothing without this explicit, higher-specificity
   override forcing [hidden] to always win for these two elements. */
.game-lightbox-media img[hidden],
.game-lightbox-video[hidden] {
  display: none;
}

.game-lightbox-iframe-wrap {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
}

.game-lightbox-iframe-wrap iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

.game-lightbox-video {
  display: block;
  width: 100%;
  height: 100%;
  max-height: 66vh;
}

.game-lightbox-counter {
  position: absolute;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  padding: 4px 12px;
  border-radius: var(--radius-pill);
  background: rgba(0, 0, 0, 0.5);
  color: #fff;
  font-size: 12px;
  font-weight: 500;
}

.game-lightbox-info {
  padding: clamp(18px, 3vw, 28px) clamp(20px, 4vw, 32px);
  background: #fff;
  overflow-y: auto;
  /* A pale/white image (object-fit:contain can still fill the media box
     edge-to-edge with no letterboxing, depending on its aspect ratio)
     can otherwise blend straight into this panel's white background
     with no visible seam, reading as if the image spilled into the
     text — this border guarantees a visible line regardless of what
     color the image's own bottom edge happens to be. */
  border-top: 3px solid #000;
}

.game-lightbox-title {
  font-weight: 500;
  font-size: clamp(18px, 2vw, 22px);
  margin: 0 0 10px;
  color: var(--color-text);
}

.game-lightbox-desc {
  font-weight: 300;
  font-size: 15px;
  line-height: 1.65;
  color: var(--color-text-body);
  margin: 0;
  white-space: pre-line;
}

@media (max-width: 700px) {
  .game-lightbox-panel { max-height: 96vh; }
  .game-lightbox-media { max-height: 42vh; }
  .game-lightbox-media img { max-height: 42vh; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .work-card-glass,
  .work-card-pill,
  .work-card-detail,
  .work-card > img {
    transition: none;
  }
}
