/* ===================================================================
   OMAR MOOTAMRI — PORTFOLIO
   Faithfully cloned from omarmota.com
   Design tokens extracted directly from Framer source.
   =================================================================== */

/* ── LENIS (vendor, inlined) ───────────────────────────────────────
   Previously loaded from https://unpkg.com/lenis@1.3.17/dist/lenis.css —
   inlined here instead so the page doesn't pay for a whole extra
   DNS/TLS/HTTP round trip to a third origin for 381 bytes of render-
   blocking CSS. Keep in sync with the Lenis version pinned in
   includes/footer.php if that's ever bumped. */
html.lenis, html.lenis body { height: auto; }
.lenis:not(.lenis-autoToggle).lenis-stopped { overflow: clip; }
.lenis [data-lenis-prevent], .lenis [data-lenis-prevent-wheel], .lenis [data-lenis-prevent-touch] { overscroll-behavior: contain; }
.lenis.lenis-smooth iframe { pointer-events: none; }
.lenis.lenis-autoToggle { transition-property: overflow; transition-duration: 1ms; transition-behavior: allow-discrete; }

/* ── DESIGN TOKENS ─────────────────────────────────────────────── */
:root {
  /* Color palette — exact Framer tokens */
  --c-bg:          #090909;
  --c-white:       #ffffff;
  --c-off-white:   #f5f4f3;
  --c-fafaf9:      #fafaf9;
  --c-f9f9f9:      #f9f9f9;
  --c-f9-80:       #f9f9f9cc;
  --c-f9-60:       #f9f9f999;
  --c-f9-10:       #f9f9f91a;
  --c-f9-05:       #f9f9f90d;
  --c-09-80:       #090909cc;
  --c-09-60:       #09090999;
  --c-09-50:       #09090980;
  --c-dark:        #222222;
  --c-1e:          #1e1e1e;
  --c-eaeaea:      #eaeaea;
  --c-eaeaea-30:   #eaeaea4d;
  --c-d9d9d9:      #d9d9d9;
  --c-e3e3e3:      #e3e3e3;
  --c-63:          #63615e;
  --c-7f:          #7f7f80;
  --c-accent:      #ff462e;
  --c-accent-10:   #ff462e1a;
  --c-accent-20:   #ff462e33;
  /* Status green — availability indicator only, intentionally outside the accent palette. */
  --c-available:   #34d399;
  /* The site's actual decorative accent — same coral→purple stops as
     wavePalette in js/interactions.js (initGradientWaveText). Keep these
     two in sync if that palette ever changes. Use this (not --c-accent)
     for anything meant to read as the site's signature accent mark. */
  --gradient-accent: linear-gradient(90deg, #FE8F6D, #FBC085, #F3F3A5, #80FFFE, #4C92FE, #6160FB, #B78CDE);
  /* Same stops as --gradient-accent, rotated to run top-to-bottom — for
     anything the accent gradient is applied to that's taller than it is
     wide (the work-history timeline line), where a 90deg gradient on a
     2px-wide bar has no room to actually show the spectrum. Keep in sync
     with --gradient-accent/wavePalette above. */
  --gradient-accent-vertical: linear-gradient(180deg, #FE8F6D, #FBC085, #F3F3A5, #80FFFE, #4C92FE, #6160FB, #B78CDE);
  /* Same stops as --gradient-accent, radiating from an off-center light
     source — for small round marks (timeline dot) where a 90deg linear
     band reads as flat stripes instead of a spectrum. */
  --gradient-accent-radial: radial-gradient(circle at 30% 30%, #FE8F6D, #FBC085, #F3F3A5, #80FFFE, #4C92FE, #6160FB, #B78CDE);
  /* Reserved for .footer only — deliberately its own value, not var(--c-bg)
     or var(--c-1e), so the footer reads as a distinct, slightly lighter
     "floor" beneath the rest of the page. Don't reuse this elsewhere. */
  --c-footer-bg: #000000;
  /* Derived, not a new hardcoded color — exactly 5% lighter than --c-bg. */
  --c-bg-lifted-5: color-mix(in srgb, var(--c-bg) 95%, white 5%);
  /* Stepped, opaque (not alpha-transparent) lifts off --c-bg-lifted-5 for the
     three service cards — 3% / 6% / 9% white mixed in, not rgba overlays. */
  --c-lift-3: color-mix(in srgb, var(--c-bg-lifted-5) 97%, white 3%);
  --c-lift-6: color-mix(in srgb, var(--c-bg-lifted-5) 94%, white 6%);
  --c-lift-9: color-mix(in srgb, var(--c-bg-lifted-5) 91%, white 9%);

  /* Typography */
  --font:      'Host Grotesk', system-ui, -apple-system, sans-serif;
  --font-mono: 'Fragment Mono', 'Courier New', monospace;

  /* Spacing */
  --pad-x:  40px;
  --max-w:  1800px;
  /* Narrower "editorial" reading column for case-study pages specifically
     (see .case-page rules) — the site's general --max-w reads fine for
     grids/hero layouts, but ran edge-to-edge on wide viewports for long-
     form case-study copy, which read as generic web layout rather than an
     editorial one. */
  --case-max-w: 1360px;

  /* Easing */
  --ease:       cubic-bezier(0.25, 0.46, 0.45, 0.94);
  --ease-out:   cubic-bezier(0.16, 1, 0.3, 1);
  --ease-sharp: cubic-bezier(0.77, 0, 0.175, 1);
}

/* ------------------------- Scaling System by Osmo [https://osmo.supply/] -------------------------  */
/* Fluid base unit — body font-size uses this, and heading sizes below are
   expressed in em (multiples of it) instead of independent vw-based clamps,
   so every heading scales together and keeps the same relative proportions
   to each other at any viewport width, instead of drifting in and out of
   the intended hierarchy the way independent per-element clamps could. */

/* Desktop */
:root {
  --size-unit: 16; /* body font-size in design - no px */
  --size-container-ideal: 1440; /* screen-size in design - no px */
  --size-container-min: 992px;
  --size-container-max: 1920px;
  --size-container: clamp(var(--size-container-min), 100vw, var(--size-container-max));
  --size-font: calc(var(--size-container) / (var(--size-container-ideal) / var(--size-unit)));
}

/* Tablet */
@media screen and (max-width: 991px) {
  :root {
    --size-container-ideal: 834; /* screen-size in design - no px */
    --size-container-min: 768px;
    --size-container-max: 991px;
  }
}

/* Mobile Landscape */
@media screen and (max-width: 767px) {
  :root {
    --size-container-ideal: 550; /* screen-size in design - no px */
    --size-container-min: 480px;
    --size-container-max: 767px;
  }
}

/* Mobile Portrait */
@media screen and (max-width: 479px) {
  :root {
    --size-container-ideal: 390; /* screen-size in design - no px */
    --size-container-min: 320px;
    --size-container-max: 479px;
  }
}

/* ── RESET ──────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body, #main { margin: 0; padding: 0; }
/* overflow-x:hidden on body alone (below) doesn't reliably stop the page
   itself from scrolling horizontally — per the CSS spec, an overflow value
   on body only propagates to the viewport when body is the root scrolling
   box, and browsers vary on when that applies. html needs its own rule too
   so deliberately-bleeding elements (e.g. .about-hero-photo on mobile)
   never let the whole page pan sideways, which was dragging the fixed nav
   along with it (About/mobile: nav read as cut off on the right). */
html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; scroll-behavior: smooth; overflow-x: hidden; }
h1,h2,h3,h4,h5,h6,p,figure { margin: 0; }
/* Balanced line-breaking on paragraph copy — browsers cap this at ~6 lines
   for performance, so it degrades gracefully on longer paragraphs. */
p { text-wrap: balance; }
a { color: inherit; text-decoration: none; }
img, video, svg { display: block; max-width: 100%; }
button { cursor: pointer; background: none; border: none; font: inherit; color: inherit; appearance: none; -webkit-appearance: none; }
ul, ol { list-style: none; margin: 0; padding: 0; }
::selection {
  background-color: var(--c-f9f9f9);
  color: var(--c-bg);
}
/* Visually hidden but readable by screen readers / search crawlers — used
   where a page needs a real <h1> for SEO/accessibility but the design has
   no room for visible heading text (e.g. the Lab page's visual grid). */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

body {
  font-family: var(--font);
  font-size: var(--size-font);
  background: var(--c-bg);
  color: var(--c-f9f9f9);
  overflow-x: hidden;
  /* Plain per-page fade-in, replacing the old Barba-driven pixel-wipe page
     transition — CSS-only (no JS coordination to get wrong), and it can
     never leave content stuck invisible the way a JS-gated opacity could
     if a script failed to run. */
  animation: page-fade-in 0.4s var(--ease-out);
}

@keyframes page-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  body { animation: none; }
}

/* ── CROSS-DOCUMENT PAGE TRANSITION (blur crossfade) ──────────────────
   Native CSS View Transitions, cross-document navigation.js is gone, but
   this gets a real transition back without any JS orchestration to get
   wrong: `navigation: auto` tells a supporting browser to snapshot the
   outgoing page and the freshly-loaded incoming page and animate between
   those two snapshots as the ::view-transition-old(root)/-new(root)
   pseudo-elements below, which by default just cross-fade opacity — the
   animation-name overrides replace that default with a synchronized
   opacity + blur(). Both snapshots occupy the same box the whole time
   (no ::view-transition-group(root) transform is ever set here), so
   there's no sliding or positional movement, only the blur/opacity
   dissolve. Unsupported browsers (Safari/Firefox as of writing) just
   navigate normally — this whole block is inert there, not a fallback
   that needs its own code path. */
@view-transition {
  navigation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 1s;
  animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

::view-transition-old(root) {
  animation-name: page-crossfade-out;
}

::view-transition-new(root) {
  animation-name: page-crossfade-in;
}

@keyframes page-crossfade-out {
  from { opacity: 1; filter: blur(0); }
  to   { opacity: 0; filter: blur(5px); }
}

@keyframes page-crossfade-in {
  from { opacity: 0; filter: blur(5px); }
  to   { opacity: 1; filter: blur(0); }
}

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

/* ── PROGRESSIVE BLUR (bottom) ──────────────────────────────────── */

/* ── LAYOUT ─────────────────────────────────────────────────────── */
.container {
  width: 100%;
  max-width: var(--max-w);
  margin: 0 auto;
  padding: 0 var(--pad-x);
}

/* ── NAVIGATION ─────────────────────────────────────────────────── */
.nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  background: transparent;
  padding: 20px var(--pad-x);
  display: flex;
  align-items: center;
  justify-content: space-between;
  transition: transform 0.45s var(--ease-out);
}

/* Hidden by js/interactions.js (initLenis scroll handler) once the user
   scrolls down past the threshold; slides back in on scroll-up or near top. */
.nav.nav-hidden {
  transform: translateY(-100%);
}

.nav-logo,
.nav-right,
.nav-toggle {
  position: relative;
  z-index: 1;
}

/* Progressive blur band behind the nav, connected to it (a child, so it
   hides/shows together with .nav-hidden). Fades in via .is-visible once
   the page has scrolled, toggled in js/interactions.js. */
.nav-blur {
  position: absolute;
  inset: 0 0 auto 0;
  height: 150px;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.5s var(--ease-out);
}
.nav-blur.is-visible { opacity: 1; }

/* Single blurred layer: a color wash fading out toward the bottom edge,
   masked so both the wash and the backdrop blur fade together — solid at
   the top, fully sharp by the bottom of the band. */
.nav-blur-layer {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, transparent, var(--c-bg));
  mask-image: linear-gradient(to bottom, var(--c-bg) 50%, transparent);
  -webkit-mask-image: linear-gradient(to bottom, var(--c-bg) 50%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

@media (prefers-reduced-motion: reduce) {
  .nav { transition: none; }
  .nav-blur { transition: none; }
}

.nav-logo {
  display: block;
  flex-shrink: 0;
}

.nav-logo img {
  width: 63px;
  height: 32px;
  display: block;
}

.nav-right {
  display: flex;
  align-items: center;
  gap: 24px;
}

.nav-link {
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--c-f9f9f9);
  opacity: 0.8;
  transition: opacity 0.2s;
}
.nav-link:hover { opacity: 1; }

/* Language dropdown */
.lang-dropdown {
  position: relative;
}
.lang-dropdown-trigger {
  display: flex;
  align-items: center;
  gap: 6px;
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-f9f9f9);
  opacity: 0.8;
  cursor: pointer;
  transition: opacity 0.2s;
}
.lang-dropdown-trigger:hover { opacity: 1; }
.lang-dropdown-chevron {
  width: 8px;
  height: 8px;
  flex-shrink: 0;
  transition: transform 0.25s var(--ease);
}
.lang-dropdown.is-open .lang-dropdown-chevron { transform: rotate(180deg); }

.lang-dropdown-menu {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  min-width: 130px;
  margin: 0;
  padding: 6px;
  list-style: none;
  background: var(--c-1e);
  border: 1px solid var(--c-f9-10);
  border-radius: 10px;
  box-shadow: 0 12px 32px rgba(0,0,0,0.45);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-6px);
  transition: opacity 0.2s var(--ease-out), transform 0.2s var(--ease-out), visibility 0.2s;
  z-index: 10;
}
.lang-dropdown.is-open .lang-dropdown-menu {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.lang-dropdown-option {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 8px 10px;
  border-radius: 6px;
  font-family: var(--font);
  font-size: 13px;
  color: var(--c-f9-80);
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}
.lang-dropdown-option:hover { background: var(--c-f9-10); color: var(--c-f9f9f9); }
.lang-dropdown-option.is-active { color: var(--c-f9f9f9); font-weight: 500; }

.lang-dropdown--overlay {
  align-self: flex-start;
}
.lang-dropdown--overlay .lang-dropdown-trigger { font-size: 14px; }
.lang-dropdown--overlay .lang-dropdown-menu {
  top: auto;
  bottom: calc(100% + 10px);
  left: 0;
  right: auto;
}

/* Mobile hamburger */
.nav-toggle {
  display: none;
  flex-direction: column;
  gap: 5px;
  padding: 4px;
  cursor: pointer;
}
.nav-toggle span {
  display: block;
  width: 22px;
  height: 1.5px;
  background: var(--c-f9f9f9);
  transition: transform 0.3s var(--ease), opacity 0.3s;
}
.nav-toggle.open span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
.nav-toggle.open span:nth-child(2) { opacity: 0; }
.nav-toggle.open span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }

/* ── MOBILE FULLSCREEN NAV OVERLAY ─────────────────────────────── */
.nav-overlay {
  position: fixed;
  inset: 0;
  z-index: 99;
  background: var(--c-bg);
  display: flex;
  flex-direction: column;
  /* Content anchored near the top with a bit of breathing room under the
     nav bar (still visible above this overlay, z-index:100 vs 99), not
     vertically centered in the full viewport height — centering left a
     large empty gap above the links on tall phone screens. */
  justify-content: flex-start;
  padding: 110px var(--pad-x) 40px;
  gap: 40px;
  pointer-events: none;
  visibility: hidden;
  opacity: 0;
  /* Now carries the socials + copyright/status/location block below (same
     info the footer has), on top of the nav links themselves — taller
     than a single short phone screen can always fit, so this scrolls
     internally instead of clipping the bottom content. */
  overflow-y: auto;
}

.nav-overlay.is-open { pointer-events: auto; }

.nav-overlay-links {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.nav-overlay-link {
  font-family: var(--font);
  font-size: clamp(40px, 12vw, 72px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.1;
  color: var(--c-f9f9f9);
  display: block;
  padding: 6px 0;
  opacity: 0.9;
  transition: opacity 0.2s;
}
.nav-overlay-link:hover { opacity: 1; }

.nav-overlay-cta { align-self: flex-start; }

.nav-overlay-socials {
  display: flex;
  flex-wrap: wrap;
  gap: 12px 20px;
}
.nav-overlay-social-link {
  font-family: var(--font-mono);
  font-size: 13px;
  letter-spacing: 0.02em;
  color: var(--c-f9-60);
  transition: color 0.2s;
}
.nav-overlay-social-link:hover { color: var(--c-f9f9f9); }

/* Same info the footer carries (copyright/VAT, availability, location +
   time) — footer itself sits far below the fold on most pages, so the
   mobile menu is otherwise the only place this ever surfaces on a phone
   without scrolling all the way down. */
.nav-overlay-meta {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding-top: 24px;
  border-top: 1px solid var(--c-f9-10);
}
.nav-overlay-copy,
.nav-overlay-status,
.nav-overlay-location {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.02em;
  color: var(--c-f9-60);
}
.nav-overlay-status {
  display: flex;
  align-items: center;
  gap: 8px;
}
.nav-overlay-location {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Hide overlay on desktop — GSAP only needs to animate on mobile */
@media (min-width: 810px) {
  .nav-overlay { display: none; }
}

/* ── BUTTONS (character stagger) ────────────────────────────────── */
.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 14px 24px;
  border: none;
  border-radius: 0;
  font-family: var(--font);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
  overflow: hidden;
}

/* Background box — inset shrinks slightly on hover for a subtle "press" resize */
.btn-bg {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transition: inset 0.6s var(--ease-sharp);
}
.btn:hover .btn-bg { inset: 0.125em; }

.btn-primary { color: var(--c-bg); }
.btn-primary .btn-bg { background: var(--c-f9f9f9); }

.btn-secondary { color: var(--c-f9f9f9); }
.btn-secondary .btn-bg {
  background: var(--c-f9-10);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

/* Shimmer accent — a moving highlight in the site's gradient-accent colors
   sweeping across the button, on top of .btn-bg rather than replacing it,
   so the button stays legible (white text on the existing dark blurred
   base) while still reading as more visually prominent. mix-blend-mode
   lightens whatever's underneath as the streak passes over it instead of
   sitting as a flat colored bar. */
.btn-shimmer {
  position: relative;
  overflow: hidden;
}
.btn-shimmer::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg,
    transparent 15%,
    color-mix(in srgb, #FBC085 65%, transparent) 30%,
    color-mix(in srgb, #80FFFE 65%, transparent) 45%,
    color-mix(in srgb, #B78CDE 65%, transparent) 60%,
    transparent 75%);
  background-size: 250% 100%;
  background-position: 200% 0;
  mix-blend-mode: screen;
  pointer-events: none;
  animation: btn-shimmer-sweep 3.2s ease-in-out infinite;
}
@keyframes btn-shimmer-sweep {
  0%   { background-position: 200% 0; }
  55%, 100% { background-position: -120% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .btn-shimmer::after { animation: none; display: none; }
}

/* Shimmer ring — the "Live site" case-study button only. Same rotating
   gradient-accent technique as the tool tag badges' shimmering border
   (.tools-badge-shape in css/tools.css): a conic-gradient masked down to
   just the 1px border via the padding + mask-composite trick (::before,
   the crisp ring), plus a softer, heavily blurred, unmasked copy of the
   same arc behind/around it (::after, the trailing glow) instead of the
   flat linear-gradient sweep .btn-shimmer above uses. The mask technique
   doesn't care about corner radius — border-radius:0 here keeps this
   button square, unlike the pill-shaped badge original. */
@property --btn-shimmer-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.btn-shimmer-ring {
  position: relative;
  /* visible, not hidden — this is the actual fix for the hard edge: a
     blurred glow still has to fade out SOMEWHERE, and with overflow:hidden
     that fade got cropped at the exact pixel of the button's own edge,
     so the soft coloring stopped dead against a crisp rectangular cutoff
     instead of actually dissolving to nothing. Letting it spill past the
     button (same principle as any CSS glow/shadow) gives the blur the
     room it needs to fully fade before there's any edge left to notice.
     .btn's own text/background stay exactly as contained as before —
     nothing here reads from outside the button's box except this glow. */
  overflow: visible;
  border-radius: 0;
}

.btn-shimmer-ring::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  padding: 1.5px;
  border-radius: inherit;
  background: conic-gradient(from var(--btn-shimmer-angle),
    transparent 0deg,
    transparent 295deg,
    #FE8F6D 315deg,
    #F3F3A5 330deg,
    #80FFFE 345deg,
    #B78CDE 358deg,
    transparent 360deg);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  /* Softens the ring itself, not just the trailing glow behind it — a
     crisp 1.5px mask edge reads as a hard, slightly harsh stroke
     (especially where it doubles up going round a square corner). filter
     applies after masking (not before), so pushing this blur up doesn't
     touch the clip at all — .btn-shimmer-ring's own overflow:hidden still
     crops the blurred result exactly like it did the crisp one, it's just
     a softer shape being cropped. Bumped well past the first pass (2px) to
     read as an ambient glow concentrated wherever the arc currently sits,
     not a traceable line around the whole edge. */
  filter: blur(6px);
  animation: btn-shimmer-ring-spin 3s linear infinite;
  pointer-events: none;
}

/* Trailing glow — unmasked (fills the whole face, not just the ring),
   heavily blurred well past the badge original's 3px since this traces a
   much bigger surface, and screen-blended so it reads as ambient light
   drifting past rather than a shape covering the button. */
.btn-shimmer-ring::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  background: conic-gradient(from var(--btn-shimmer-angle),
    transparent 0deg,
    transparent 300deg,
    color-mix(in srgb, #FE8F6D 45%, transparent) 320deg,
    color-mix(in srgb, #F3F3A5 45%, transparent) 335deg,
    color-mix(in srgb, #80FFFE 45%, transparent) 348deg,
    color-mix(in srgb, #B78CDE 45%, transparent) 358deg,
    transparent 360deg);
  filter: blur(10px);
  opacity: 0.7;
  mix-blend-mode: screen;
  animation: btn-shimmer-ring-spin 3s linear infinite;
  pointer-events: none;
}

@keyframes btn-shimmer-ring-spin {
  to { --btn-shimmer-angle: 360deg; }
}

@media (prefers-reduced-motion: reduce) {
  .btn-shimmer-ring::before,
  .btn-shimmer-ring::after {
    animation: none;
  }
}

.btn-text,
.btn-icon {
  position: relative;
  z-index: 1;
}
.btn-icon { flex-shrink: 0; }
.btn-text { white-space: nowrap; }

/* Character stagger on hover */
.btn [data-button-animate-chars] {
  overflow: hidden;
  position: relative;
  display: inline-block;
}
.btn [data-button-animate-chars] span {
  display: inline-block;
  position: relative;
  text-shadow: 0 1.3em currentColor;
  transform: translateY(0em) rotate(0.001deg);
  transition: transform 0.6s var(--ease-sharp);
}
.btn:hover [data-button-animate-chars] span {
  transform: translateY(-1.3em) rotate(0.001deg);
}

@media (prefers-reduced-motion: reduce) {
  .btn-bg,
  .btn [data-button-animate-chars] span {
    transition: none;
  }
}

/* ── SECTION DIVIDER ────────────────────────────────────────────── */
.section-line {
  width: 100%;
  height: 1px;
  background: var(--c-f9-10);
  max-width: var(--max-w);
  margin-left: auto;
  margin-right: auto;
}

/* Animated rule — draws from left on scroll/entry */
[data-reveal-line] {
  transform-origin: left center;
  transform: scaleX(0);
  transition: transform 0.9s var(--ease-out);
}
[data-reveal-line].is-visible {
  transform: scaleX(1);
}

/* ── LABELS / TAGS ──────────────────────────────────────────────── */
.label {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  color: var(--c-f9-60);
  text-transform: uppercase;
}

.tag {
  display: inline-flex;
  align-items: center;
  padding: 5px 12px;
  background: var(--c-f9-10);
  border-radius: 0;
  font-size: 12px;
  font-weight: 500;
  color: var(--c-f9-60);
  letter-spacing: -0.01em;
}

.tag-accent {
  background: var(--c-accent-10);
  color: var(--c-accent);
}

.tag-online {
  background: color-mix(in srgb, var(--c-available) 15%, transparent);
  color: var(--c-available);
}

/* ── SCROLL REVEAL ──────────────────────────────────────────────── */
[data-reveal] {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.7s var(--ease-out), transform 0.7s var(--ease-out);
}
[data-reveal].is-visible {
  opacity: 1;
  transform: translateY(0);
}
[data-reveal="0.1"] { transition-delay: 100ms; }
[data-reveal="0.2"] { transition-delay: 200ms; }

/* Content that waits for a section's pixelated-scroll-transition to finish
   dissolving before revealing — see initRevealAfterPixels in interactions.js.
   Opacity only, no transform: a transform here (even translateY(0) at rest)
   makes this element a containing block for descendants, which silently
   breaks position:sticky on the stacking cards nested inside it. */
[data-reveal-after-pixels] {
  opacity: 0;
  transition: opacity 0.9s var(--ease-out);
}
[data-reveal-after-pixels].is-visible {
  opacity: 1;
}

/* FAQ-only ramp-up: .faq-inner has no sticky/pinned descendants (unlike the
   services/offerings instances above, which must stay opacity-only), so it
   can safely add a translateY reveal on top of the shared opacity fade. */
.faq-inner[data-reveal-after-pixels] {
  transform: translateY(32px);
  transition: opacity 0.9s var(--ease-out), transform 0.9s var(--ease-out);
}
.faq-inner[data-reveal-after-pixels].is-visible {
  transform: translateY(0);
}
[data-reveal="0.3"] { transition-delay: 300ms; }
[data-reveal="0.4"] { transition-delay: 400ms; }
[data-reveal="0.5"] { transition-delay: 500ms; }

/* ── MASKED TEXT REVEAL ──────────────────────────────────────────── */
[data-split] {
  visibility: hidden;
}

/* Give descenders (g, j, p, q, y) room inside the SplitText line mask so
   they don't get clipped by the wrapper's overflow:hidden. */
.line-mask {
  padding-bottom: 0.1em;
  margin-bottom: -0.1em;
}

/* ── GRADIENT WAVE TEXT ──────────────────────────────────────────── */
[data-gradient-wave-text] {
  visibility: hidden;
}

/* ═══════════════════════════════════════════════════════════════════
   HOME PAGE
   ═══════════════════════════════════════════════════════════════════ */

/* ── HERO ────────────────────────────────────────────────────────── */
.hero {
  position: relative;
  overflow: hidden;
  min-height: 90svh;
  background: var(--c-bg);
  padding: 0 var(--pad-x);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding-top: 80px;
}

/* Animated shader background — js/hero-wave.js sizes its pixel buffer off
   this element's own rendered box (not the viewport), so it stays sharp
   and undistorted at the hero's actual aspect ratio. Sits behind the
   content below via z-index, not DOM order, since it's positioned. */
.hero-wave {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  /* No blur. The previous three.js shader drew a displaced plane in
     perspective and stair-stepped at the pixel level, which a 20px
     compositor blur was covering (and which also dimmed this box's own
     edges against .hero). The wave gradient that replaced it evaluates its
     ramp per-pixel and carries its own grain term for the banding a smooth
     ramp shows at large sizes, so there is nothing left to hide — blurring
     it here would only smear the wave folds the shader exists to draw. */
}

/* Fades the shader out into the page's own background toward the bottom
   of the hero, so it blends into the section below instead of cutting
   off sharply. Sits after .hero-wave in DOM (both z-index: 0), so it
   paints on top of the canvas but still under .hero-top's content. */
.hero-wave-overlay {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: linear-gradient(to bottom, transparent 0%, var(--c-bg) 100%);
}

.hero-top {
  position: relative;
  z-index: 1;
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  padding-top: 80px;
  padding-bottom: 60px;
  width: 100%;
  max-width: var(--max-w);
  margin: 0 auto;
}

/* ── HERO LOGO MARQUEE ───────────────────────────────────────────── */
@keyframes logo-marquee {
  0%   { transform: translateX(0); }
  100% { transform: translateX(-50%); }
}

.hero-logos {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-bottom: 0;
  width: 100%;
}

.hero-logos__label {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-f9-60);
  opacity: 0.5;
  margin-bottom: 16px;
}

.hero-logos__row {
  overflow: hidden;
  /* Slack for the baseline translateY nudges further down, so none of them
     can push artwork into the overflow clip. */
  padding-block: 3px;
  width: 100%;
  -webkit-mask-image: linear-gradient(
    to right,
    transparent,
    black 12%,
    black 88%,
    transparent
  );
  mask-image: linear-gradient(
    to right,
    transparent,
    black 12%,
    black 88%,
    transparent
  );
}

.hero-logos__row:hover .hero-logos__track {
  animation-play-state: paused;
}

.hero-logos__track {
  display: flex;
  align-items: center;
  width: max-content;
  animation: logo-marquee 22s linear infinite;
}

.hero-logos__item {
  height: 24px;
  width: auto;
  opacity: 0.28;
  filter: brightness(0) invert(1);
  transition: opacity 0.25s var(--ease);
  display: block;
  flex-shrink: 0;
  margin-right: 56px;
}

.hero-logos__item:hover {
  opacity: 0.85;
}

/* --- Optical balancing of the logo strip ------------------------------
   The shared 24px above is a box height, and a box height is not a letter
   size: each of these files crops differently around its own artwork, and
   several are lockups where a mark, not the type, sets the box. Left at a
   single 24px the strip ran from LEXROOM's 10.5px caps to nexi's 14.1px
   lowercase, with EA's letters down at 8px inside their disc.

   Both blocks below were measured rather than eyeballed: each logo was
   rasterised and its row-by-row ink profile read to find the cap/x-height
   top and the baseline, with a 25% ink threshold so i-dots, the WÜRTH
   umlaut and descenders don't count as letter height. Re-measure the same
   way before changing a number here.

   1. HEIGHT — sized so every logo's letters land near a common size
   (caps ~12.5px, lowercase x-height ~10px). Width stays auto, so each
   file keeps its own aspect ratio.

   Two entries deliberately sit above what that maths gives, because
   letter height alone does not predict perceived size: EA's letters are
   counters knocked out of a solid disc, which reads smaller than the same
   letters drawn in ink, and nexi is a short, light-weight lowercase
   wordmark that reads small beside a long bold one whatever its letters
   measure. Trust the eye over the numbers in cases like these. */
.hero-logos__item--ea         { height: 42px; }  /* letters are 34% of the file */
.hero-logos__item--plenitude  { height: 39px; }  /* wordmark is 33% of the file, rest is the Eni dog */
.hero-logos__item--lexroom    { height: 28px; }  /* caps are only 44% of the file */
.hero-logos__item--nexi       { height: 29px; }  /* set by eye, not by the letter-height maths: "nexi" is four light-weight
                                                    lowercase letters, so it reads small next to a long bold wordmark even
                                                    when its letters measure larger. Footprint and ink mass win here. */
.hero-logos__item--wurth      { height: 22px; }  /* shield lockup, caps are 54% of the file */

/* 2. BASELINE — .hero-logos__track centres every item on its own box, but
   the logos carry wildly different amounts of empty space above and below
   their artwork, so centring them scattered their baselines across ~7px.
   These translate the outliers onto one line (+6.2px below the row's
   centre, where the sized-up group above naturally falls). Transform, not
   margin, so nothing here changes the marquee's layout or spacing.
   The three logos not listed already land within half a pixel of it.
   Note these depend on the heights above: change one and its nudge has to
   be recomputed as 6.2 - height * (baselineFraction - 0.5). */
.hero-logos__item--enel             { transform: translateY(-4.8px); }
.hero-logos__item--essilorluxottica { transform: translateY(-1.5px); }
.hero-logos__item--iuman            { transform: translateY(-1.3px); }
.hero-logos__item--mooney           { transform: translateY(0.7px); }
.hero-logos__item--nexi             { transform: translateY(-2.2px); }
.hero-logos__item--plenitude        { transform: translateY(2px); }

.hero-left {
  width: 100%;
  max-width: 1100px;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 0;
}

.hero-right {
  display: none;
}

.hero-tag {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--c-f9-60);
  letter-spacing: 0.02em;
  display: flex;
  align-items: center;
  gap: 10px;
  text-transform: uppercase;
}

.hero-headline {
  position: relative;
  /* The page's main heading — sized to be the largest heading on the page
     (see the Osmo scaling system above), but capped lower than the other
     page H1s (about/case/works) since this one sits centered in a narrower
     1100px column: 72px is close to the limit before the current headline
     copy wraps to an unintended 3rd line. */
  font-size: clamp(30px, 9.5vw, 72px);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.04em;
  color: var(--c-f9f9f9);
  margin-bottom: 16px;
}

.hero-sub {
  font-size: clamp(16px, 1.5vw, 22px);
  font-weight: 400;
  line-height: 1.55;
  color: var(--c-f9-60);
  max-width: 520px;
  margin-bottom: 36px;
}

.hero-cta-wrap {
  margin-bottom: 48px;
}

.hero-scroll-line {
  width: 100%;
  max-width: var(--max-w);
  margin: 0 auto;
}

/* ── WHO I AM ────────────────────────────────────────────────────── */
.section-who {
  background: var(--c-bg);
  padding: 120px var(--pad-x);
}

.who-inner {
  max-width: var(--max-w);
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 60px 80px;
  align-items: start;
}

.who-label {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  color: var(--c-f9-60);
  margin-bottom: 30px;
  display: flex;
  align-items: center;
  gap: 10px;
  text-transform: uppercase;
}

.who-heading {
  font-size: clamp(32px, 6.85vw, 52px);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.03em;
  color: var(--c-f9f9f9);
}

.who-text {
  font-size: clamp(16px, 1.3vw, 19px);
  line-height: 1.6;
  color: var(--c-f9-60);
}

.who-text + .who-text {
  margin-top: 24px;
}

.who-cta {
  margin-top: 50px;
}

/* ── ABOUT TEASER ────────────────────────────────────────────────── */
.section-about-teaser {
  background: var(--c-bg);
  padding: 38vh var(--pad-x) clamp(70px, 10vh, 130px);
  position: relative;
  overflow: hidden;
}

.about-teaser-inner {
  max-width: var(--max-w);
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  position: relative;
  z-index: 1;
}

/* ── BOTTOM PHOTO STRIP ───────────────────────────────────────────
   Replaced the scattered "floating image field" (and js/about-floaters.js)
   on 2026-08-22: a row of overlapping, tilted photos running along the
   section's bottom edge instead of drifting behind the text.

   Bare photos on purpose, no polaroid frame. That costs the separation a
   white frame would have given where the cards overlap, so the ring in
   the box-shadow below does that job instead: against --c-bg it is
   invisible, but where a card sits over its neighbour's artwork it reads
   as a clean edge.

   The row is deliberately wider than the viewport so the outer two cards
   run off the sides and the strip reads as continuing past the page. It
   is NOT cropped vertically: the cards sit whole, with the section's own
   bottom padding leaving air beneath them. */
.about-strip {
  --strip-overlap: 5vw;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  /* Out through the section's own --pad-x and past it on each side, so
     the row measures ~103vw and the outer two cards are cut by the
     viewport edges. Five cards rather than four is what makes that
     possible at this card size: the row has to be wider than the
     viewport to bleed, and four cards this small cannot reach it without
     opening gaps between them. */
  margin-inline: calc(-1 * (var(--pad-x) + 1.5vw));
  margin-top: clamp(80px, 13vh, 170px);
  position: relative;
  z-index: 0;
}

.about-strip-item {
  flex: 1 1 0;
  aspect-ratio: 3 / 4;
  overflow: hidden;
  /* Base transform is the no-JS/reduced-motion resting state. When
     initAboutStrip (js/interactions.js) runs it takes the transform over
     entirely via GSAP, seeded from these same two custom properties. */
  transform: translateY(var(--strip-y, 0)) rotate(var(--strip-rot, 0deg));
  box-shadow:
    0 0 0 1px rgba(9, 9, 9, 0.85),
    0 24px 60px rgba(0, 0, 0, 0.55);
}

.about-strip-item + .about-strip-item {
  margin-left: calc(-1 * var(--strip-overlap));
}


/* Each card sits above the one to its left, so the row overlaps in one
   consistent direction instead of by DOM accident. */
.about-strip-item:nth-child(1) { z-index: 1; }
.about-strip-item:nth-child(2) { z-index: 2; }
.about-strip-item:nth-child(3) { z-index: 3; }
.about-strip-item:nth-child(4) { z-index: 4; }
.about-strip-item:nth-child(5) { z-index: 5; }

/* Whichever card initAboutStrip has under the pointer. Only job is the
   stacking order — the lift, scale and straighten are all GSAP, since they
   have to compose with the parallax offset written to the same transform.
   Must stay AFTER the nth-child rules above: it matches at the same
   specificity, so source order is the only thing that lets it win, and a
   hovered card that loses rises underneath its own neighbour. */
.about-strip-item.is-lifted {
  z-index: 6;
}

/* ── TEMPORARY: strip hidden (2026-08-22, at Omar's request) ───────
   "For now hide the polaroids (all of them)." Everything above is left
   intact — markup in index.php, initAboutStrip in js/interactions.js,
   the images on disk — so this is a two-line revert.

   The padding-bottom is part of the same hide, not a separate decision:
   .section-about-teaser gave up its symmetrical 38vh bottom padding to
   make room for the strip, and without the strip that leaves the section
   visibly top-heavy. Restoring it here keeps the two changes together, so
   deleting this block returns both to the strip layout in one step.

   TO RESTORE: delete this whole block. Nothing else. */
.about-strip {
  display: none;
}

.section-about-teaser {
  padding-bottom: 38vh;
}

.about-strip-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Narrow viewports: the four cards would each be too small to read, so
   they grow past the viewport instead and only the middle two-ish stay
   fully visible — the same "row continues off both edges" idea, just with
   fewer cards in frame. */
@media (max-width: 809px) {
  .about-strip {
    --strip-overlap: 5vw;
    /* ~205vw of row, so cards stay ~45vw and about two and a half of the
       five are in frame at a time. */
    margin-inline: calc(-1 * (var(--pad-x) + 53vw));
    margin-top: clamp(56px, 9vh, 90px);
  }
}

.about-teaser-label {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.12em;
  color: var(--c-f9-60);
  text-transform: uppercase;
  margin-bottom: 48px;
}

.about-teaser-thesis {
  margin-bottom: 20px;
}

.about-teaser-thesis-line {
  display: block;
  font-family: var(--font);
  font-size: clamp(34px, 7.9vw, 60px);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.03em;
  color: var(--c-f9f9f9);
}

.about-teaser-support {
  font-size: clamp(20px, 1.8vw, 26px);
  line-height: 1.2;
  color: var(--c-f9-60);
  max-width: 520px;
  margin-bottom: 56px;
}

.about-teaser-links {
  display: flex;
  align-items: center;
  gap: 24px;
}

/* ── MY WORKS (home) ─────────────────────────────────────────────── */
.section-works-home {
  background: var(--c-bg);
  padding: 0 var(--pad-x) 160px;
}

.works-home-inner {
  max-width: var(--max-w);
  margin: 0 auto;
}

/* Sticky left label (title + description) alongside the scrolling row list */
.works-home-layout {
  display: grid;
  grid-template-columns: minmax(200px, 280px) 1fr;
  gap: 40px;
  align-items: start;
  padding-top: 40px;
}

.works-home-label {
  position: sticky;
  top: 140px;
}

.works-home-heading {
  font-size: clamp(24px, 4.2vw, 32px);
  font-weight: 700;
  letter-spacing: -0.03em;
  color: var(--c-f9f9f9);
  margin-bottom: 16px;
}

.works-home-sub {
  font-size: 15px;
  color: var(--c-f9-60);
  line-height: 1.55;
}

/* Two copies of this CTA in the DOM, one shown at a time by breakpoint:
   --label sits inside .works-home-label, right after the title/description
   (desktop default — pinned along with the sticky label as the list
   scrolls beside it). --bottom is a sibling of .works-list, after the last
   work row (mobile only, ≤809px — see the .works-home-cta--label/--bottom
   toggle in the media query below). */
.works-home-cta--label {
  margin-top: 24px;
}
.works-home-cta--bottom {
  display: none;
  grid-column: 2 / -1;
  margin-top: 24px;
}

.works-list {
  display: flex;
  flex-direction: column;
}

.work-row {
  display: grid;
  /* Media caps at 900px (see .work-row-media below) but previously sat in a
     1fr track that kept stretching on wide viewports, leaving a wide dead
     gap before the info column. Column 2 now absorbs the extra space
     instead, with .work-row-info's own max-width keeping it snug against
     the image rather than stretching the text out with it. */
  grid-template-columns: minmax(0, 900px) minmax(260px, 1fr);
  align-items: start;
  gap: 40px;
  padding: 48px 0;
  border-bottom: 1px solid var(--c-f9-10);
  text-decoration: none;
  color: inherit;
}
.work-row:first-child { padding-top: 0; }
.work-row:last-child { border-bottom: none; padding-bottom: 0; }

.work-row-media {
  position: relative;
  overflow: hidden;
  /* Matches project cover media's actual export frame (1440x790) instead
     of a generic 16/10 — that was narrower than the real assets (project
     videos included), so object-fit:cover on .work-row-media-inner below
     was cropping real width off the left/right of every video/image in
     this slot to force it into the wrong ratio, not just Luxottica's. */
  aspect-ratio: 1440 / 790;
  max-width: 900px;
  background: var(--c-1e);
  /* Forces a new stacking context on this element itself. Without it,
     .cover-shader-canvas's z-index:-1 (a couple rules down) has nothing
     local to sit inside — it escapes past this element's own painted
     background and gets grouped into the parent .work-row's stacking
     context instead (its data-reveal transform is what creates one),
     where the paint order puts this element's *own* background (an
     ordinary in-flow box in that outer context) above negative-z
     descendants. Net effect: the canvas rendered, but entirely hidden
     behind this element's flat var(--c-1e) fill. isolation:isolate is
     the side-effect-free way to get a local stacking context (unlike
     transform/opacity hacks) so the canvas reliably paints just above
     this element's own background and only below its sibling image. */
  isolation: isolate;
}

/* Oversized + translated by js/interactions.js (initWorkRowParallax) for a
   subtle scroll parallax, clipped by .work-row-media's overflow:hidden.
   Overscan is uniform (width AND height at 120%) rather than height-only
   — height-only overscan left this box narrower relative to its own
   height than .work-row-media itself, so the object-fit:cover image/
   video inside had to zoom in further just to cover that taller-than-
   visible shape, cropping real width off the sides beyond what the
   visible frame's own aspect ratio would ever need (most visible on
   Essilor Luxottica's 16:9 screen-recording video, where it cut into the
   nav text on both edges). Uniform scale keeps this box's aspect ratio
   identical to the visible frame's, so cover-fit crops by the same
   (small) amount a non-oversized box would — the vertical parallax range
   (yPercent ±8, initWorkRowParallax) is unaffected since it's relative
   to this element's own height, which is unchanged. */
.work-row-media-inner {
  position: absolute;
  inset: -10%;
  width: 120%;
  height: 120%;
}
/* Same "nudge the image down, let the shader show above it" treatment as
   .case-main-image img (css/style.css, case-study hero) — margin, not
   padding, so it composes with the parallax translateY GSAP applies to
   this same element (initWorkRowParallax) instead of fighting it: margin
   shifts the box before the transform is applied, so the two just add.
   Percentage insets (top/bottom: -10% via the shorthand above) are
   relative to *this* fixed-size parent (.work-row-media), not an
   oversized ancestor, so a px margin here lands as real on-screen
   pixels — no scaling correction needed the way nudging the inner <img>
   itself would require. */
.work-row-media:has(.cover-shader-canvas) .work-row-media-inner {
  margin-top: 48px;
}
.work-row-media-inner img,
.work-row-media-inner video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* Dims the BACK image on hover so the front preview card reads clearly. */
.work-row-media-inner::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
  transition: opacity 0.5s var(--ease-sharp);
}
.work-row:hover .work-row-media-inner::after {
  opacity: 1;
}

/* Hover reveal — a smaller card inset within the frame (not full-bleed),
   rotating into place as it slides up from the bottom. translateY(150%)
   (rather than 120%) keeps it fully clear of the frame at rest — the
   rotated corners were still peeking into view at 120%. Stays crisp/
   undimmed since the darkening now lives on the back image instead. */
.work-row-hover {
  position: absolute;
  inset: 14%;
  z-index: 2;
  overflow: hidden;
  transform: translateY(150%) rotate(-6deg);
  transform-origin: bottom;
  transition: transform 0.7s var(--ease-sharp);
  pointer-events: none;
}
.work-row-hover img,
.work-row-hover video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.work-row:hover .work-row-hover,
.project-card:hover .work-row-hover {
  transform: translateY(0) rotate(0deg);
}

/* Right-entrance variant — app-screen projects (Mooney Go, Enel, Allimb;
   see the "hoverFromRight" JSON field and index.php) slide in from the
   right instead of rotating up from below like every other project's
   hover card. Same specificity as the base rules above (one class each),
   so this only wins by coming later in the file — matches the existing
   .work-row-hover--ladder convention just below, which relies on the same
   source-order override rather than extra specificity. Combines with
   whichever hover type a project also uses (--marquee, --ladder, or the
   plain default); none of those set their own `transform`, except
   --ladder, which is handled separately (its frames animate on the X
   axis now instead of the group transform — see initWorkRowLadderHover
   in js/interactions.js). */
.work-row-hover--from-right {
  transform: translateX(120%);
}
.work-row:hover .work-row-hover--from-right,
.project-card:hover .work-row-hover--from-right {
  transform: translateX(0);
}

/* Touch devices never fire :hover, so the reveal card can never show
   itself the normal way. Per Omar's call, mobile now just keeps the main
   image only instead of showing the hover-reveal permanently (the
   previous behavior here) — display:none instead of leaving it
   transformed-off-canvas-but-still-loaded also skips fetching an image
   that would never be seen there anyway. */
@media (hover: none), (pointer: coarse) {
  .work-row-hover { display: none; }
  /* .work-row-hover--ladder's own unconditional `display: flex` below
     (2-class selector, higher specificity than .work-row-hover alone)
     otherwise wins over the rule above regardless of this media query —
     Allimb's ladder frames stayed visible on touch when every other
     project correctly fell back to just the background image. */
  .work-row-hover--ladder { display: none; }
}

/* "Ladder" hover — one or more "screen" frames, side by side with no gap
   between them, each staggered in from the right into place and scaled
   down as it settles, reversing (same stagger, exit order flipped) on
   mouse-leave. Entirely JS/GSAP-driven (initWorkRowLadderHover in
   js/interactions.js) since a properly *reversed* stagger on exit isn't
   achievable with a single CSS transition-delay. The group-level
   .work-row-hover transform (rules above, including --from-right) is
   switched off here so it doesn't compound with the frames' own motion —
   every app-screen project now uses this same mechanism (Mooney Go, Enel,
   Allimb, Luxottica), a single-frame array included, instead of a
   separate "plain" path. */
.work-row-hover--ladder {
  /* Horizontal inset used to be 12% a side — narrower than the base
     .work-row-media-inner image/video underneath (which covers the full
     card). flex-end settles the frame group against this box's own right
     edge, not the card's, so that 12% gap left a strip on the right where
     the differently-cropped base layer showed through past the last
     frame — a hard seam right at the boundary. 0 here makes this box's
     horizontal bounds match the card (the "image cover" underneath)
     exactly, so the rightmost frame's edge lands on the card's own right
     edge and there's nothing mismatched left showing behind it. Vertical
     inset (6%) is unrelated — the seam was never a top/bottom issue —
     kept as-is. */
  inset: 6% 0;
  display: flex;
  /* Frames are content-sized now (see .work-row-hover-frame below), so
     their combined width won't always exactly fill this box the way a
     flex:1 equal division always did. Centered in the box — they still
     animate in *from* the right (each frame's own xPercent:70→0 in
     initWorkRowLadderHover, js/interactions.js), just settle in the
     middle rather than pinned to the right edge, once the box's own
     bounds moved to match the full card above. */
  justify-content: center;
  gap: 0;
  transform: none;
}
.work-row:hover .work-row-hover--ladder,
.project-card:hover .work-row-hover--ladder {
  transform: none;
}
.work-row-hover-frame {
  position: relative;
  /* Content-sized (not flex:1 equal division) — each frame is exactly as
     wide as its own image/video renders at the shared height below, so a
     screenshot that isn't quite the same aspect ratio as its neighbors
     never leaves letterboxed space around it. That letterboxing (against
     this element's own now-removed background fill) was the "gray
     background" showing through whenever a shot didn't perfectly match
     the old equal-division frame's proportions. */
  flex: none;
  height: 100%;
  overflow: hidden;
}
/* Specificity has to beat .work-row-hover img/video above (position:
   absolute; inset:0; width/height:100%; object-fit:cover) — meant for the
   single plain <img> a project with no "hoverImages" falls back to, but
   as a plain descendant selector it also matches these nested ladder
   frames. Without this override every frame would stretch to fill the
   whole card rather than sitting at its own natural size in the row. */
.work-row-hover--ladder .work-row-hover-frame img,
.work-row-hover--ladder .work-row-hover-frame video {
  position: static;
  inset: auto;
  height: 100%;
  width: auto;
  object-fit: contain;
  display: block;
}

.work-row-info {
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 340px;
}
.work-row-title {
  font-size: clamp(22px, 3.95vw, 30px);
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--c-f9f9f9);
}
.work-row-desc {
  font-size: 15px;
  line-height: 1.6;
  color: var(--c-f9-60);
}
.work-row-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

@media (prefers-reduced-motion: reduce) {
  .work-row-hover { transition: none; }
}

/* ── PROJECT CARD ────────────────────────────────────────────────── */
.project-card {
  display: flex;
  flex-direction: column;
  background: var(--c-1e);
  border-radius: 0;
  overflow: hidden;
  cursor: pointer;
  text-decoration: none;
  color: inherit;
  transition: transform 0.5s var(--ease-out);
  position: relative;
}
.project-card-image {
  width: 100%;
  /* Same real export frame as .work-row-media above (1440/790), not a
     generic 16/9 — same cropping problem, same fix. */
  aspect-ratio: 1440 / 790;
  background: #1a1a1a;
  overflow: hidden;
  position: relative;
}
.project-card-image img,
.project-card-image video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Same hover treatment as the home page's .work-row: back image dims,
   a smaller inset card (the same media) slides up from the bottom. */
.project-card-image::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
  transition: opacity 0.5s var(--ease-sharp);
}
.project-card:hover .project-card-image::after { opacity: 1; }

/* Hover-reveal card itself (.work-row-hover / .work-row-hover--ladder) is
   shared with the home page's .work-row grid — see render_project_hover()
   in includes/functions.php and the .work-row-hover* rules above, whose
   trigger selectors already include ".project-card:hover" for this reuse. */

.project-card-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.project-card-placeholder img {
  width: 50%;
  max-width: 200px;
  opacity: 0.15;
}

.project-card-body {
  padding: 24px 28px 28px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  flex: 1;
}

.project-card-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.project-card-cats {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

.project-card-year {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  text-transform: uppercase;
}

.project-card-title {
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -0.025em;
  color: var(--c-f9f9f9);
  line-height: 1.2;
}

.project-card-desc {
  font-size: 14px;
  line-height: 1.6;
  color: var(--c-f9-60);
}

.project-card-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--c-f9-60);
  margin-top: 8px;
  transition: color 0.2s, gap 0.25s var(--ease);
}
.project-card:hover .project-card-link {
  color: var(--c-f9f9f9);
  gap: 10px;
}

/* ── WHAT I DO ───────────────────────────────────────────────────── */
.section-services {
  /* position:relative anchors the pixel-transition wrapper; no overflow:hidden
     here — it would clip the stacking cards as they fan out sideways. */
  position: relative;
  background: var(--c-bg-lifted-5);
  /* Bottom padding trimmed from 260px — the pixel-dissolve exit was
     triggering well after the cards had visually settled, reading as a
     long dead scroll before anything happened. See .section-offerings'
     own top padding below: this section's exit was intentionally moved
     earlier while that one's entrance was pushed later, closing the empty
     gap between them without losing the dissolve's own reveal room. */
  padding: 200px var(--pad-x) 160px;
}

.services-inner {
  max-width: var(--max-w);
  margin: 0 auto;
}

/* Shared header for both stacking-card components (home "What I Do" and
   about "How I Work") — lives inside [data-stacking-cards-init] itself
   (not a sibling above it) so it's part of the same component on both
   pages, and stays in normal flow above the sticky card list below it. */
.stack-header {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding-bottom: 40px;
}

.stack-eyebrow {
  padding-top: 10vh;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  margin-bottom: 20px;
}

.stack-heading {
  font-size: clamp(32px, 7.4vw, 56px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.1;
  color: var(--c-f9f9f9);
}

/* Stacking sticky cards — each card is sticky, and js/interactions.js
   (initStackingCardsNoBounce) fans the [data-stacking-card-target] cards
   out via rotate/x/y as you scroll. */
.services-stack {
  padding-top: 60px;
}

.services-stack__list {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 140px;
  padding-bottom: 140px;
}

.services-stack__item {
  width: 100%;
  max-width: 380px;
  position: sticky;
  top: 220px;
}

/* Desktop-only pinned fan-out mode (js/interactions.js initHowIWorkPin) —
   toggled on by JS, tablet/mobile never get this class and keep the sticky-
   fan layout above. Cards start stacked centered and fly out one at a time
   into the fanned layout as the pinned section is scrolled through, ending
   with all three visible at once. No hardcoded transform here — GSAP sets
   xPercent/yPercent for the centering translate and composes it with the
   tweened x/y/rotate/scale into one transform. */
.services-stack__list.is-pinned-mode {
  position: relative;
  display: block;
  padding-bottom: 0;
}
.services-stack__list.is-pinned-mode .services-stack__item {
  position: absolute;
  top: 50%;
  left: 50%;
  margin: 0;
}

.service-card {
  aspect-ratio: 4 / 5;
  padding: 36px 32px;
  /* Opaque fill (not a shadow) — a translucent bg let cards show through
     each other once fanned out, making the overlapping text unreadable. */
  background: var(--c-lift-3);
  border-radius: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  box-shadow: 0 0 0 1px transparent;
  transition: box-shadow 0.3s var(--ease);
}
.services-stack__item:nth-child(2) .service-card { background: var(--c-lift-6); }
.services-stack__item:nth-child(3) .service-card { background: var(--c-lift-9); }

/* Whichever card has most recently landed in the sticky spot — toggled by
   the 'stacking-active-*' ScrollTrigger in initStackingCardsNoBounce. */
.service-card.is-active {
  box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
}

.service-card-end {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* Thinking Orbs canvas — sits in the flexible middle area of the card,
   between the number and the title/desc block. */
.service-orb {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Prevents an inline canvas baseline gap (per the resource's own CSS). */
[data-thinking-orb] {
  display: block;
}

.service-num {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  text-transform: uppercase;
}

.service-eyebrow {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-f9-60);
}

.service-title {
  font-size: 24px;
  font-weight: 600;
  letter-spacing: -0.025em;
  color: var(--c-f9f9f9);
  line-height: 1.2;
}

.service-desc {
  font-size: 15px;
  line-height: 1.15;
  color: var(--c-f9-60);
}

/* ── SERVICES (OFFERINGS) — horizontal scroll-jacked reveal ─────────
   Desktop/tablet: js/interactions.js (initOfferingsScroll) pins the
   section and translates .offerings-track one card per scroll step,
   toggling .is-active on the current card. Mobile instead reuses the
   shared stacking-card component (initStackingCardsNoBounce, same one
   "How I Work" uses) for a vertical trading-card pile — see the max-width
   override below, which also resets the JS-driven transform in case a
   resize crosses the breakpoint mid-scroll. */
.section-offerings {
  position: relative;
  background: var(--c-bg);
  /* Top padding gives the incoming pixel-dissolve (see .section-services
     above) room to finish, and keeps the eyebrow clear of the fixed nav
     (~90px tall) even when it's shown, without eating so much of the
     viewport that the pinned card below gets clipped at the bottom —
     the whole section pins for the horizontal scroll-jack (see
     initOfferingsScroll), so everything inside has to fit one viewport. */
  padding: 140px 0 160px;
  overflow: hidden;
}

.offerings-inner {
  max-width: var(--max-w);
  margin: 0 auto;
  padding: 0 var(--pad-x);
  /* Tightened from 64px — brings the card up closer under the heading so
     the whole pinned stack (header + card) has a better chance of fitting
     one viewport without the card's bottom running past the fold. */
  margin-bottom: 32px;
}

.offerings-header-text {
  max-width: 640px;
}

.offerings-eyebrow {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  margin-bottom: 20px;
}

.offerings-heading {
  font-size: clamp(28px, 5vw, 44px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.15;
  color: var(--c-f9f9f9);
}

.offerings-viewport {
  overflow: hidden;
}
.offerings-track {
  display: flex;
  gap: 24px;
  width: max-content;
  padding: 0 var(--pad-x);
  will-change: transform;
}

/* Invisible to the desktop/tablet flex layout — .offering-card is treated
   as a direct .offerings-track child there. Only becomes a real box (for
   the mobile sticky pile) in the max-width: 809px override below. */
.offering-item { display: contents; }

.offering-card {
  width: 460px;
  flex-shrink: 0;
  /* Flatter than 4/5 — the section pins for the horizontal scroll-jack
     (initOfferingsScroll), so a taller card risked its own description
     text running past the bottom of the viewport with no way to scroll
     further to reveal it while pinned. */
  aspect-ratio: 4 / 3.8;
  padding: 40px 32px 36px;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 16px;
  background: var(--c-lift-3);
  opacity: 0.55;
  position: relative;
  overflow: hidden;
  /* transition-delay below is positionally mapped to this list (opacity,
     background, filter, transform) — only "transform" ever gets a
     non-zero delay (the entrance stagger, desktop/tablet only, below), so
     the is-active/inactive opacity+background+filter crossfade always
     stays instant-on-hover, never inheriting that stagger. */
  transition: opacity 0.4s var(--ease), background 0.4s var(--ease), filter 0.4s var(--ease), transform 0.7s var(--ease-out);
}
/* Targets .offering-item (the actual sibling now that each card sits inside
   a wrapper), not .offering-card itself — nth-child counts DOM siblings,
   and .offering-card's only sibling inside .offering-item is its own canvas. */
.offering-item:nth-child(3n+2) .offering-card { background: var(--c-lift-6); }
.offering-item:nth-child(3n) .offering-card { background: var(--c-lift-9); }

/* Entrance — desktop/tablet only (mobile's cards already get their own
   transform from the shared stacking-card component, see
   data-stacking-card-target in index.php; a competing CSS transform here
   would just be overridden by GSAP's inline style anyway, but scoping it
   out avoids a flash of the wrong position before JS runs). Cards slide in
   from the right, staggered by --card-i (set inline per card in index.php
   as reverse index, so the rightmost card enters first and the sweep reads
   right-to-left across the row). 1.1s/card plus a 0.4s base delay before
   the first card even starts, both pushed later than before so the whole
   sweep reads slower and more deliberate. */
@media (min-width: 810px) {
  .offering-card {
    transition-delay: 0s, 0s, 0s, calc(0.4s + var(--card-i, 0) * 1.1s);
  }
  .offerings-viewport[data-reveal-after-pixels] .offering-card {
    transform: translateX(50%);
  }
  .offerings-viewport[data-reveal-after-pixels].is-visible .offering-card {
    transform: translateX(0);
  }
}
.offering-card.is-active {
  opacity: 1;
  background: var(--c-lift-9);
  /* filter, not box-shadow — .offering-card clips with overflow:hidden
     (see that rule), which would also clip a box-shadow at the same
     element; filter isn't affected by the overflow clip on its own
     element. */
  filter: drop-shadow(0 24px 48px rgba(0, 0, 0, 0.35));
}

.offering-heading {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.offering-title {
  font-size: 26px;
  font-weight: 700;
  letter-spacing: -0.025em;
  color: var(--c-f9f9f9);
  line-height: 1.15;
}

.offering-subtitle {
  font-size: 14px;
  font-weight: 500;
  color: var(--c-f9-60);
  line-height: 1.3;
}

/* The card's hero mark now, not a background flourish — flex:1 lets it
   claim whatever vertical space is left between the heading and the
   description (varies per card/language) and centers the canvas within
   that space, so the mark always sits visually mid-card regardless of
   how many lines the text above/below wraps to. Fixed 120x120 on the
   canvas itself (not this wrapper) keeps js/offerings-canvas.js's own
   getBoundingClientRect-based scale factor a constant 0.75 (120/160, the
   pixel values below were originally tuned at 160) — a separate
   SQUARE_SIZE_BOOST constant in that file counteracts the shrink for the
   square marks specifically, so a smaller mark still reads as bold, not
   just a scaled-down version of the old one. Full opacity now that it's
   the focal point rather than something to keep out of the text's way. */
.offering-canvas-wrap {
  flex: 1;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.offering-canvas-wrap canvas {
  display: block;
  width: 120px;
  height: 120px;
}

.offering-desc {
  font-size: 14px;
  line-height: 1.5;
  color: var(--c-f9-60);
  max-width: 320px;
}

@media (max-width: 809px) {
  .section-offerings {
    padding: 100px 0 120px;
    /* Base overflow:hidden (needed on desktop/tablet to clip the horizontal
       scroll-jack track) would also clip the sticky pile's stacking offsets
       and cap position:sticky to this box instead of the viewport — same
       reasoning as .section-services never using overflow:hidden. */
    overflow: visible;
  }
  .offerings-viewport { overflow: visible; }
  .offerings-track {
    flex-direction: column;
    width: auto;
    gap: 64px;
    transform: none !important;
  }
  .offering-item {
    display: block;
    position: sticky;
    top: 100px;
    max-width: 380px;
    margin: 0 auto;
  }
  /* Base .offering-card opacity:0.55 is for dimming inactive cards in the
     desktop horizontal track, where only one card is ever in front. On the
     mobile vertical pile every card is stacked directly on top of the next,
     so that same dimming let lower cards' text bleed through the ones
     above them instead of reading as a solid stack of opaque cards. */
  .offering-card { width: 100%; opacity: 1; }
  /* Base rule's aspect-ratio:4/3.8 + overflow:hidden exist for the desktop
     scroll-jack (a taller card there risked description text running past
     the bottom of the pinned viewport) — on the mobile vertical pile that
     same fixed ratio instead clipped any card whose text didn't fit,
     hiding part of it. Height auto (content-driven) + visible overflow
     here, plus tighter 24px side padding, and .offering-desc's own
     max-width:320px dropped so the text runs the full card width instead
     of sitting narrower than it needs to. */
  .offering-card {
    aspect-ratio: auto;
    overflow: visible;
    padding: 32px 24px;
  }
  .offering-desc { max-width: none; }
}

/* ── PIXELATED SCROLL TRANSITION ─────────────────────────────────── */
/* color is set per-instance inline (matching whichever section precedes it,
   so the pixels dissolve FROM that color INTO this section, instead of
   matching their own section's bg and reading as invisible). */
.pixelated-scroll-transition {
  z-index: 10;
  pointer-events: none;
  position: absolute;
  inset: auto 0% 0%;
}
[data-pixelated-scroll-transition][data-mode="reveal"] {
  inset: 0% 0% auto;
}

.pixelated-scroll-transition__panel {
  display: flex;
  flex-direction: row;
  width: 100%;
}
.pixelated-scroll-transition__col {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: stretch;
  flex: 1 1 0%;
  min-width: 0;
}
.pixelated-scroll-transition__pixel {
  aspect-ratio: 1;
  width: 100%;
  background-color: currentColor;
  backface-visibility: hidden;
  will-change: opacity;
}

/* ── WHAT I BELIEVE ──────────────────────────────────────────────── */
.section-beliefs {
  background: var(--c-bg);
  padding: 0 var(--pad-x) 160px;
  overflow: hidden;
}

.beliefs-inner {
  max-width: var(--max-w);
  margin: 0 auto;
}

.beliefs-header {
  padding: 40px 0 0;
  margin-bottom: 36px;
}

.beliefs-heading {
  font-size: clamp(28px, 5vw, 38px);
  font-weight: 700;
  letter-spacing: -0.03em;
  color: var(--c-f9f9f9);
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 150px;
  padding-top: 100px;
}

.stat-item {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.stat-value {
  font-size: clamp(64px, 8vw, 120px);
  font-weight: 700;
  letter-spacing: -0.05em;
  line-height: 1;
  color: var(--c-f9f9f9);
}

.stat-label {
  font-size: 18px;
  font-weight: 500;
  color: var(--c-f9f9f9);
  letter-spacing: -0.02em;
}

.stat-desc {
  font-size: 14px;
  line-height: 1.65;
  color: var(--c-f9-60);
  padding-top: 20px;
  border-top: 1px solid var(--c-f9-10);
}

/* ── CTA SECTION (home) ──────────────────────────────────────────── */
.section-cta {
  background: var(--c-bg);
  padding: 0 var(--pad-x) 120px;
}

.cta-inner {
  max-width: var(--max-w);
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 30px;
  padding: 120px 0;
  border-top: 1px solid var(--c-f9-10);
}

.cta-eyebrow {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.cta-heading {
  /* A closing section heading, not the page's primary one — previously
     capped at 90px, which made it render larger than .hero-headline (the
     actual H1) on wide viewports. Brought down to the same tier as the
     other major section headings (passion/recognition/history-heading). */
  font-size: clamp(28px, 7.4vw, 56px);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 1.2;
  color: var(--c-f9f9f9);
}

.cta-desc {
  font-size: 16px;
  line-height: 1.65;
  color: var(--c-f9-60);
  max-width: 560px;
}

/* ═══════════════════════════════════════════════════════════════════
   FOOTER
   ═══════════════════════════════════════════════════════════════════ */
.footer-parallax-wrap {
  position: relative;
  overflow: hidden;
}

.footer {
  background: var(--c-footer-bg);
  padding: 60px var(--pad-x);
  position: relative;
}

/* Fades in as the footer scrolls into view, driven by initFooterParallax()
   in js/interactions.js — see data-footer-parallax-dark. */
.footer-parallax-dark {
  opacity: 0;
  pointer-events: none;
  background: var(--c-bg);
  position: absolute;
  inset: 0;
}

.footer-inner {
  max-width: var(--max-w);
  margin: 0 auto;
  position: relative;
}

/* 3-column grid, not flex space-between — same reasoning as
   .footer-bottom-row's own grid below: it's the only way to keep the nav
   links at a true page-center regardless of how wide the logo or the
   social/location block on either side happen to be. */
.footer-top {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: flex-start;
  gap: 24px;
}
.footer-logo {
  justify-self: start;
}
.footer-nav {
  justify-self: center;
}
.footer-socials {
  justify-self: end;
  display: flex;
  gap: 20px;
}

.footer-logo img {
  width: 48px;
  height: auto;
}

.footer-nav {
  display: flex;
  gap: 32px;
}

.footer-link {
  font-size: 14px;
  color: var(--c-f9-60);
  transition: color 0.2s;
}
.footer-link:hover { color: var(--c-f9f9f9); }

/* Same underline treatment as .footer-privacy-link below, scoped to the
   footer's own social links (LinkedIn/Instagram/X) — not a sitewide
   .footer-link default, since the nav links (Works/About/Contact) and Lab
   don't use it. */
.footer-social-link {
  text-decoration: underline;
  text-decoration-color: rgba(249, 249, 249, 0.25);
  text-underline-offset: 3px;
  transition: color 0.2s, text-decoration-color 0.2s;
}
.footer-social-link:hover { text-decoration-color: var(--c-f9f9f9); }

/* Lab — shimmering gradient text instead of the old dim/brighten-on-hover
   treatment, using the same accent-gradient hues driving the gradient-wave-
   text reveal elsewhere (see wavePalette in initGradientWaveText,
   js/interactions.js) so it reads as part of the same visual language,
   even though this is a separate always-on CSS animation, not that JS
   scroll-driven effect. White at rest, gradient band sweeping over it —
   band widened (was a ~24%-of-text window, now ~50%) so more of the word
   is mid-color at once instead of a thin streak. The bloom (drop-shadow,
   keyframed alongside background-position on the same animation) peaks at
   50% to roughly line up with the band crossing the text, fading back out
   at both ends — an approximation, not a pixel-exact sync, since a pure
   CSS animation has no way to read the gradient's actual on-screen
   position each frame the way a JS-driven effect could. */
.footer-link--lab {
  position: relative;
  cursor: default;
  background-image: linear-gradient(100deg,
    var(--c-f9f9f9) 0%, var(--c-f9f9f9) 25%,
    #FBC085 38%, #80FFFE 50%, #B78CDE 62%,
    var(--c-f9f9f9) 75%, var(--c-f9f9f9) 100%);
  background-size: 280% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
@media (prefers-reduced-motion: no-preference) {
  .footer-link--lab { animation: footer-lab-shimmer 4s linear infinite; }
}
@keyframes footer-lab-shimmer {
  0%   { background-position: 220% 0; filter: drop-shadow(0 0 0 transparent); }
  50%  { filter: drop-shadow(0 0 7px rgba(128, 255, 254, 0.6)); }
  100% { background-position: -220% 0; filter: drop-shadow(0 0 0 transparent); }
}

/* "Work in progress" hover/focus tooltip — not a link (no href, see
   includes/footer.php: a <span> now, not an <a>), so the accessible name
   carrying the tooltip text lives in aria-label (also what
   data-i18n-aria-label swaps for Italian). Same small-popover treatment
   as .lang-dropdown-menu (dark surface, 1px border, small radius) rather
   than a native title="" tooltip, so it matches the site instead of the
   browser's own styling and shows on keyboard focus too, not just a
   mouse hover. */
.footer-link--lab::after {
  content: attr(aria-label);
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--c-f9f9f9);
  background: var(--c-1e);
  border: 1px solid var(--c-f9-10);
  border-radius: 6px;
  padding: 6px 10px;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.2s var(--ease-out), transform 0.2s var(--ease-out), visibility 0.2s;
}
.footer-link--lab:hover::after,
.footer-link--lab:focus-visible::after {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
}

.footer-location {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--c-f9-60);
  font-family: var(--font-mono);
  justify-self: end;
}

/* Copyright + Privacy / availability / location+time — a 3-column grid
   (not flex space-between) so "Available for work" sits at a true center
   regardless of how long the copyright text or the location/time are. */
.footer-bottom-row {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 16px;
  margin-top: 40px;
  padding-top: 24px;
  border-top: 1px solid var(--c-f9-10);
}

.footer-copy {
  font-size: 13px;
  color: var(--c-f9-60);
  font-family: var(--font-mono);
  text-transform: uppercase;
  justify-self: start;
}

.footer-status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--c-f9-60);
  justify-self: center;
  transition: color 0.2s;
}
/* Only the footer's own instance is a <button data-open-contact> — the
   bio page reuses this class on a plain, non-interactive <div>, which the
   global button reset's cursor:pointer never touches, so this hover only
   reads as clickable where it actually is. */
.footer-status:hover { color: var(--c-f9f9f9); }

.footer-status-dot {
  position: relative;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--c-available);
  flex: none;
}

.footer-status-dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--c-available);
  animation: footer-status-pulse 2s var(--ease-out) infinite;
}

@keyframes footer-status-pulse {
  0%   { transform: scale(1);   opacity: 0.7; }
  100% { transform: scale(2.8); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .footer-status-dot::after { animation: none; }
}

/* Inline within .footer-copy now (Name © year · VAT · Privacy), not its
   own grid column — color/font/size all inherit from .footer-copy, this
   just adds the underline. */
.footer-privacy-link {
  text-decoration: underline;
  text-decoration-color: rgba(249, 249, 249, 0.25);
  text-underline-offset: 3px;
  transition: color 0.2s, text-decoration-color 0.2s;
}
.footer-privacy-link:hover {
  color: var(--c-f9f9f9);
  text-decoration-color: var(--c-f9f9f9);
}

@media (max-width: 809px) {
  .footer { padding-top: 100px; }
  .footer-top {
    grid-template-columns: 1fr;
    justify-items: start;
  }
  .footer-logo,
  .footer-nav,
  .footer-socials {
    justify-self: start;
  }
  .footer-bottom-row {
    grid-template-columns: 1fr;
    justify-items: start;
    gap: 14px;
  }
  .footer-status,
  .footer-location {
    justify-self: start;
  }
}

/* ═══════════════════════════════════════════════════════════════════
   WORK LISTING PAGE
   ═══════════════════════════════════════════════════════════════════ */
.works-page {
  padding-top: 72px;
  min-height: 100svh;
  background: var(--c-bg);
}

.works-page-hero {
  padding: 100px var(--pad-x) 80px;
  max-width: var(--max-w);
  margin: 0 auto;
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 40px;
}

.works-page-headline {
  font-size: clamp(30px, 9.5vw, 72px);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 1.05;
  color: var(--c-f9f9f9);
  max-width: 700px;
}
/* The <br>-separated lines are meant to read as exactly two lines — nowrap
   (desktop/tablet only, same breakpoint as .about-hero-headline) stops
   "Every product is a response." from wrapping into an unintended 3rd
   line at this size. */
@media (min-width: 810px) {
  .works-page-headline { white-space: nowrap; }
}

.works-page-count {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--c-f9-60);
  text-transform: uppercase;
}

.works-page-grid {
  padding: 0 var(--pad-x) 120px;
  max-width: var(--max-w);
  margin: 0 auto;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 20px;
}

/* ── WORK TRACKER (internal, /work/status/) ────────────────────────── */
.work-status-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  padding: 0 var(--pad-x) 40px;
  max-width: var(--max-w);
  margin: 0 auto;
}

.work-status-table {
  padding: 0 var(--pad-x) 120px;
  max-width: var(--max-w);
  margin: 0 auto;
  display: flex;
  flex-direction: column;
}

.work-status-row {
  display: grid;
  grid-template-columns: minmax(160px, 1fr) minmax(140px, 1fr) 160px 160px;
  align-items: center;
  gap: 20px;
  padding: 16px 0;
  border-bottom: 1px solid var(--c-f9-10);
}

.work-status-row--head {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--c-f9-60);
}

.work-status-title {
  font-size: 15px;
  font-weight: 500;
  color: var(--c-f9f9f9);
}

.work-status-type {
  font-size: 13px;
  color: var(--c-f9-60);
}

.work-status-link {
  font-size: 13px;
  color: var(--c-f9f9f9);
  text-decoration: none;
  border-bottom: 1px solid var(--c-f9-10);
  transition: border-color 0.3s var(--ease);
}
.work-status-link:hover { border-color: var(--c-f9-60); }

.work-status-link--disabled {
  color: var(--c-f9-60);
  border-bottom: none;
}

@media (max-width: 809px) {
  .work-status-row { grid-template-columns: 1fr 1fr; row-gap: 8px; }
  .work-status-row--head { display: none; }
}

/* ═══════════════════════════════════════════════════════════════════
   CASE STUDY PAGE
   ═══════════════════════════════════════════════════════════════════ */
.case-page {
  padding-top: 72px;
  background: var(--c-bg);
  min-height: 100svh;
}

.case-hero {
  padding: 100px var(--pad-x) 80px;
  max-width: var(--case-max-w);
  margin: 0 auto;
}

.case-hero-meta {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 40px;
  flex-wrap: wrap;
}

.case-hero-title {
  font-size: clamp(48px, 14.5vw, 110px);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 1.0;
  color: var(--c-f9f9f9);
  margin-bottom: 60px;
}

/* Replaces what used to be .case-hero-row's own border-top — a plain CSS
   border painted instantly on load, before the title/meta/button reveal
   animations around it had even started. This is a real [data-reveal]
   element instead (see templates/case-study.php), so it animates in on
   its own timing like everything else, and can be given the last delay in
   the sequence. */
.case-hero-divider {
  height: 1px;
  background: var(--c-f9-10);
  margin-top: 30px;
}

.case-hero-row {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  padding-top: 30px;
  flex-wrap: wrap;
  gap: 20px;
}

.case-hero-info {
  display: flex;
  gap: 60px;
}

.case-info-item {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.case-info-label {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.case-info-value {
  font-size: 15px;
  font-weight: 500;
  color: var(--c-f9f9f9);
}

/* Main image */
.case-main-image {
  position: relative;
  width: 100%;
  max-width: var(--case-max-w);
  margin: 0 auto;
  overflow: hidden;
  background: var(--c-1e);
  /* See .work-row-media's own isolation:isolate for why this matters:
     without a stacking context of its own, .cover-shader-canvas's
     z-index:-1 would paint below THIS element's background instead of
     just below its sibling image. This element happens to also carry
     [data-reveal] (a transform, which creates one incidentally) so it
     isn't strictly load-bearing here — set explicitly anyway so this
     doesn't silently break if the reveal treatment is ever removed. */
  isolation: isolate;
}
.case-main-image img {
  width: 100%;
  aspect-ratio: 16 / 8;
  object-fit: cover;
  display: block;
}
/* Projects with a coverShader get their PNG nudged down within the frame
   so the gradient plane reads as a margin above it, instead of the gap
   being carved out of the shader itself (see .cover-shader-canvas
   below). margin-top, not padding-top: padding shrinks the img's own
   content box, which object-fit:cover then re-fits *into* — that was
   cropping/zooming the photo instead of just repositioning it. margin
   sits outside the box cover computes against, so the image keeps its
   exact original framing and is simply pushed down. .case-main-image's
   own overflow:hidden gives it a new block-formatting context, so this
   margin doesn't collapse through it — the container still grows to fit
   the shift instead of the offset being clipped away. */
.case-main-image:has(.cover-shader-canvas) img {
  margin-top: 48px;
}

/* ── BRANDED INTRO COVER (opt-in per project via "introCover" in
   data/projects/*.json — currently only Essilor Luxottica) ─────────────
   The project's own logo (its top-level "logo" field — the same SVG the
   homepage's hero logo marquee uses, see .hero-logos__item) fixed dead
   center over the hero photo — no drift, no scroll-linked motion, so it
   reads as the image's own label rather than something floating loose.
   aria-hidden: the project's own <h1> right above already announces the
   name; this is a purely visual echo of it. */
.case-main-image-logo {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 var(--pad-x);
  pointer-events: none;
}
.case-main-image-logo img {
  /* min(), not a vw-based clamp() — tied to this flex container's own
     (already pad-x-inset) width instead of the raw viewport, so the logo
     can never run past the frame no matter how the two interact at a
     given viewport size. max-width:100% is the second, unconditional
     safety net on top of that. */
  width: min(60%, 320px);
  max-width: 100%;
  height: auto;
  /* THE actual fix for the crop: .case-main-image img (above) matches
     this element too — it's a plain descendant selector, same specificity
     as this rule (one class + one tag each), so cascade applies BOTH
     rules' declarations, not just this one's. Its aspect-ratio:16/8 +
     object-fit:cover were forcing this SVG's own 6:1 shape into a 2:1 box
     and cropping it to cover that box — exactly the "logo is cropped,
     way too big" bug. Resetting both here is what actually stops it;
     width/height alone (the first attempt at this fix) never could. */
  aspect-ratio: auto;
  object-fit: contain;
  /* Guarantees pure white regardless of the source SVG's own fill —
     same defensive trick the homepage marquee uses (.hero-logos__item).
     Only right for the default path, where this is the *same* file the
     marquee shows and white is what's wanted; a project that deliberately
     supplies its own cover logo opts out just below. */
  filter: brightness(0) invert(1);
}

/* Set when a project fills in "introCover"."logo" (see
   templates/case-study.php). That field exists precisely for the case where
   the mark belonging on the cover is not the one belonging in the homepage
   hero strip — Mooney Go puts its full-colour lockup here while its "logo"
   stays the flat white version the strip needs — so forcing it white would
   throw away the only reason to override it. */
.case-main-image-logo--as-supplied img {
  filter: none;
}
.case-main-image--branded {
  background: var(--c-bg);
  /* The <img> that used to set this container's height is now a
     background-image layer, which has no intrinsic size — so the frame has
     to declare the ratio itself. Same 16/8 .case-main-image img uses, so
     the branded and plain covers stay the same shape. */
  aspect-ratio: 16 / 8;
}

/* Parallax cover layer (initCaseCoverParallax, js/interactions.js).
   Deliberately taller than the frame: the drift moves it vertically, and
   without the overhang the top or bottom edge would slide into view at the
   extremes. inset -12% top/bottom against a scrub range of +/-8% leaves
   margin at both ends. background-size: cover keeps the photo filling the
   frame at any viewport, exactly as object-fit: cover did on the <img>. */
.case-main-image-bg {
  position: absolute;
  inset: -12% 0;
  z-index: 0;
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  will-change: transform;
}
.case-main-image--branded::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: linear-gradient(0deg, rgba(9, 9, 9, 0.45), rgba(9, 9, 9, 0.15) 40%, rgba(9, 9, 9, 0.45));
  pointer-events: none;
}

/* ── PROJECT COVER SHADER (js/cover-shader.js) ────────────────────────
   Opt-in per project (data/projects/*.json → "coverShader") — the same
   GSAP-free WebGL gradient plane as the homepage hero, sitting behind a
   cover image that has real transparency, so the animated gradient shows
   through wherever the PNG doesn't paint (see initHeroWave's
   createGradientPlane in js/hero-wave.js for the shader itself). z-index
   -1 rather than DOM order: an absolutely-positioned, z-index:auto
   element actually paints ABOVE static in-flow content per the CSS
   stacking spec regardless of source order, so without this the canvas
   would cover the image instead of sitting behind it even though it
   comes first in the markup. Fills the entire container (inset:0) — the
   gradient is the background, so it should cover the whole canvas; any
   top margin belongs to the image sitting on top of it, not to the
   shader itself (see .case-main-image img's padding-top above). */
.cover-shader-canvas {
  position: absolute;
  inset: 0;
  /* Explicit width/height, not just inset:0 — canvas is a replaced
     element, and a replaced element's auto width/height under absolute
     positioning falls back to its own intrinsic size (a bare canvas
     defaults to 300×150) even with every inset side set, instead of
     stretching to fill the gap the way a plain div would. That's what
     left this rendering as a tiny 300×150 patch pinned to the
     container's top-left corner instead of filling it. */
  width: 100%;
  height: 100%;
  z-index: -1;
  display: block;
}

/* Case sections */
.case-section {
  padding: 100px var(--pad-x);
  max-width: var(--case-max-w);
  margin: 0 auto;
}

.case-section-border {
  border-top: 1px solid var(--c-f9-10);
}

.case-section-layout {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 80px;
  align-items: start;
}

.case-section-label {
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  color: var(--c-f9-60);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding-top: 8px;
  position: sticky;
  top: 100px;
  opacity: 0.7;
}

.case-section-content h2 {
  font-size: clamp(28px, 6.85vw, 52px);
  font-weight: 700;
  letter-spacing: -0.03em;
  color: var(--c-f9f9f9);
  line-height: 1.1;
  margin-bottom: 32px;
  /* Matches .case-section-content p's own max-width below — without this
     the heading was free to stretch across the whole content column
     (up to ~1080px at desktop) while the body copy stayed capped at
     680px, so the two never shared a rag and the heading read as
     needlessly wide/thin. Same fixed 680px (not a %) as the paragraph,
     so both wrap at the exact same measure regardless of viewport.
     text-wrap:balance on top of that: a bold 28–52px heading wrapping
     naturally within that measure still breaks wherever the browser's
     default greedy algorithm happens to run out of room on each line —
     often a short, lonely last line — which is what kept reading as "a
     different width" even once the cap matched. Balance instead
     distributes the heading's own text evenly across however many
     lines it takes, so its lines actually look like they share the
     paragraph's measure instead of just being capped by it. Body copy
     stays on the browser default (not set here): balance is only
     supported up to ~6 lines in most engines and is meant for headings,
     not flowing paragraph text. */
  max-width: 680px;
  text-wrap: balance;
}

.case-section-content p {
  font-size: 17px;
  line-height: 1.75;
  color: var(--c-f9-60);
  margin-bottom: 24px;
  max-width: 680px;
}
.case-section-content p:last-child { margin-bottom: 0; }

/* Design in action pillars */
.case-pillars {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 20px;
  margin-top: 40px;
}

.case-pillar {
  padding: 28px;
  background: var(--c-1e);
  border-radius: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.case-pillar-icon {
  margin-bottom: 4px;
}
.case-pillar-icon canvas {
  display: block;
  width: 48px;
  height: 48px;
}

.case-pillar-title {
  font-size: 17px;
  font-weight: 600;
  color: var(--c-f9f9f9);
  letter-spacing: -0.02em;
}

.case-pillar-desc {
  font-size: 14px;
  line-height: 1.6;
  color: var(--c-f9-60);
}

/* ── CASE OUTRO (closing contact banner) ─────────────────────────── */
/* Padding-top gives the last case-section real breathing room before this
   banner starts (also doubles as extra scroll runway for the heading's
   gradient-wave-onload play below the fold — see data-gradient-wave-onload
   in templates/case-study.php). */
.case-outro {
  padding: 20vh var(--pad-x) 120px;
}

/* Capped at --case-max-w (not full viewport width) so this reads as part
   of the same content column as the rest of the page, not an edge-to-edge
   banner — the lifted background lives on this inner box specifically so
   the page's own bg still shows as a margin on either side at wide
   viewports. */
.case-outro-inner {
  max-width: var(--case-max-w);
  margin: 0 auto;
  padding: 100px var(--pad-x);
  background: var(--c-lift-3);
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 40px;
}

.case-outro-eyebrow {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.case-outro-heading {
  font-size: clamp(28px, 7.4vw, 56px);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 1.15;
  color: var(--c-f9f9f9);
  max-width: 26ch;
  text-wrap: balance;
}

.case-outro-cta-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 24px;
  flex-wrap: wrap;
}

.case-outro-person {
  display: flex;
  align-items: center;
  gap: 14px;
}

.case-outro-avatar {
  display: block;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  flex-shrink: 0;
  overflow: hidden;
  background: var(--c-f9-10);
}
.case-outro-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.case-outro-contact-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  text-align: left;
}
.case-outro-contact-title {
  font-size: 15px;
  font-weight: 600;
  color: var(--c-f9f9f9);
}
.case-outro-contact-sub {
  font-size: 13px;
  color: var(--c-f9-60);
}

@media (max-width: 809px) {
  .case-outro-inner { padding: 80px var(--pad-x); }
  .case-outro-cta-row { flex-direction: column; gap: 28px; }
}

/* ── CASE VISUALS + PLACEHOLDERS ────────────────────────────────── */
.case-visual {
  padding: 0 var(--pad-x) 100px;
  max-width: var(--case-max-w);
  margin: 0 auto;
}

.case-visual--full img,
.case-visual--full .case-placeholder {
  width: 100%;
  aspect-ratio: 16 / 8;
}

/* Live prototype embed (case-visual type "prototype", see
   render_case_visuals in includes/functions.php). The iframe is a
   self-contained sub-app with its own stylesheets, so nothing here styles
   its insides — this only decides how big a box it gets. The prototype
   scales its own phone to fit whatever it is given (its fit() reads
   window.innerWidth/Height, which inside a frame is the frame), so the
   aspect-ratio below is the only control over how large the device
   renders. 4/3 leaves the 402x874pt phone room to sit at full scale on a
   desktop-width case study, with breathing room either side. */
.case-prototype-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 3;
  border-radius: 12px;
  overflow: hidden;
  /* Same lifted panel the placeholder boxes used, so the embed reads as a
     deliberate slot in the page rather than a hole punched through it. */
  background: var(--c-bg-lifted-5, var(--c-1e));
}

.case-prototype-frame iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

/* Below the tablet breakpoint the prototype's own layout switches to its
   mobile branch (a 44/56px inner padding instead of 40/80) and the phone
   shrinks hard against a landscape box. 1/2 is the ratio at which its
   width and height limits come out equal at this column width, so the
   device is as large as the frame can make it rather than being pinned
   small by leftover vertical space. */
@media (max-width: 809px) {
  .case-prototype-frame { aspect-ratio: 1 / 2; }
}

.case-visual--two-col,
.case-visual--three-col {
  display: grid;
  gap: 16px;
}

.case-visual--two-col   { grid-template-columns: 1fr 1fr; }
.case-visual--three-col { grid-template-columns: repeat(3, 1fr); }

.case-visual-item img,
.case-visual-item .case-placeholder {
  width: 100%;
  aspect-ratio: 4 / 3;
}

.case-visual--full img,
.case-visual-item img {
  display: block;
  object-fit: cover;
}

.case-placeholder {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  background: var(--c-1e);
  border: 1px dashed rgba(249, 249, 249, 0.1);
  padding: 40px 32px;
}

.case-placeholder-label {
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 1.7;
  color: var(--c-f9-60);
  text-align: center;
  max-width: 280px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.case-visual-caption {
  margin-top: 14px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.case-visual-caption--center { text-align: center; }

/* ── PHONE ROW VISUAL (render_case_visuals "phones" type) ────────────
   A row of real screenshots framed as phones, rising up from below with
   an explicit GSAP stagger by position as the row scrolls into view —
   see initCasePhonesReveal in js/interactions.js. Not CSS-transition-
   driven (a first pass using --phone-i in a transition-delay technically
   staggered but was too subtle against the generic, early-firing
   [data-reveal] observer to read as sequential — see that function's
   own comment). GSAP owns opacity/y entirely via inline styles here, so
   this file only sets layout, not the hidden/revealed state — no
   opacity:0 default, to avoid a flash of invisible content if JS is
   slow to run before gsap.set() establishes it itself. The phone shell
   is the same deliberate rounded-corner exception as the
   /prototype/allimb/ mockup (an iPhone has to read as a phone) but
   doesn't share its CSS: that one recreates a light-mode app UI with
   its own --proto-* palette, this one just frames a real screenshot, so
   there's nothing to share beyond the shape. */
.case-phones {
  display: flex;
  justify-content: center;
  align-items: flex-end;
  gap: 56px;
}

.case-phone {
  flex: 1;
  max-width: 280px;
}

.case-phone-shell {
  position: relative;
  aspect-ratio: 9 / 19.5;
  background: #0c0c0c;
  border-radius: 44px;
  padding: 10px;
  box-shadow: 0 0 0 1.5px var(--c-f9-10), 0 40px 80px -20px rgba(0, 0, 0, 0.6);
}

.case-phone-island {
  position: absolute;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  width: 32%;
  height: 20px;
  background: #000;
  border-radius: 16px;
  z-index: 1;
}

.case-phone-screen {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 34px;
}

@media (max-width: 809px) {
  .case-visual--two-col,
  .case-visual--three-col { grid-template-columns: 1fr; }
  .case-visual-item .case-placeholder { aspect-ratio: 3 / 2; }

  /* Escapes .case-visual's own left/right padding so the strip can
     scroll flush to the viewport edge (how the peeking next-card reads
     as "there's more, swipe" instead of stopping dead at a margin) —
     .case-phones re-applies that same padding itself, just on one side
     of each edge instead of both, which is what leaves the peek visible
     at the right instead of clipping it. */
  .case-visual--phones { padding-left: 0; padding-right: 0; }
  .case-phones {
    justify-content: flex-start;
    align-items: stretch;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    padding: 0 var(--pad-x);
  }
  .case-phones::-webkit-scrollbar { display: none; }
  .case-phone {
    flex: 0 0 76%;
    max-width: none;
    scroll-snap-align: start;
  }
}

/* ── VISUAL CAROUSEL (render_case_visuals "carousel" type) ────────────
   A horizontal scroll-snap strip — first item full-size, the next one
   peeking at the edge (cropped by the true viewport edge, not a padded
   content column) as a "there's more, swipe/drag" cue. Item count is
   whatever the project's own visuals array supplies (see
   includes/functions.php), so nothing here assumes a fixed number.
   Touch/trackpad get this for free from scroll-snap; initCaseCarousel
   in js/interactions.js adds mouse click-drag on top so a desktop
   mouse-only visitor isn't left with no way to move it.

   .case-visual--carousel breaks out of .case-visual's own centered,
   padded, case-max-w-capped box to run truly edge-to-edge — the
   standard 100vw + negative-margin breakout. body's overflow-x:hidden
   (css/style.css :root area) is what keeps this safe from ever
   introducing real horizontal scroll if a fraction of a pixel rounds
   the wrong way; it doesn't clip this on its own since 100vw is exactly
   the viewport's own width, not wider than it. */
.case-visual--carousel {
  max-width: 100vw;
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  padding: 0 0 100px;
}

.case-carousel {
  display: flex;
  /* No gap — the gutter is padding-right on .case-carousel-item instead,
     and that is deliberate. Smooothy's infinite wrap positions slide i at
     symmetricMod(current + i, n) * itemWidth, where itemWidth is item 0's
     own getBoundingClientRect().width. A flex `gap` is space *between*
     items and is not part of any item's box, so every wrap step would come
     up short by one gap and the loop would visibly drift. Folding the
     gutter into each item's own width keeps that math exact. Same class of
     bug as the retired home-page hover marquee, where `gap` on N items
     broke the "-50%" halfway point that per-item margin did not. */
  gap: 0;
  overflow: hidden;
  cursor: grab;
  user-select: none;
  /* Left edge only — the first image should start at the same inset as
     every other .case-visual's image (the centered, --case-max-w-capped
     column's own pad-x), not flush at the true viewport edge. min()
     reproduces that inset: when the viewport is narrower than
     --case-max-w, the centering margin is 0, so this collapses to plain
     pad-x, matching every other .case-visual exactly; above that width it
     adds the (container - case-max-w)/2 margin the centered column would
     have. % rather than vw — .case-carousel's own parent
     (.case-visual--carousel) is already exactly viewport-width via its
     100vw breakout, so % here resolves against that same true rendered
     width; vw does not account for a vertical scrollbar's width the way %
     of an already-laid-out parent does, which was throwing this off by
     that scrollbar's width. No matching right padding — that's what
     leaves the next slide running off the true viewport edge.
     Unlike the scroll-snap strip this replaced, nothing can scroll this
     inset back off: the track never scrolls, slides move by transform. */
  padding-left: calc((100% - min(100%, var(--case-max-w))) / 2 + var(--pad-x));
}
.case-carousel:active { cursor: grabbing; }

.case-carousel-item {
  flex: none;
  /* Percentage of the padded content box, so the visible slide keeps the
     same proportions it had as a scroll-snap item. */
  width: 88%;
  padding-right: 20px;
  position: relative;
}

.case-carousel-item-inner {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: clip;
}

/* The layer initCaseParallaxSlider translates. Frame-sized; the image
   inside it is what overflows. */
.case-carousel-item-visual {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  will-change: transform;
}

.case-carousel-item-img {
  position: absolute;
  /* Overscan, so the image still covers its frame at the extremes of the
     parallax drift instead of exposing a bare edge. Osmo's resource ships
     160%/-30%; this uses far less, paired with a matching
     data-parallax-amount of 4 (theirs is 10) in render_case_visuals().
     Reason is the content: horizontal parallax on a fixed-height frame
     can only be bought with extra image width, and the frame then shows
     only the middle (100 / overscan) of it — the left and right edges get
     clipped. Osmo's demo slides are photographs, which lose nothing that
     way. These are UI screenshots, where the clipped edges are a product
     logo and a nav. Stepping the value through a real render, 115% already
     cuts into Allimb's "allimb" wordmark and 160% takes about a third off
     each side; 108% is the last step where every slide stays whole. Raise
     this and data-parallax-amount in render_case_visuals() together for a
     stronger drift, and raise maxOffset in initCaseParallaxSlider to
     match — the clamp there is what keeps travel inside this slack, so a
     slide at the far end of its drift never pulls its own edge into
     frame. */
  --parallax-overscan: 108%;
  left: calc((100% - var(--parallax-overscan)) / 2);
  width: var(--parallax-overscan);
  max-width: none;
  height: 100%;
  object-fit: cover;
  /* Top, not the object-fit default center — these are desktop screenshots
     taller than 16:9 (a dashboard, a long schedule page), and centering
     the crop cuts the header/nav off the top as often as it cuts content
     off the bottom. Anchoring to the top keeps what a visitor would
     actually see first on landing on that real page. */
  object-position: top;
  display: block;
  -webkit-user-drag: none;
  user-select: none;
}

/* Placeholder slides have no composition to preserve and nothing to
   parallax, so they just fill their frame rather than taking the
   overscan geometry above. */
.case-carousel-item-visual .case-placeholder {
  width: 100%;
  height: 100%;
}

@media (max-width: 809px) {
  .case-carousel-item { width: 92%; }
}

/* ── BEFORE/AFTER COMPARISON (render_case_visuals "before-after" type) ──
   Two full-bleed images stacked — "after" underneath, "before" on top,
   clipped via clip-path to a percentage a draggable handle controls (see
   initCaseBeforeAfter in js/interactions.js). Dragging the handle right
   reveals more of "before" from the left; dragging left reveals more of
   "after" from the right. */
.case-baw {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  cursor: ew-resize;
  /* pan-y, not none — this is a purely horizontal drag, so vertical page
     scroll can pass straight through even when a touch starts on this
     element; only none would have blocked it outright. Same reasoning as
     .case-infinite-grid below. */
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
}
.case-baw-media {
  position: absolute;
  inset: 0;
}
.case-baw-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  -webkit-user-drag: none;
}
.case-baw-divider {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--c-f9f9f9);
  transform: translateX(-50%);
  z-index: 2;
  pointer-events: none;
}
.case-baw-handle-btn {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--c-f9f9f9);
  color: var(--c-bg);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
}
.case-baw-tag {
  position: absolute;
  top: 16px;
  z-index: 1;
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 6px 10px;
  background: rgba(9, 9, 9, 0.6);
  color: var(--c-f9f9f9);
  pointer-events: none;
}
.case-baw-tag--before { left: 16px; }
.case-baw-tag--after  { right: 16px; }

/* ── INFINITE DRAGGABLE COMPONENT GRID (render_case_visuals "infinite-grid"
   type) ──────────────────────────────────────────────────────────────
   Wheel/touch/drag-driven infinite tiling wall of design-system component
   screenshots — adapted from an Osmo Supply resource (data-infinite-grid-*
   attributes, tuning constants and positioning math kept as supplied; see
   initCaseInfiniteGrid in js/interactions.js). The one deliberate change
   from the source resource: object-fit: contain instead of cover on
   .case-infinite-grid__card-img, so a component screenshot always shows
   in full rather than being cropped to fill a uniform cell — this
   replaced an earlier bento-grid attempt where wide/narrow tiles ended up
   overlapping. border-radius is 0 throughout (the resource's own default
   is a slight rounding) to match this site's sharp-cornered look
   elsewhere (case-pillar, case-baw, etc.). */
.case-visual--infinite-grid {
  max-width: 100vw;
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  /* .case-visual's own `padding: 0 var(--pad-x)` still applied here, so the
     grid sat inset 40px a side inside its own full-bleed breakout — 1360px
     of grid in a 1440px viewport. Zeroed for a genuinely edge-to-edge
     gallery, which is what a full-screen-height grid wants. The spacing
     below is a margin rather than the usual bottom padding so this box ends
     exactly where the grid does — .case-infinite-grid-bg is inset:0 against
     it, and as padding it was painting the background 100px past the grid's
     own clipped area. */
  padding: 0;
  margin-bottom: 100px;
  position: relative;
}
/* Height pinned to .case-infinite-grid's own 100svh rather than inset:0
   against the wrapper — the wrapper also contains the caption below the
   grid, so inset:0 painted the gradient behind that too. Keep this in sync
   with .case-infinite-grid's height if that ever changes. */
.case-infinite-grid-bg {
  position: absolute;
  inset: 0 0 auto 0;
  height: 100svh;
  z-index: 0;
}
.case-infinite-grid-bg img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  opacity: 0.5;
}
.case-infinite-grid {
  /* pan-y, not none — belt-and-suspenders alongside initCaseInfiniteGrid
     no longer listening for "touch" at all (see js/interactions.js):
     even if that ever changes, this guarantees a touch gesture here can
     still scroll the page vertically instead of being swallowed. */
  touch-action: pan-y;
  width: 100%;
  /* Full screen, matching the resource's own 100svh — svh rather than vh so
     a mobile browser's collapsing address bar doesn't leave the grid taller
     than the visible viewport. Was min(78vh, 680px); Omar asked for the
     component gallery at full screen size. */
  height: 100svh;
  position: relative;
  z-index: 1;
  overflow: clip;
  cursor: grab;
  transition: opacity 0.5s ease;
  opacity: 1;
}
.case-infinite-grid[data-infinite-grid-status="loading"] { opacity: 0; }
.case-infinite-grid[data-infinite-grid-status="dragging"] { cursor: grabbing; }
.case-infinite-grid__collection {
  will-change: transform;
  width: 100%;
  height: 100%;
  position: absolute;
}
.case-infinite-grid__list {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}
/* Cell geometry. The Osmo grid's wrap math is a uniform lattice — slide i
   sits at column * itemW / row * itemH and wraps modulo the total — so
   every cell has to be exactly the same size. What varies is the card
   inside it: each component is rendered at its own natural pixel size and
   centered, rather than being scaled to fill a square. The cell is sized
   to the largest asset in the set (720x483) plus a 28px gutter, so nothing ever
   needs to shrink and no card can grow into its neighbour. */
.case-infinite-grid__item {
  width: 748px;
  height: 512px;
  will-change: transform;
  backface-visibility: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 0;
  position: absolute;
  top: 0;
  left: 0;
}

/* Content-sized, not 100%/100% of the cell — this is what keeps a mobile
   component (267x~470, portrait) and a desktop one (~700x~300, landscape)
   each reading at their true proportions instead of both being fitted into
   one square. No background fill: with cards sized to their own art there
   is nothing left to letterbox, and a fill would just draw a box around
   every screenshot. */
.case-infinite-grid__card {
  -webkit-user-select: none;
  user-select: none;
  will-change: transform;
  backface-visibility: hidden;
  position: relative;
  width: auto;
  height: auto;
  /* Ceiling only, never a resize of the current set: every component is
     already smaller than the cell, so these only bite if a much larger
     asset is added later — in which case it scales down to fit rather
     than overlapping its neighbours, which is the failure mode Omar
     rejected in the earlier bento-grid attempt. */
  max-width: 100%;
  max-height: 100%;
}

/* position: static and auto sizing, so the image lays out at its intrinsic
   size — "real size", 1 image pixel to 1 CSS pixel. The resource's own
   absolutely-positioned, 100%-of-card, object-fit rule is what forced every
   component to a uniform square before. */
.case-infinite-grid__card-img {
  pointer-events: none;
  position: static;
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
}

.case-infinite-grid-caption {
  position: relative;
  z-index: 1;
  margin-top: 24px;
}

@media (max-width: 809px) {
  .case-baw-tag { font-size: 10px; padding: 5px 8px; }
  .case-infinite-grid { height: 100svh; }
  /* Real size is not possible here — the widest component (720px) is wider
     than the viewport — so the cell shrinks and .case-infinite-grid__card's
     max-width/max-height scale each component down proportionally. Aspect
     ratios, and so the mobile/desktop distinction, are preserved. */
  .case-infinite-grid__item { width: 88vw; height: 62vh; }
}

/* ── CASE STUDY DOCK ROW ──────────────────────────────────────────────
   Bottom-center fixed row holding the section anchor dock and the
   back-to-top button as one flex group, not two independently-positioned
   elements. Centering via left:50%/translateX(-50%) on a flex container
   is what makes the pair "recenter" for free when back-to-top's own width
   transitions in later in the scroll — see initBackToTop's comment in
   js/interactions.js. */
.case-dock-row {
  position: fixed;
  left: 50%;
  bottom: 32px;
  transform: translateX(-50%);
  z-index: 100;
  display: flex;
  align-items: center;
}

@media (max-width: 1199px) {
  .case-dock-row { display: none; }
}

/* ── SECTION ANCHOR DOCK (Osmo pattern, dark-theme tokens) ────────────
   .case-toc itself is the element initCaseToc's GSAP shows/hides
   (yPercent + autoAlpha) — position:relative only, the fixed centering
   lives one level up on .case-dock-row. The opacity:0 default below is
   just a pre-hydration safety net; GSAP's inline styles take over the
   instant initCaseToc runs. See initCaseToc in js/interactions.js. */
.case-toc {
  position: relative;
  width: fit-content;
  opacity: 0;
}

.case-toc__pill {
  position: relative;
  color: var(--c-f9-60);
  background: var(--c-f9-10);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border-radius: 0;
  overflow: hidden;
}

.case-toc__toggle {
  appearance: none;
  background: none;
  border: none;
  color: inherit;
  white-space: nowrap;
  height: 44px;
  padding: 0 1.5em;
  display: flex;
  align-items: center;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.02em;
  cursor: pointer;
}

.case-toc__label-wrap {
  height: 1.4em;
  display: block;
  position: relative;
  overflow: hidden;
}

.case-toc__label {
  line-height: 1.4;
  display: block;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.case-toc__link-num {
  color: var(--c-f9-60);
  opacity: 0.7;
  margin-right: 0.6em;
  font-size: 0.85em;
  display: inline-block;
  position: relative;
  bottom: 0.05em;
}
[data-section-dock-link][data-active] .case-toc__link-num {
  color: var(--c-bg);
  opacity: 0.6;
}

.case-toc__list {
  opacity: 0;
  visibility: hidden;
  width: max-content;
  padding: 6px;
  position: absolute;
  top: 0;
  left: 0;
}

.case-toc__indicator {
  z-index: 1;
  pointer-events: none;
  background: var(--c-f9f9f9);
  border-radius: 0;
  position: absolute;
  top: 0;
  left: 0;
}

.case-toc__items {
  flex-flow: column;
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
}

.case-toc__link {
  z-index: 2;
  position: relative;
  display: block;
  white-space: nowrap;
  color: inherit;
  border-radius: 0;
  padding: 0.5em 1em;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  text-decoration: none;
  transition: color 0.3s var(--ease-out);
}
.case-toc__link[data-active] {
  color: var(--c-bg);
}

@media (prefers-reduced-motion: reduce) {
  .case-toc__link { transition: none; }
}

/* ── BACK TO TOP ──────────────────────────────────────────────────────
   Icon-only, riding in .case-dock-row to the right of the anchor dock.
   Width/margin (not just opacity) transition on .is-visible so the flex
   row's total footprint changes gradually — combined with the row's own
   translateX(-50%) centering, that's what makes the group visibly
   recenter as this button grows in, instead of snapping. */
.back-to-top {
  position: relative;
  flex: none;
  width: 0;
  height: 44px;
  margin-left: 0;
  padding: 0;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--c-f9-10);
  border: none;
  border-radius: 0;
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  color: var(--c-f9-60);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: width 0.5s var(--ease-out), margin-left 0.5s var(--ease-out), opacity 0.4s var(--ease-out), color 0.2s var(--ease-out);
}

.back-to-top.is-visible {
  width: 44px;
  margin-left: 10px;
  opacity: 1;
  pointer-events: auto;
}

.back-to-top:hover {
  color: var(--c-f9f9f9);
}

.back-to-top-icon {
  flex: none;
  animation: back-to-top-bounce 1.8s var(--ease-out) infinite;
}

@keyframes back-to-top-bounce {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-3px); }
}

@media (prefers-reduced-motion: reduce) {
  .back-to-top { transition: none; }
  .back-to-top-icon { animation: none; }
}

/* ── NEXT PROJECT TEASER (floating, appears mid-scroll) ─────────────── */
.case-teaser {
  position: fixed;
  z-index: 56;
  bottom: 24px;
  right: 24px;
  width: 340px;
  max-width: calc(100vw - 48px);
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 14px;
  background: var(--c-1e);
  border: 1px solid var(--c-f9-10);
  border-radius: 0;
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  text-decoration: none;
  color: inherit;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  /* Slides in from fully outside the viewport on the right, not just a
     short local nudge — 120% of the card's own width clears its 24px
     right offset with room to spare regardless of viewport size. */
  transform: translateX(120%);
  transition: opacity 0.4s var(--ease-out), transform 0.5s var(--ease-out), visibility 0.5s;
}

.case-teaser.is-visible {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: translateX(0);
}

.case-teaser-thumb {
  width: 72px;
  height: 72px;
  border-radius: 0;
  overflow: hidden;
  flex-shrink: 0;
  background: var(--c-bg);
}
.case-teaser-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.case-teaser-body {
  flex: 1;
  min-width: 0;
}

.case-teaser-title {
  font-size: 15px;
  font-weight: 600;
  color: var(--c-f9f9f9);
  line-height: 1.3;
  letter-spacing: -0.01em;
  margin-bottom: 10px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.case-teaser-meta {
  display: flex;
  align-items: center;
  gap: 8px;
}

.case-teaser-dot {
  width: 8px;
  height: 8px;
  border-radius: 0;
  background: var(--c-f9f9f9);
  flex-shrink: 0;
}

.case-teaser-label {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--c-f9-60);
}

.case-teaser-arrow {
  margin-left: auto;
  color: var(--c-f9f9f9);
  flex-shrink: 0;
  transition: transform 0.3s var(--ease-out);
}
.case-teaser:hover .case-teaser-arrow { transform: translateX(4px); }

@media (prefers-reduced-motion: reduce) {
  .case-teaser { transition: none; }
  .case-teaser:hover .case-teaser-arrow { transform: none; }
}

@media (max-width: 809px) {
  .case-teaser {
    left: 16px;
    right: 16px;
    bottom: 16px;
    width: auto;
    max-width: none;
    transform: translateY(calc(100% + 32px));
  }
  .case-teaser.is-visible { transform: translateY(0); }
}

/* ═══════════════════════════════════════════════════════════════════
   ABOUT PAGE
   ═══════════════════════════════════════════════════════════════════ */
.about-page {
  padding-top: 72px;
  background: var(--c-bg);
}

.about-hero {
  position: relative;
  /* z-index:0 (not just position:relative) is what actually makes this a
     stacking context — without it, .about-hero-photo's z-index:-1 below
     escapes past this element entirely and gets compared against page-level
     ancestors instead, landing behind .about-page's own solid background
     (i.e. fully invisible) rather than just behind this hero's text. */
  z-index: 0;
  padding: 100px var(--pad-x) 120px;
  max-width: var(--max-w);
  margin: 0 auto;
}

.about-hero-photo {
  position: absolute;
  /* Centered against .about-hero's own box (top:50% + translateY(-50%))
     instead of a fixed top offset — that offset was tuned against the
     hero's height when the now-removed credential line made it taller;
     centering against the content's actual box means it stays visually
     centered as the content above/below it changes, instead of drifting
     off-center. */
  top: 50%;
  /* At least 15% of the hero's own width from the right edge, so the photo
     never sits flush against it on wide viewports — pad-x alone (a small
     fixed px value) stops being enough of a margin past a certain width. */
  right: max(var(--pad-x), 15%);
  width: clamp(220px, 24vw, 420px);
  /* Matches assets/images/avatar_photo.png's real 1170x1345 ratio instead
     of force-cropping it square — the portrait reads taller than wide. */
  aspect-ratio: 1170 / 1345;
  overflow: hidden;
  background: var(--c-1e);
  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
  /* translateX(140%) here is the pre-JS resting state, not just a visual
     detail — without it the photo paints in its final on-screen spot on
     first render, then snaps out to window.innerWidth the instant
     initAboutHeroPhotoIntro's gsap.fromTo() runs, producing a visible
     "pre-rendered, then yanked off-screen and animated back" flash. Once
     that GSAP call fires it overrides this via inline style (its own
     explicit `x` "from" value) regardless of this exact percentage — this
     only has to get the photo off-screen for the brief pre-JS window, not
     match the animation's real starting position. */
  transform: translateY(-50%) rotate(6deg) translateX(140%);
  /* Behind the hero copy (tag/headline/sub, all normal in-flow static
     elements) instead of on top of it — negative z-index paints before
     them within .about-hero's stacking context. Matters most on mobile,
     where the photo bleeds into the same vertical band as the headline. */
  z-index: -1;
}
.about-hero-photo-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Mobile: shown (previously hidden entirely) and deliberately bled off the
   right edge — the "photo hanging off-canvas" treatment. Safe against
   horizontal scroll because both html and body have overflow-x:hidden
   sitewide (see the html rule near the top of this file), which clips the
   off-screen portion instead of adding scroll width. */
@media (max-width: 809px) {
  .about-hero-photo {
    display: block;
    /* Near the very top of .about-hero's own box (padding-box, before its
       100px padding-top is "consumed" by the tag/headline below) — was
       150px, which at this width/aspect-ratio (195px wide, ~224px tall)
       reached down far enough to overlap the description text under the
       headline. Sits above/beside the "About Omar" tag instead now. */
    top: 24px;
    right: -30px;
    width: 195px;
    /* Fixed offset, not the desktop rule's centered translateY(-50%) —
       mobile deliberately overlaps a fixed spot in the headline band.
       translateX(140%) is the same pre-JS off-screen resting state as the
       desktop rule above — mobile needs its own copy since this replaces
       the whole transform shorthand rather than adding to it. */
    transform: rotate(6deg) translateX(140%);
  }
  .about-hero-headline {
    /* Narrower column so the wrapped lines sit clear of the photo instead
       of running underneath it — matches the 4-line mobile layout. */
    max-width: 68%;
  }
}

.about-hero-tag {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  margin-bottom: 40px;
  display: flex;
  align-items: center;
  gap: 10px;
  text-transform: uppercase;
}

.about-hero-headline {
  font-size: clamp(30px, 9.5vw, 72px);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 1.2;
  color: var(--c-f9f9f9);
  max-width: 900px;
  margin-bottom: 60px;
}
/* The <br>-separated phrases are meant to read as exactly two lines —
   nowrap (desktop/tablet only, where the 900px max-width comfortably
   fits either page's copy) stops the second line from wrapping again
   on its own at in-between viewport widths. */
@media (min-width: 810px) {
  .about-hero-headline { white-space: nowrap; }
}

/* Now a wrapper around one <p> per paragraph (about.hero.sub is an array,
   see about/index.php), each with its own staggered [data-reveal] instead
   of one block revealing at once. */
.about-hero-sub {
  max-width: 760px;
}
.about-hero-sub p {
  font-size: clamp(17px, 1.8vw, 22px);
  line-height: 1.6;
  color: var(--c-f9-60);
}
.about-hero-sub p + p {
  margin-top: 1em;
}

.about-hero-cta {
  margin-top: 32px;
}

/* Principles — replaces the old "How I Work" (section-passion) slot.
   Base rules below are also the complete mobile/no-JS/reduced-motion
   layout: a plain stacked list of cards. The pinned horizontal scroll-jack
   carousel (initPrinciplesCarousel, js/interactions.js) only takes over at
   desktop widths, and only once JS actually adds .principles-carousel-
   ready — see the min-width block further down. One background tone for
   every card, no per-card variation. */
.section-principles {
  position: relative;
  background: var(--c-bg);
  padding: 100px var(--pad-x) 120px;
}

.principles-header {
  max-width: var(--max-w);
  margin: 0 auto 60px;
  text-align: center;
}

.principles-eyebrow {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  margin-bottom: 12px;
}
.principles-heading {
  font-size: clamp(28px, 5.5vw, 44px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.1;
  color: var(--c-f9f9f9);
}
/* Manual 2-line break ("What guides every" / "decision") — mobile only,
   where the phrase otherwise wraps at an awkward point. Desktop reads fine
   as one line within the fixed carousel header bar. */
.principles-break-mobile { display: none; }
@media (max-width: 809px) {
  .principles-break-mobile { display: block; }
}

.principles-cards {
  display: flex;
  flex-direction: column;
  gap: 1px;
  background: var(--c-f9-10);
  border-top: 1px solid var(--c-f9-10);
  max-width: var(--max-w);
  margin: 0 auto;
  /* Depth for the cards' own rotateX/rotateY hover tilt (initPrincipleCardTilt
     in js/interactions.js) — perspective has to live on the parent, not the
     rotating element itself, or the 3D tilt renders flat. */
  perspective: 1200px;
}

/* Dark cards (var(--c-1e)), same tone the desktop carousel already used —
   mobile/fallback used to be white with dark-on-light text; now matches
   this page's usual light-on-dark treatment at every breakpoint instead of
   flipping. See the min-width block further down for the desktop overrides
   (now largely redundant with these base values, kept for the carousel-
   specific sizing they still carry). */
/* --- Card tone ------------------------------------------------------
   Every colour on a card derives from one stop of --gradient-accent, so
   retoning a card is a one-hex change and the four cards stay a family
   instead of four hand-picked palettes. The recipe (matched against the
   blue reference Omar specified for card 01, whose literal values were
   #144CCD / #6694FF / #2365FF over a #0A0909 -> #09101F ground):

     --pc-deep  = 80% tone + black   -> the saturated bottom glow
     --pc-lit   = 70% tone + white   -> the softer glow above it
     --pc-floor = 8%  tone + #0A0909 -> the tinted bottom of the ground
     --pc       = the tone itself    -> the thin top-edge highlight

   Mixed in oklab, not sRGB: darkening toward black in sRGB drains chroma,
   which turned the coral card brown while the blues held up fine. oklab
   keeps all four reading as the same colour, only darker or lighter.

   Card 01 sits on #2365FF rather than the palette's own #4C92FE because
   that is the value the recipe needs to land on the reference; 02-04 walk
   the ramp from there through indigo and lilac to coral. */
.principle-card {
  --pc: #2365FF;
  --pc-deep:  color-mix(in oklab, var(--pc) 80%, #000);
  --pc-lit:   color-mix(in oklab, var(--pc) 70%, #fff);
  --pc-floor: color-mix(in oklab, var(--pc) 8%, #0A0909);

  position: relative;
  overflow: hidden;
  background: linear-gradient(180deg, #0A0909 0%, var(--pc-floor) 100%);
  /* Three of the four inner shadows. Figma's inner-shadow Y is inverted
     relative to where the light lands: a negative Y pushes the shadow up,
     so the glow reads along the BOTTOM edge, and the +6 on the last one
     puts a thin catch of light along the top. The fourth (white, "plus
     lighter") needs a blend mode a box-shadow cannot carry, so it lives
     on ::after below. */
  box-shadow:
    inset 0 -80px 60px -30px var(--pc-deep),
    inset 0 -40px 30px  -8px color-mix(in srgb, var(--pc-lit) 50%, transparent),
    inset 0   6px  6px  -2px color-mix(in srgb, var(--pc) 15%, transparent);
  /* Was 40px flat. The extra top padding is the space the "01" used to
     occupy in flow — Omar kept the gap and moved the numeral itself down
     to the bottom-right corner (see .principle-num). */
  padding: 68px 40px 40px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.principle-card:nth-child(2) { --pc: #6160FB; }
.principle-card:nth-child(3) { --pc: #B78CDE; }
.principle-card:nth-child(4) { --pc: #FE8F6D; }

/* Inner shadow 4 of 4: white, "plus lighter" in Figma. Its own layer
   because mix-blend-mode applies to an element, not to one shadow in a
   box-shadow list. plus-lighter adds channels rather than compositing
   over, which is what keeps this reading as light on top of the blue
   below it instead of washing it grey. */
.principle-card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 -20px 20px -6px rgba(255, 255, 255, 0.4);
  mix-blend-mode: plus-lighter;
  pointer-events: none;
  z-index: 3;
}

/* Square-dot field. A <canvas> driven by initPrincipleDots
   (js/interactions.js), not a CSS background: the reference effect is each
   dot on a fixed grid randomly stepping between opacity levels on its own
   schedule, and CSS animations can only move or fade a whole layer. Two
   earlier CSS attempts are worth not repeating — overlaying grids of
   different pitch produced moiré rather than twinkle and stopped reading
   as a grid at all, and sweeping a band of light across one grid moves the
   light, not the dots.

   The mask stays in CSS and is the "gradient as alpha": solid at the lit
   bottom edge, gone by 55% up, so the field fades into the card instead of
   sitting on top of it. Masks apply to canvas elements the same as to any
   other, so nothing about it has to move into the drawing code.

   The canvas also carries the corner numeral, drawn as a frosted pane: a
   blurred copy of the dot field clipped to the glyph shapes, plus a 10%
   white fill. Same effect Figma's "Background blur" on a text layer gives,
   done in the one place a CSS transform on the card cannot disturb it. */
.principle-dots {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 2;
  opacity: 0.42;
  -webkit-mask-image: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0.65) 22%, transparent 55%);
  mask-image: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0.65) 22%, transparent 55%);
}

/* Reduced motion is handled in initPrincipleDots, which paints the field
   once at rest instead of animating it. */

/* Refined sweep — a direct evolution of the site's original shimmer
   technique (same diagonal-streak + mix-blend-mode:overlay approach still
   used on .btn-shimmer), just wider, slower, and richer: a bigger
   transparent-to-color transition band per stop (12% vs the original 5%),
   higher color-mix opacity (26% vs 22%), and a larger background-size so
   the sweep has more room to travel. Replaces an earlier "Aurora glow"
   attempt (rim-light blobs) that Omar tried and didn't like — this reuses
   an already-proven, already-on-brand pattern instead of a new one. Each
   card keeps its own duration/delay (unchanged from the original) so the
   four sweeps don't visibly sync up. */
.principle-card::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    100deg,
    transparent 30%,
    color-mix(in srgb, #FE8F6D 26%, transparent) 42%,
    color-mix(in srgb, #80FFFE 26%, transparent) 50%,
    color-mix(in srgb, #6160FB 26%, transparent) 58%,
    transparent 70%
  );
  background-size: 260% 100%;
  background-repeat: no-repeat;
  background-position: 160% 0;
  mix-blend-mode: overlay;
  animation: principle-shimmer 6.5s ease-in-out infinite;
  animation-delay: 1.5s;
  pointer-events: none;
}
.principle-card:nth-child(1)::before { animation-duration: 7.7s; animation-delay: 0.6s; }
.principle-card:nth-child(2)::before { animation-duration: 5.9s; animation-delay: 2.8s; }
.principle-card:nth-child(3)::before { animation-duration: 8.6s; animation-delay: 1.7s; }
.principle-card:nth-child(4)::before { animation-duration: 6.6s; animation-delay: 3.9s; }
@keyframes principle-shimmer {
  0%, 15%, 100% { background-position: 160% 0; }
  65%           { background-position: -60% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .principle-card::before { animation: none; }
}

/* The corner numeral has no element of its own any more — initPrincipleDots
   paints it into the dot canvas. It was a <span> masked to its own glyphs
   (an inline SVG <mask>) carrying a CSS backdrop-filter, which is the only
   way CSS can blur a backdrop within a text shape. That worked until
   initPrincipleCardTilt applied its 3D hover transform to the card: under
   it, the mask still clipped the span's own fill, but the backdrop-filter
   escaped the mask and blurred the element's whole box — which, since the
   span was inset:0, meant the entire card washed grey and the dot field
   blurred flat. Compositing the blur inside the canvas puts it beyond the
   reach of CSS stacking and transform quirks, and incidentally drops the
   SVG-mask dependency that needed Safari 15.4+. */

.principle-title,
.principle-desc {
  position: relative;
  z-index: 4;
}

.principle-title {
  font-size: 32px;
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.15;
  color: var(--c-f9f9f9);
}

.principle-desc {
  font-size: 15px;
  line-height: 1.6;
  color: var(--c-f9-60);
}

@media (max-width: 809px) {
  .principles-cards {
    gap: 16px;
    background: none;
    border-top: none;
  }
  .principle-card {
    /* The glow geometry is tuned for a tall card; without a floor here the
       card collapses to the height of its text and the bottom light covers
       the whole thing. */
    min-height: 420px;
    padding: 56px 28px 28px;
    border-radius: 12px;
  }
}

/* Desktop, JS-driven carousel mode only — .principles-carousel-ready is
   added by initPrinciplesCarousel right before it starts creating
   ScrollTriggers, and removed again (with every inline style it set) if a
   resize crosses back under 810px, so a mid-session breakpoint change
   can't strand GSAP's inline transforms on top of this fallback layout's
   CSS. Section pins directly (same pattern as initOfferingsScroll) —
   .principles-cards is the only thing that actually translates
   horizontally, so the header above it stays put on screen the whole
   time, centered. Only 4 cards, sized to all fit within the viewport at
   once (width:100% + justify-content:center, not width:max-content with
   huge side padding) — the pin exists only to bring them on screen as a
   group entering from the right, not to pan through more content than
   fits, so the min end of each card's width clamp is kept narrow enough
   that 4 + gaps still fit down to the 810px breakpoint itself. */
@media (min-width: 810px) {
  .section-principles.principles-carousel-ready {
    height: 100vh;
    padding: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  .principles-carousel-ready .principles-header {
    position: absolute;
    /* Was a flat 64px, which sat the heading right under the nav. Held in
       place while the cards travel past it (that is what the absolute
       position inside the pinned section buys), just lower down. */
    top: clamp(96px, 13vh, 160px);
    left: 0;
    right: 0;
    z-index: 10;
    margin-bottom: 0;
  }
  .principles-carousel-ready .principles-cards {
    display: flex;
    flex-direction: row;
    flex-wrap: nowrap;
    justify-content: center;
    /* stretch (not center) so every card matches the tallest one's height
       automatically, regardless of how much title/desc text each has —
       more robust than guessing a fixed/min height per card. */
    align-items: stretch;
    width: 100%;
    max-width: none;
    gap: 1.5vw;
    padding: 0;
    background: none;
    border-top: none;
    will-change: transform;
  }
  .principles-carousel-ready .principle-card {
    width: clamp(190px, 21vw, 380px);
    /* Taller than the old 340px: the numeral now occupies the bottom of
       the card, so the copy needs room to clear it. */
    min-height: clamp(400px, 32vw, 560px);
    flex: none;
    background: linear-gradient(180deg, #0A0909 0%, var(--pc-floor) 100%);
    border-radius: 1vw;
    padding: calc(2vw + 28px) 2vw 2vw;
    justify-content: flex-start;
    will-change: transform;
  }
  .principles-carousel-ready .principle-title {
    font-size: clamp(22px, 2.1vw, 32px);
    color: var(--c-f9f9f9);
  }
  .principles-carousel-ready .principle-desc {
    font-size: clamp(12px, 0.95vw, 14px);
    color: var(--c-f9-60);
  }
}

/* Beyond the work — same lifted background as Principles (one continuous
   band). Centered hero-style layout: a small label, then a large centered
   paragraph with three hoverable keywords (football/videogames/
   collecting). Hovering a keyword spawns a short burst of that keyword's
   own image near it, falling away as they exit — see initBeyondWords in
   js/interactions.js and .beyond-burst-img below. */
.section-beyond {
  position: relative;
  background: var(--c-bg);
  min-height: 90vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 32px;
  padding: 100px var(--pad-x);
  text-align: center;
  overflow: hidden;
}

.beyond-eyebrow {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.beyond-paragraph {
  font-size: clamp(24px, 3.4vw, 46px);
  /* Display sizes need far less leading than body copy — at 1.4 the lines
     read as separate statements rather than one paragraph. Tightened with
     matching negative tracking, which display type at this size carries
     comfortably and which keeps the line rhythm even. */
  line-height: 1.2;
  letter-spacing: -0.02em;
  color: var(--c-f9-80);
  max-width: 1100px;
}

.beyond-keyword {
  color: var(--c-f9f9f9);
  font-weight: 600;
  text-decoration: underline;
  text-decoration-thickness: 0.06em;
  text-underline-offset: 0.14em;
}

/* Tap feedback for touch devices (no hover to trigger the burst-image
   effect there) — toggled by initBeyondWords on click/tap. Same gradient
   sweep technique as .footer-link--lab, just capped at 2 iterations
   instead of looping forever, since this is a one-off tap response, not
   an ambient always-on effect. */
.beyond-keyword.is-shimmering {
  background-image: linear-gradient(100deg,
    var(--c-f9f9f9) 0%, var(--c-f9f9f9) 25%,
    #FBC085 38%, #80FFFE 50%, #B78CDE 62%,
    var(--c-f9f9f9) 75%, var(--c-f9f9f9) 100%);
  background-size: 280% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: footer-lab-shimmer 0.8s linear 2;
}

/* Manual break (about.beyond.segments, the {"break":true} entry) —
   desktop only, natural wrapping handles mobile's narrower column fine on
   its own. Currently unused by either dictionary; kept because the
   segment type is still supported by both renderers. */
.beyond-break-desktop { display: none; }
@media (min-width: 810px) {
  .beyond-break-desktop { display: block; }
}

/* Paragraph separator (about.beyond.segments, the {"paragraph":true}
   entry). An empty block-level span rather than a real second <p>: the
   whole passage is one [data-split="lines"] element, and splitting it
   across two elements would give the closing line its own independent
   scroll reveal instead of continuing the first one's stagger. Half a
   line of height is enough to read as a paragraph break at this size
   without opening a gap that reads as two separate statements. */
.beyond-para-break {
  display: block;
  height: 0.5em;
}

/* The closing thought (about.beyond.segments, an {"emphasis":true}
   entry). Full-strength text against the paragraph's --c-f9-80, at the
   same weight as .beyond-keyword but deliberately WITHOUT its underline —
   the underline is the site's cue that a word is hoverable, and this run
   is not. */
.beyond-emphasis {
  color: var(--c-f9f9f9);
  font-weight: 600;
}

/* Spawned by initBeyondWords on hover — absolutely positioned against
   .section-beyond (position:relative), x/y/rotation/opacity all driven by
   GSAP so they can drift and fall independent of normal document flow. */
.beyond-burst-img {
  position: absolute;
  top: 0;
  left: 0;
  width: 14vw;
  max-width: 220px;
  min-width: 140px;
  aspect-ratio: 1 / 1.25;
  object-fit: cover;
  pointer-events: none;
  z-index: 5;
}

@media (max-width: 809px) {
  .section-beyond { min-height: 0; padding: 80px var(--pad-x); }
}

/* Recognition */
.section-recognition {
  position: relative;
  padding: 0 var(--pad-x) 120px;
}

.recognition-inner {
  max-width: var(--max-w);
  margin: 0 auto;
  padding-top: 80px;
}

.recognition-header {
  margin-bottom: 60px;
}

.recognition-eyebrow {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 20px;
  text-transform: uppercase;
}
.recognition-heading {
  font-size: clamp(32px, 7.4vw, 56px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.1;
  color: var(--c-f9f9f9);
}
.recognition-count {
  font-family: var(--font-mono);
  font-size: clamp(14px, 2vw, 20px);
  font-weight: 500;
  color: var(--c-f9-60);
  margin-left: 12px;
}

.awards-list {
  display: flex;
  flex-direction: column;
}

.award-item-wrap { width: 100%; transition: opacity 0.3s var(--ease); }

.award-item {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  padding: 28px 0;
  border-bottom: 1px solid var(--c-f9-10);
  gap: 40px;
}

.award-title {
  font-size: 20px;
  font-weight: 600;
  color: var(--c-f9f9f9);
  letter-spacing: -0.02em;
  margin-bottom: 6px;
}

.award-project {
  font-size: 14px;
  color: var(--c-f9-60);
}

.award-year {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--c-f9-60);
  text-transform: uppercase;
}

/* ── AWARDS: CURSOR-FOLLOWING IMAGE PREVIEW ─────────────────────────
   Adapted from Osmo Supply's "Image Preview Cursor Follower" resource —
   same data-follower-* contract and GSAP clone/slide behavior, restyled
   to this site's tokens (square corners, dark theme) and driven by
   initPreviewFollower in js/interactions.js. */
.awards-follower {
  position: relative;
}

.award-visual {
  aspect-ratio: 1 / 1.25;
  width: 20em;
  display: none;
  position: absolute;
  overflow: hidden;
}
.award-visual img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.awards-follower-cursor [data-follower-visual] {
  display: block;
  width: 100%;
  height: 100%;
  z-index: 0;
}

.awards-follower-cursor {
  z-index: 100;
  aspect-ratio: 1 / 1.25;
  pointer-events: none;
  width: 20em;
  display: flex;
  justify-content: center;
  align-items: center;
  position: fixed;
  inset: 0% auto auto 0%;
  overflow: hidden;
}

.awards-follower-cursor-inner {
  z-index: 2;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
  position: relative;
  opacity: 0;
  transform: scale(0);
  transition: opacity 0.1s ease, transform 0.6s cubic-bezier(0.65, 0.1, 0, 1);
}

@media (hover: hover) and (min-width: 992px) {
  body:has([data-follower-collection]:hover) .awards-follower-cursor-inner {
    opacity: 1;
    transform: scale(1);
  }
  body:has(.award-item-wrap:hover) .award-item-wrap:not(:hover) {
    opacity: 0.5;
  }
}

@media (max-width: 991px) {
  .awards-follower-cursor { display: none; }
  .award-visual {
    display: block;
    position: relative;
    width: 100%;
    margin-bottom: 12px;
    order: -1;
  }
  .award-item { grid-template-columns: 1fr; }
}

/* Work History */
.section-history {
  padding: 0 var(--pad-x) 120px;
}

.history-inner {
  max-width: var(--max-w);
  margin: 0 auto;
  padding-top: 80px;
}

.history-header { margin-bottom: 60px; }

.history-eyebrow {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.04em;
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 20px;
  text-transform: uppercase;
}
.history-heading {
  font-size: clamp(32px, 7.4vw, 56px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.1;
  color: var(--c-f9f9f9);
}

.history-list {
  position: relative;
  display: flex;
  flex-direction: column;
}

/* Step-timeline progress line — .history-timeline-line's top/height are
   set directly by initStepByStepTimeline (measureLine in
   js/interactions.js), spanning from the first row's marker center to the
   last row's; left position (4px) is tuned to sit under the 10px marker
   dot's own center (marker sits at left:0, width:10px → center at 5px;
   a 2px line at left:4px centers on that same point). */
.history-timeline-line {
  position: absolute;
  left: 4px;
  width: 2px;
  background: var(--c-f9-10);
  pointer-events: none;
}
.history-timeline-fill {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* Vertical, not --gradient-accent's 90deg — this bar is 2px wide and
     runs the full height of the list, so the gradient needs to travel
     top-to-bottom to actually read as a spectrum instead of one flat hue. */
  background: var(--gradient-accent-vertical);
  pointer-events: none;
}

.history-item {
  position: relative;
  display: grid;
  grid-template-columns: 130px 1fr;
  gap: 64px;
  padding: 48px 0;
  border-bottom: 1px solid var(--c-f9-10);
  align-items: start;
  transition: opacity 0.3s var(--ease);
}
.history-item:first-child { padding-top: 0; }
.history-item:last-child { border-bottom: none; padding-bottom: 0; }

.history-item-period {
  position: relative;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  letter-spacing: 0.02em;
  padding-top: 6px;
  padding-left: 22px;
  text-transform: uppercase;
}

.history-timeline-marker {
  position: absolute;
  left: 0;
  top: 9px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  /* Clearly filled (not just a faint near-bg tint) even at rest, so an
     inactive row's dot still reads as a solid marker on the line rather
     than an empty ring. */
  background: var(--c-1e);
  border: 1px solid var(--c-f9-10);
  transition: background-color 0.3s var(--ease), border-color 0.3s var(--ease);
}
/* Marker on a row the line has already reached */
[data-step-timeline-item][data-status="active"] .history-timeline-marker {
  background: var(--c-f9f9f9);
  border-color: transparent;
}
/* Ring around the single leading/current row — box-shadow can't take a
   gradient, so this is a slightly oversized pseudo carved into a ring via
   mask instead of a solid color glow. */
[data-step-timeline-item][data-current] .history-timeline-marker::before {
  content: '';
  position: absolute;
  inset: -5px;
  border-radius: 50%;
  background: var(--c-f9f9f9);
  -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 6px), #000 calc(100% - 5px));
  mask: radial-gradient(closest-side, transparent calc(100% - 6px), #000 calc(100% - 5px));
  opacity: 0.55;
  pointer-events: none;
}
/* Radar-ping pulse on the still-current role (about/index.php marks
   jobs[0] with data-ongoing) — same mechanic as .footer-status-dot's
   "Available for work" indicator. Gated on data-status="active" so it only
   starts once the line has actually reached this row, matching the rest of
   the timeline's progressive reveal instead of pulsing before it's "on". */
[data-step-timeline-item][data-ongoing][data-status="active"] .history-timeline-marker::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--c-f9f9f9);
  animation: footer-status-pulse 2s var(--ease-out) infinite;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  [data-step-timeline-item][data-ongoing] .history-timeline-marker::after { animation: none; }
}

@media (max-width: 809px) {
  .history-timeline-line,
  .history-timeline-marker {
    display: none;
  }
  .history-item-period { padding-left: 0; }
}

/* Role always sits on its own line under the company name now, instead of
   sharing a row and only wrapping once it ran out of space — long pairs
   (e.g. "TBWA\Worldwide" + "GRAPHIC & UI/UX DESIGNER") read as cramped
   when they *just* fit on one line, not only when they overflow it. */
.history-item-head {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  margin-bottom: 16px;
}

.history-item-company {
  position: relative;
  display: inline-flex;
  align-items: center;
  font-size: clamp(24px, 4.75vw, 36px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.1;
  color: var(--c-f9f9f9);
}

/* Logo "stamp" — pops in beside the company name on hover, skipped for
   the oldest/last entry. Hidden entirely when no logo is configured (see
   ASSET.md for which companies still need one). */
.history-item-stamp {
  position: absolute;
  left: calc(100% + 20px);
  top: 50%;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--c-f9f9f9);
  opacity: 0;
  pointer-events: none;
  transform: translateY(-50%) scale(0.4) rotate(-10deg);
  transition: opacity 0.25s var(--ease), transform 0.35s var(--ease-sharp);
}
.history-item-company:hover .history-item-stamp {
  opacity: 1;
  transform: translateY(-50%) scale(1) rotate(-6deg);
}
.history-item-stamp img {
  width: 28px;
  height: 28px;
  object-fit: contain;
}

.history-item-role {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--c-f9-60);
}

.history-item-desc {
  font-size: 15px;
  line-height: 1.7;
  color: var(--c-f9-60);
  max-width: 860px;
}

/* ── HISTORY: CASCADING PROJECT-IMAGE TRAIL ─────────────────────────
   Adapted from Osmo Supply's "Rotating Image Trail" resource — same
   data-trail-* contract, rotation removed from the state transforms
   (cascade, not spin) and scoped per-row instead of full-viewport. See
   initHistoryTrail in js/interactions.js. */
.history-item-trail-collection {
  opacity: 0;
  pointer-events: none;
  position: absolute;
}
.history-item-trail-list {
  display: flex;
  flex-wrap: wrap;
  gap: 1em;
}
.history-item-trail-item {
  z-index: 20;
  pointer-events: none;
  user-select: none;
}
.history-item-trail-card {
  aspect-ratio: 3 / 4;
  width: 160px;
  position: relative;
  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
}
.history-item-trail-card img {
  object-fit: cover;
  width: 100%;
  height: 100%;
  display: block;
  position: absolute;
  top: 0;
  left: 0;
}
[data-trail-item="hidden"] {
  position: absolute;
  transform: translate(-50%, -50%) scale(0);
}
[data-trail-item="visible"] {
  position: absolute;
  transform: translate(-50%, -50%) scale(1);
  transition: transform 0.4s cubic-bezier(0.625, 0.05, 0, 1);
}
[data-trail-item="transition-out"] {
  position: absolute;
  transform: translate(-50%, -50%) scale(0);
  transition: transform 0.8s cubic-bezier(0.625, 0, 0.875, 0);
}

/* Work History — tilted image preview, one per row, swapped as you hover
   different [data-history-preview-src] rows (see initHistoryPreview in
   js/interactions.js). Sizing/shadow match the other Osmo-derived preview
   elements above (.awards-follower-cursor, .history-item-trail-card) for
   consistency. Fixed to the right side of the screen horizontally, but no
   longer pinned to a constant top:50% vertically — GSAP drives the actual
   y position (tracking whichever row is hovered, updated every frame while
   scrolling), which is also why rotation/scale live in JS now instead of a
   plain CSS transform: mixing the two would fight the tween on every
   frame. Only opacity stays a declarative CSS transition. */
.history-preview {
  position: fixed;
  top: 0;
  right: 6%;
  z-index: 50;
  width: 20em;
  aspect-ratio: 1 / 1.25;
  pointer-events: none;
  overflow: hidden;
  opacity: 0;
  box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4);
  transition: opacity 0.4s var(--ease);
}
.history-preview.is-visible {
  opacity: 1;
}
.history-preview-inner {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
}
.history-preview-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

@media (max-width: 991px) {
  .history-preview { display: none; }
}

/* About contact section */
.about-contact {
  padding: 120px var(--pad-x);
  max-width: var(--max-w);
  margin: 0 auto;
  border-top: 1px solid var(--c-f9-10);
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 30px;
}

/* ═══════════════════════════════════════════════════════════════════
   BIO PAGE (Instagram link-in-bio, mobile-first)
   ═══════════════════════════════════════════════════════════════════ */
.bio-page {
  background: var(--c-bg);
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  padding: 60px 24px;
}

.bio-avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: var(--c-1e);
  margin: 0 auto 20px;
  overflow: hidden;
  border: 2px solid var(--c-f9-10);
}

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

.bio-name {
  font-size: 20px;
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--c-f9f9f9);
  text-align: center;
  margin-bottom: 8px;
}

.bio-handle {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--c-f9-60);
  text-align: center;
  margin-bottom: 16px;
  text-transform: uppercase;
}

.bio-desc {
  font-size: 14px;
  line-height: 1.6;
  color: var(--c-f9-60);
  text-align: center;
  max-width: 300px;
  margin: 0 auto 40px;
}

.bio-links {
  width: 100%;
  max-width: 360px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.bio-link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 20px;
  background: var(--c-1e);
  border-radius: 0;
  font-size: 14px;
  font-weight: 500;
  color: var(--c-f9f9f9);
  transition: background 0.2s, transform 0.2s;
}
.bio-link:hover {
  background: #2a2a2a;
  transform: translateY(-2px);
}

.bio-link-label { flex: 1; text-align: center; }

.bio-link-icon {
  width: 20px;
  height: 20px;
  opacity: 0.4;
}

.bio-footer {
  margin-top: 60px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-f9-60);
  opacity: 0.4;
  text-align: center;
  text-transform: uppercase;
}

/* ═══════════════════════════════════════════════════════════════════
   404 PAGE
   ═══════════════════════════════════════════════════════════════════ */
.page-404 {
  min-height: 100svh;
  background: var(--c-bg);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 40px var(--pad-x);
  text-align: center;
  gap: 24px;
}

.err-number {
  font-size: clamp(120px, 20vw, 260px);
  font-weight: 800;
  letter-spacing: -0.06em;
  line-height: 1;
  color: var(--c-f9-10);
  position: relative;
  user-select: none;
}

.err-number span {
  background: var(--gradient-accent);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.err-emoji {
  font-size: 48px;
  line-height: 1;
}

.err-heading {
  font-size: clamp(22px, 5vw, 38px);
  font-weight: 700;
  letter-spacing: -0.03em;
  color: var(--c-f9f9f9);
}

.err-sub {
  font-size: 16px;
  color: var(--c-f9-60);
  max-width: 420px;
  line-height: 1.6;
}

/* ═══════════════════════════════════════════════════════════════════
   DRAG CAROUSEL
   ═══════════════════════════════════════════════════════════════════ */
.dragcarousel {
  height: 420px;
  display: flex;
  align-items: center;
  overflow: clip;
  background: var(--c-bg);
  position: relative;
  opacity: 0;
  transform: translateY(20px);
}

.dragcarousel__container {
  width: max-content;
  display: flex;
  align-items: center;
  gap: 15px;
  padding: 0 1vw 0 0;
  user-select: none;
  cursor: grab;
  will-change: transform;
}

.dragcarousel__container.is-dragging {
  cursor: grabbing;
}

.dragcarousel__card {
  height: 380px;
  width: max-content;
  flex-shrink: 0;
  overflow: hidden;
  transform-origin: 50% 100%;
  background: var(--c-dark);
}

.dragcarousel__card img,
.dragcarousel__card video {
  height: 380px;
  width: auto;
  max-width: none;
  display: block;
  pointer-events: none;
}

/* ═══════════════════════════════════════════════════════════════════
   RESPONSIVE
   ═══════════════════════════════════════════════════════════════════ */
@media (max-width: 1199px) {
  :root { --pad-x: 32px; }

  .hero-left { max-width: 100%; }

  .stats-grid { gap: 60px; }
}

@media (max-width: 809px) {
  :root { --pad-x: 20px; }

  .dragcarousel { height: 220px; }
  .dragcarousel__card { height: 180px; }
  .dragcarousel__card img,
  .dragcarousel__card video { height: 180px; }

  .works-page-grid { grid-template-columns: 1fr; }

  /* Mobile keeps the sticky pin (see data-stacking-cards-mobile-* in
     index.php / about/index.php) so cards still pile up like a dealt
     trading-card stack instead of flowing as flat vertical list items — just
     a shorter "top" offset and tighter inter-card scroll distance than
     desktop/tablet, to suit smaller viewports. Cards' sticky top must clear
     the pinned .stack-header's own rendered height (eyebrow + heading +
     padding) or they land underneath/through it instead of stopping below
     it — .stack-eyebrow's 10vh top padding is reduced to a fixed, smaller
     value here specifically so that height stays predictable across
     devices instead of scaling with viewport height. */
  .stack-eyebrow { padding-top: 32px; }
  .services-stack__item { max-width: 100%; top: 190px; }
  .services-stack__list { gap: 64px; }
  /* Fixed aspect-ratio:4/5 (set for the desktop/tablet fanned-card look)
     forced a height independent of content on mobile too, so a card with
     enough description text could run past its own bottom edge instead of
     the box growing to fit it. Height auto (content-driven) here instead,
     plus tighter 24px side padding so text runs closer to the card edges. */
  .service-card { aspect-ratio: auto; padding: 32px 24px; }

  .about-teaser-links { flex-direction: column; align-items: stretch; gap: 12px; }
  .about-teaser-links .btn { width: 100%; }

  .works-home-layout { grid-template-columns: 1fr; gap: 24px; }
  .works-home-label { position: static; }
  .works-home-cta--label { display: none; }
  /* grid-column:2/-1 (set in the base rule above, for desktop's 2-column
     layout) must be reset here — left alone, it forces the grid to
     fabricate an implicit second column even inside this single-column
     mobile layout, silently breaking .works-home-layout's whole collapse
     to 1fr. */
  .works-home-cta--bottom { display: inline-flex; grid-column: auto; }
  .work-row { grid-template-columns: 1fr; gap: 20px; padding: 32px 0; }
  .work-row-media { max-width: 100%; }

  .who-inner { grid-template-columns: 1fr; }

  .stats-grid { grid-template-columns: 1fr; gap: 0; padding-top: 40px; }
  .stat-item { padding: 40px 0; border-bottom: 1px solid var(--c-f9-10); }

  .case-section-layout { grid-template-columns: 1fr; gap: 20px; }
  .case-section-label { position: static; }
  .case-pillars { grid-template-columns: 1fr; }
  .case-hero-info { gap: 30px; flex-wrap: wrap; }

  .history-item { grid-template-columns: 1fr; gap: 12px; }
  /* Hover-only effects — no pointer to hover with on touch. */
  .history-item-stamp,
  .history-item-trail-collection { display: none; }

  .nav-right { display: none; }

  .works-page-hero { flex-direction: column; align-items: flex-start; }

  .cta-inner { padding: 80px 0; }

  .nav-toggle { display: flex; }
}

@media (max-width: 480px) {
  .hero-headline { letter-spacing: -0.03em; }
  .case-hero-title { letter-spacing: -0.03em; }
}

/* ═══════════════════════════════════════════════════════════════════
   CONTACT PAGE
   ═══════════════════════════════════════════════════════════════════ */
.contact-page {
  padding-top: 72px;
  background: var(--c-bg);
}

.contact-form-section {
  padding: 0 var(--pad-x) 140px;
  max-width: var(--max-w);
  margin: 0 auto;
}

.contact-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(240px, 320px);
  gap: 80px;
  padding-top: 60px;
  align-items: start;
}

.contact-form {
  display: flex;
  flex-direction: column;
}

/* Numbered, one-question-per-row layout — the question label reads like the
   input's own oversized placeholder, no boxed/filled input chrome. */
.contact-row {
  display: grid;
  grid-template-columns: 36px 1fr;
  gap: 20px;
  padding: 22px 0;
  border-bottom: 1px solid var(--c-f9-10);
}
.contact-row:first-child { padding-top: 0; }

.contact-row-num {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--c-f9-60);
  padding-top: 6px;
  text-transform: uppercase;
}

.contact-row-body {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-width: 0;
}

.contact-row-label {
  font-size: clamp(19px, 2vw, 26px);
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--c-f9f9f9);
}
.contact-row-hint {
  font-weight: 400;
  font-size: 0.6em;
  color: var(--c-f9-60);
  text-transform: none;
  letter-spacing: 0;
}

.contact-input-line {
  background: none;
  border: none;
  border-radius: 0;
  padding: 4px 0;
  font-family: var(--font);
  font-size: 18px;
  color: var(--c-f9f9f9);
  width: 100%;
}
.contact-input-line::placeholder {
  color: var(--c-f9-60);
}
.contact-input-line:focus {
  outline: none;
}

.contact-textarea-line {
  resize: vertical;
  min-height: 80px;
  line-height: 1.6;
  font-size: 16px;
}

.contact-chip-group {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.contact-chip {
  display: inline-flex;
  align-items: center;
  padding: 9px 16px;
  background: var(--c-f9-10);
  border: none;
  border-radius: 0;
  font-family: var(--font);
  font-size: 14px;
  font-weight: 500;
  color: var(--c-f9-60);
  cursor: pointer;
  transition: background 0.2s var(--ease), color 0.2s var(--ease);
}
.contact-chip:hover { color: var(--c-f9f9f9); }
.contact-chip.is-selected {
  background: var(--c-accent-10);
  color: var(--c-accent);
}

/* Hidden from real visitors via off-screen positioning (not display:none) —
   catches bots that skip display:none fields but still auto-fill layout. */
.contact-honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.contact-submit-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 16px;
  padding-top: 40px;
}

.contact-submit[aria-disabled="true"] {
  opacity: 0.6;
  pointer-events: none;
}

.contact-status {
  font-size: 14px;
  color: var(--c-f9-60);
  min-height: 20px;
}
.contact-status.is-success { color: var(--c-f9f9f9); }
.contact-status.is-error   { color: var(--c-accent); }


/* ── SIDEBAR: photo + contact/business/social info ─────────────── */
.contact-sidebar {
  position: sticky;
  top: 120px;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.contact-avatar {
  width: 96px;
  height: 96px;
  border-radius: 50%;
  overflow: hidden;
  background: var(--c-f9-10);
  display: flex;
  align-items: center;
  justify-content: center;
}
.contact-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.contact-avatar-fallback {
  font-size: 32px;
  font-weight: 700;
  color: var(--c-f9-60);
}

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

.contact-sidebar-label {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-f9-60);
}

.contact-sidebar-link,
.contact-sidebar-text {
  font-size: 15px;
  line-height: 1.6;
  color: var(--c-f9f9f9);
}
.contact-sidebar-link {
  text-decoration: underline;
  text-decoration-color: rgba(249, 249, 249, 0.25);
  text-underline-offset: 3px;
  transition: text-decoration-color 0.2s;
}
.contact-sidebar-link:hover { text-decoration-color: var(--c-f9f9f9); }

/* Button masquerading as the same underlined text link, reset of UA button
   chrome — used for the copy-to-clipboard email trigger in the panel foot.
   overflow:hidden masks morphCopyLabel()'s vertical slide (js/interactions.js)
   between "Get my personal email" and "Copied to clipboard". */
.contact-panel-copy-email {
  background: none;
  border: none;
  padding: 0;
  font-family: inherit;
  cursor: pointer;
  overflow: hidden;
  display: inline-block;
}
.contact-panel-copy-email [data-copy-label] {
  display: inline-block;
  white-space: nowrap;
}

.contact-sidebar-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.contact-sidebar-list a {
  font-size: 15px;
  color: var(--c-f9f9f9);
  opacity: 0.8;
  transition: opacity 0.2s;
}
.contact-sidebar-list a:hover { opacity: 1; }

@media (max-width: 809px) {
  .contact-layout { grid-template-columns: 1fr; gap: 48px; }
  .contact-sidebar { position: static; }
}

/* ═══════════════════════════════════════════════════════════════════
   CONTACT PANEL — slide-in from the right, same on every breakpoint.
   Opened via [data-open-contact], closed via [data-close-contact] or
   Escape — see openContactPanel()/closeContactPanel() in interactions.js.
   ═══════════════════════════════════════════════════════════════════ */
.contact-panel-overlay {
  position: fixed;
  inset: 0;
  z-index: 150;
  visibility: hidden;
  pointer-events: none;
}
.contact-panel-overlay.is-open {
  visibility: visible;
  pointer-events: auto;
}

.contact-panel-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(9, 9, 9, 0.55);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  opacity: 0;
  transition: opacity 0.45s var(--ease);
}
.contact-panel-overlay.is-open .contact-panel-backdrop {
  opacity: 1;
}

.contact-panel {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  max-height: 100dvh;
  width: min(560px, 100vw);
  background: var(--c-1e);
  transform: translateX(100%);
  transition: transform 0.55s var(--ease-sharp);
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}
.contact-panel-overlay.is-open .contact-panel {
  transform: translateX(0);
}

.contact-panel-head {
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 24px var(--pad-x);
  background: var(--c-1e);
  border-bottom: 1px solid var(--c-f9-10);
}

.contact-panel-eyebrow {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-f9f9f9);
}
.contact-panel-time {
  display: flex;
  align-items: center;
  gap: 8px;
}

.contact-panel-close {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-f9-60);
  cursor: pointer;
  transition: color 0.2s;
}
.contact-panel-close:hover { color: var(--c-f9f9f9); }

.contact-panel-body {
  padding: 32px var(--pad-x) 40px;
}

.contact-panel-intro {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-bottom: 32px;
}

.contact-panel-avatar {
  width: 56px;
  height: 56px;
  flex-shrink: 0;
}
.contact-panel-avatar .contact-avatar-fallback { font-size: 20px; }

.contact-panel-heading {
  font-size: clamp(26px, 4.5vw, 34px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.0;
  color: var(--c-f9f9f9);
}

/* Tighter rhythm than the standalone page's rows — this sits in a ~560px
   panel, not a full page width. */
.contact-row--compact {
  gap: 14px;
  padding: 18px 0;
}
.contact-row-label--sm {
  font-size: 16px;
  font-weight: 600;
}

/* ── CONTACT WIZARD (panel only) ───────────────────────────────────
   One question at a time — see the wizard functions in js/contact.js,
   which toggle .is-active on each .contact-row--compact and swap the
   Next control for the real submit button on the last step. ── */
.contact-form--panel .contact-row--compact {
  display: none;
  border-bottom: none;
  padding: 4px 0 0;
}
.contact-form--panel .contact-row--compact.is-active {
  display: block;
}

/* Step number sits inline with its title (not a separate grid column),
   so the field/notes below it align flush left at the row's full width. */
.contact-form--panel .contact-row-num {
  display: inline;
  padding-top: 0;
  margin-right: 8px;
}

.contact-wizard-progress {
  height: 2px;
  background: var(--c-f9-10);
  margin-bottom: 8px;
}
.contact-wizard-progress-fill {
  display: block;
  height: 100%;
  width: 20%;
  background: var(--c-f9f9f9);
  transition: width 0.4s var(--ease-out);
}

.contact-wizard-nav {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-top: 28px;
}
.contact-wizard-btn--next,
.contact-wizard-btn--submit { margin-left: auto; }
/* Same .btn system as the rest of the site — just a lighter (5% white)
   fill on Back so it doesn't compete with the primary Next/Send button. */
.contact-wizard-btn--back.btn-secondary .btn-bg { background: var(--c-f9-05); }
.contact-form--panel.is-first-step .contact-wizard-btn--back { visibility: hidden; }
.contact-form--panel.is-last-step .contact-wizard-btn--next { display: none; }
/* Send replaces Next in the exact same slot/size on the last step, instead
   of a separate oversized submit button below. */
.contact-wizard-btn--submit { display: none; }
.contact-form--panel.is-last-step .contact-wizard-btn--submit { display: inline-flex; }

.contact-form--panel .contact-status { display: none; }
.contact-form--panel.is-last-step .contact-status { display: block; margin-top: 16px; }
/* A failed send has to be readable regardless of which step the wizard is
   showing — this rule is what the old success message was silently losing
   to, once wizardGoTo(form, 0) dropped .is-last-step. */
.contact-form--panel .contact-status.is-error { display: block; margin-top: 16px; }

/* ── SUBMIT OUTCOME ────────────────────────────────────────────────
   Success takes over the whole view; failure leaves the form untouched
   and reveals the direct address under the status line. Both toggle via
   the hidden attribute in js/contact.js, so they stay out of the
   accessibility tree until they apply.

   This block sits *after* the wizard rules on purpose. Hiding the form
   ties on specificity with .contact-form--panel .contact-row--compact
   .is-active (three classes each), so whichever comes last wins — and it
   has to be the confirmation, or the active step keeps showing straight
   through it. */
.contact-result[hidden],
.contact-error-mail[hidden] { display: none; }

.contact-form.is-complete > *:not(.contact-result) { display: none; }

/* Centred in the space it has, rather than sitting wherever the form
   happened to end. */
.contact-form.is-complete {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 420px;
}

/* Full view in the panel: the intro (avatar + heading) steps aside too,
   and the body becomes a column the form can stretch inside, so the
   confirmation is centred against the whole panel instead of hanging off
   the bottom of the last question. */
.contact-panel-body:has(.contact-form--panel.is-complete) {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}
.contact-panel-body:has(.contact-form--panel.is-complete) .contact-panel-intro { display: none; }
.contact-form--panel.is-complete { flex: 1; }

.contact-result {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 22px;
  padding: 8px 0;
  animation: contact-result-in 0.5s var(--ease-out) both;
}

@keyframes contact-result-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}

/* Circular, so .btn-shimmer-ring's ::before/::after (border-radius:
   inherit) trace a ring rather than a square. Overrides the base
   .btn-shimmer-ring border-radius: 0 — same specificity, declared later. */
.contact-result-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--c-f9-05);
  color: var(--c-f9f9f9);
}
/* Above the ring's own pseudo-elements, which sit at z-index 1. */
.contact-result-mark svg {
  position: relative;
  z-index: 2;
  width: 26px;
  height: 26px;
}

.contact-result-message {
  font-size: 20px;
  line-height: 1.4;
  color: var(--c-f9f9f9);
  max-width: 32ch;
}

.contact-error-mail {
  margin-top: 8px;
  font-size: 14px;
  color: var(--c-f9-60);
}
.contact-error-mail a {
  color: var(--c-accent);
  text-decoration: underline;
  text-underline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .contact-result { animation: none; }
}

/* "Form space" — a squared (no border-radius), 0.5px 5%-white-bordered box
   around the type-dependent hints: the daily-rate note and the plaimaker
   mention, both toggled via js/contact.js based on the selected "type" chip. */
.contact-note-box[hidden] { display: none; }
.contact-note-box {
  margin-top: 16px;
  padding: 14px 16px;
  border: 0.5px solid var(--c-f9-05);
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.contact-row-note {
  font-size: 13px;
  line-height: 1.5;
  color: var(--c-f9-60);
}
.contact-row-note a {
  color: var(--c-f9f9f9);
  text-decoration: underline;
  text-decoration-color: rgba(249, 249, 249, 0.25);
  text-underline-offset: 3px;
  transition: text-decoration-color 0.2s;
}
.contact-row-note a:hover { text-decoration-color: var(--c-f9f9f9); }

/* plaimaker.space mention — shimmering text + external-link icon */
.plaimaker-link {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  text-decoration: underline;
  text-decoration-color: rgba(249, 249, 249, 0.25);
  text-underline-offset: 3px;
  transition: text-decoration-color 0.2s;
}
.plaimaker-link:hover { text-decoration-color: var(--c-f9f9f9); }
.plaimaker-link-icon { flex-shrink: 0; color: var(--c-f9f9f9); }
.plaimaker-link-text {
  background-image: linear-gradient(100deg, var(--c-f9-60) 35%, var(--c-f9f9f9) 50%, var(--c-f9-60) 65%);
  background-size: 250% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
@media (prefers-reduced-motion: no-preference) {
  .plaimaker-link-text { animation: plaimaker-shimmer 3s linear infinite; }
}
@keyframes plaimaker-shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.contact-panel-foot {
  margin-top: auto;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 20px var(--pad-x);
  border-top: 1px solid var(--c-f9-10);
}

.contact-panel-socials {
  display: flex;
  gap: 20px;
}
.contact-panel-socials a {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-f9-60);
  transition: color 0.2s;
}
.contact-panel-socials a:hover { color: var(--c-f9f9f9); }

@media (max-width: 809px) {
  .contact-panel { width: 100vw; }
}

@media (prefers-reduced-motion: reduce) {
  .contact-panel-backdrop,
  .contact-panel { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════
   CUSTOM CURSOR (desktop only) — js/user-cursor.js
   ═══════════════════════════════════════════════════════════════════ */

/* Native cursor hidden everywhere the custom arrow substitutes for it —
   but restored over real text fields, where seeing the I-beam matters. */
body.user-cursor-active {
  cursor: none;
}
body.user-cursor-active a,
body.user-cursor-active button {
  cursor: none;
}
body.user-cursor-active input,
body.user-cursor-active textarea,
body.user-cursor-active select,
body.user-cursor-active [contenteditable] {
  cursor: text;
}

.user-cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s var(--ease);
}
.user-cursor.is-visible { opacity: 1; }
.user-cursor.is-visible.is-over-text { opacity: 0; }

/* size=31 (matching the reference's own preview prop), with the label's
   offset/padding/font-size/shadow following the same size-relative
   formulas the reference used: offset ≈ size*0.9 / size*0.2+6, padding ≈
   size*0.18 / size*0.36, font-size ≈ size*0.43. */
.user-cursor-arrow {
  position: absolute;
  top: 0;
  left: 0;
  width: 31px;
  height: 31px;
  transform-origin: 0 0;
  will-change: transform;
}
.user-cursor-arrow svg {
  display: block;
  fill: var(--c-f9f9f9);
  stroke: rgba(9, 9, 9, 0.18);
}

/* Display only — the name itself is set/edited via #cursor-name-bar.
   The 28px/12px offset from the arrow tip is baked into the JS quickTo
   targets (not a static CSS transform), since GSAP's own inline transform
   on this element would otherwise overwrite it. */
.user-cursor-label {
  position: absolute;
  top: 0;
  left: 0;
  pointer-events: none;
  will-change: transform;
}

.user-cursor-label-pill {
  display: inline-flex;
  align-items: center;
  padding: 6px 11px;
  background: var(--c-f9f9f9);
  color: var(--c-bg);
  border-radius: 999px;
  font-family: var(--font);
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.08);
}

@media (hover: none), (pointer: coarse) {
  .user-cursor,
  .cursor-name-bar { display: none; }
}

/* ── STICKY "WHO ARE YOU?" BAR ───────────────────────────────────── */
.cursor-name-bar {
  position: fixed;
  left: 50%;
  bottom: 24px;
  z-index: 9998;
  transform: translate(-50%, 0);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
}
.cursor-name-bar.is-visible {
  opacity: 1;
  pointer-events: auto;
}
/* Wins over .is-visible regardless of source order (3 classes vs 2) —
   forces the bar hidden near the end of any page, see
   initCursorNameBarSuppression in js/interactions.js, rather than letting
   it clash with the footer or a page's closing CTA. */
.cursor-name-bar.is-visible.is-suppressed {
  opacity: 0;
  pointer-events: none;
}

.cursor-name-form {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 8px 8px 20px;
  background: var(--c-f9f9f9);
  border-radius: 0;
  box-shadow: 0 12px 32px rgba(9, 9, 9, 0.35);
}

.cursor-name-input {
  border: none;
  background: none;
  padding: 0;
  font-family: var(--font);
  font-size: 15px;
  font-weight: 600;
  color: var(--c-bg);
  /* Widened from 170px to fit the longer "Who's on the other side?"
     placeholder (was sized for the shorter "Who are you?"). */
  width: 210px;
}
.cursor-name-input:focus { outline: none; }
/* Smaller than the input's own 15px/600 so the longer placeholder copy
   reads as a full sentence instead of getting clipped/overflowing the bar
   — the typed name itself (once entered) keeps the larger input font-size. */
.cursor-name-input::placeholder {
  color: var(--c-09-50);
  font-size: 13px;
  font-weight: 500;
}

.cursor-name-confirm {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  flex-shrink: 0;
  border-radius: 0;
  background: var(--c-bg);
  color: var(--c-f9f9f9);
  border: none;
  cursor: pointer;
  transition: transform 0.2s var(--ease);
}
.cursor-name-confirm:hover { transform: scale(1.08); }

/* ═══════════════════════════════════════════════════════════════════
   COOKIE CONSENT BANNER
   ═══════════════════════════════════════════════════════════════════ */
.cookie-banner[hidden] { display: none; }
.cookie-banner {
  position: fixed;
  left: 24px;
  bottom: 24px;
  z-index: 9000;
  max-width: 380px;
  padding: 20px;
  background: var(--c-1e);
  border: 0.5px solid var(--c-f9-05);
  display: flex;
  flex-direction: column;
  gap: 16px;
  opacity: 0;
  transform: translateY(12px);
  pointer-events: none;
  transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out);
}
.cookie-banner.is-visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

.cookie-banner-text {
  font-size: 13px;
  line-height: 1.6;
  color: var(--c-f9-60);
}
.cookie-banner-link {
  color: var(--c-f9f9f9);
  text-decoration: underline;
  text-decoration-color: rgba(249, 249, 249, 0.25);
  text-underline-offset: 3px;
  transition: text-decoration-color 0.2s;
}
.cookie-banner-link:hover { text-decoration-color: var(--c-f9f9f9); }

.cookie-banner-ok { align-self: flex-start; }

@media (prefers-reduced-motion: reduce) {
  .cookie-banner { transition: none; }
}

@media (max-width: 480px) {
  .cookie-banner { left: 16px; right: 16px; bottom: 16px; max-width: none; }
}

/* ═══════════════════════════════════════════════════════════════════
   CUSTOM FIND-IN-PAGE (Cmd+F / Ctrl+F)
   ═══════════════════════════════════════════════════════════════════ */
#_find_bar {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 10000;
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--c-f9f9f9);
  color: var(--c-bg);
  border-radius: 10px;
  padding: 10px 14px;
  box-shadow: 0 8px 32px rgba(0,0,0,0.55), 0 1px 4px rgba(0,0,0,0.2);
  font-family: var(--font);
  font-size: 14px;

  opacity: 0;
  pointer-events: none;
  transform: translateY(-8px) scale(0.97);
  transition: opacity 0.18s var(--ease-out), transform 0.18s var(--ease-out);
}
#_find_bar.is-open {
  opacity: 1;
  pointer-events: auto;
  transform: none;
}

#_find_input {
  background: transparent;
  border: none;
  outline: none;
  font: inherit;
  color: var(--c-bg);
  width: 200px;
  min-width: 0;
}
#_find_input::placeholder { color: var(--c-09-50); }
#_find_input::-webkit-search-cancel-button,
#_find_input::-webkit-search-decoration { -webkit-appearance: none; }

#_find_count {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--c-09-60);
  white-space: nowrap;
  min-width: 56px;
  text-align: center;
  text-transform: uppercase;
}

#_find_bar button {
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  color: var(--c-09-60);
  padding: 5px;
  border-radius: 5px;
  transition: color 0.15s, background 0.15s;
  flex-shrink: 0;
}
#_find_bar button svg { width: 14px; height: 14px; display: block; }
#_find_bar button:hover {
  color: var(--c-bg);
  background: rgba(9,9,9,0.08);
}

/* Divider between input and nav buttons */
#_find_count::before {
  content: '';
  display: inline-block;
  width: 1px;
  height: 14px;
  background: rgba(9,9,9,0.15);
  vertical-align: middle;
  margin-right: 10px;
}

/* ── Match highlight styles ─────────────────────────────────────── */
mark.find-mark {
  background: rgba(255, 70, 46, 0.15);
  color: inherit;
  border-radius: 2px;
  padding: 0 1px;
  text-decoration: underline;
  text-decoration-color: var(--c-accent);
  text-decoration-thickness: 1.5px;
  text-underline-offset: 2px;
}
mark.find-mark--current {
  background: var(--c-accent);
  color: #fff;
  text-decoration: none;
}

/* ═══════════════════════════════════════════════════════════════════
   FAQ SECTION
   ═══════════════════════════════════════════════════════════════════ */
.section-faq {
  position: relative;
  /* Reuses the two existing bg tokens (no new hex). Starts lighter than the
     incoming pixel-transition above (which matches --c-bg, the offerings
     section's own color) so that dissolve stays visible instead of fading
     pixels into an identically-colored bg; eases back to --c-bg by the
     bottom so there's no new seam against the CTA section below (also --c-bg). */
  background: linear-gradient(to bottom, var(--c-bg-lifted-5) 0%, var(--c-bg) 100%);
  /* Extra top padding gives the incoming pixelated-scroll-transition grid
     (pinned to the top of this section) room to fully dissolve before the
     heading/list starts, instead of the two competing for the same space. */
  padding: 240px var(--pad-x) 180px;
}

.faq-inner {
  max-width: var(--max-w);
  margin: 0 auto;
  display: grid;
  grid-template-columns: 3fr 7fr;
  gap: 0 60px;
  align-items: start;
  padding-top: 40px;
}

.faq-right {
  padding-top: 60px;
}

.faq-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.faq-item:not(:last-child) {
  border-bottom: 1px solid rgba(249, 249, 249, 0.08);
}

.faq-question {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding: 22px 0;
  width: 100%;
  background: none;
  border: none;
  cursor: pointer;
  font-size: 16px;
  font-weight: 500;
  color: var(--c-f9f9f9);
  letter-spacing: -0.01em;
  line-height: 1.35;
  text-align: left;
  user-select: none;
  opacity: 0.6;
  transition: opacity 0.2s;
}
.faq-item.is-open > .faq-question { opacity: 1; }

/* Glassmorphism treatment — icon scale-pop is driven by GSAP in
   js/interactions.js (initFaqAccordion) for the bouncy spring feel. */
.faq-icon {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
  position: relative;
  border-radius: 0;
  background: var(--c-f9-10);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--c-f9-60);
}
/* Both glyphs are absolutely centered on top of each other — as siblings in
   a plain flex row they'd sit side by side even at opacity:0, pushing the
   visible one off-center instead of overlapping in the middle. */
.faq-icon svg {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 14px;
  height: 14px;
  display: block;
}
.faq-icon-minus { opacity: 0; transform: translate(-50%, -50%) scale(0.5); transition: opacity 0.2s, transform 0.2s; }
.faq-icon-plus  { opacity: 1; transform: translate(-50%, -50%) scale(1);   transition: opacity 0.2s, transform 0.2s; }
.faq-item.is-open .faq-icon-minus { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.faq-item.is-open .faq-icon-plus  { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }

.faq-answer {
  overflow: hidden;
  height: 0;
}
.faq-answer-inner {
  padding-bottom: 24px;
}

.faq-answer p {
  font-size: 14px;
  line-height: 1.35;
  color: var(--c-f9-60);
}

.faq-answer a,
.faq-answer .faq-answer-link {
  color: var(--c-f9f9f9);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-color: rgba(249, 249, 249, 0.25);
  transition: text-decoration-color 0.2s;
}
.faq-answer a:hover,
.faq-answer .faq-answer-link:hover { text-decoration-color: var(--c-f9f9f9); }

/* Buttons (contact-form / copy-email triggers) inlined into the answer
   prose, styled to read exactly like the <a> text-link above. */
.faq-answer button.faq-answer-link {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font: inherit;
  cursor: pointer;
}

/* ── FAQ LEFT COLUMN ─────────────────────────────────────────────── */
.faq-left-body {
  padding-top: 28px;
  position: sticky;
  top: 100px;
}

.faq-left-label {
  display: block;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--c-f9-60);
  margin-bottom: 20px;
}

.faq-left-heading {
  font-size: clamp(28px, 5.5vw, 42px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.05;
  color: var(--c-f9f9f9);
  margin-bottom: 16px;
}

.faq-left-sub {
  font-size: 15px;
  line-height: 1.65;
  color: var(--c-f9-60);
  margin-bottom: 36px;
}

@media (max-width: 809px) {
  .faq-inner { grid-template-columns: 1fr; }
  /* Sticky only makes sense next to a taller column it can outlast — on the
     single-column mobile layout there's nothing beside it to scroll past,
     so it would just pin the title block over the FAQ list instead. */
  .faq-left-body { position: static; }
}

/* ── CLIPBOARD TOAST ─────────────────────────────────────────────── */
.copy-toast {
  position: fixed;
  bottom: 32px;
  left: 50%;
  transform: translateX(-50%) translateY(12px);
  background: var(--c-f9f9f9);
  color: var(--c-bg);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: -0.01em;
  padding: 10px 20px;
  border-radius: 100px;
  pointer-events: none;
  white-space: nowrap;
  opacity: 0;
  z-index: 9999;
}
