/* =====================================================
   Léo Félix Smith — Portfolio
   ===================================================== */

:root {
  --bg: #0c0c0d;
  --surface: #141416;
  --surface-2: #1b1b1e;
  --border: rgba(255, 255, 255, 0.08);
  --border-strong: rgba(255, 255, 255, 0.16);
  --text: #ededed;
  --text-muted: #8b8b93;
  --text-dim: #5a5a62;
  --accent: #9ab09a;
  --accent-soft: rgba(154, 176, 154, 0.15);

  --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
  --font-display: 'Fraunces', Georgia, serif;

  --radius: 6px;
  --radius-lg: 12px;

  --container: 1080px;
  --container-narrow: 760px;

  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}

* { box-sizing: border-box; }

html {
  scroll-behavior: smooth;
  /* Hide native scrollbars completely — page still scrolls via wheel,
     touch, and keyboard. The nav-circle's section dots indicate where
     the user is on the page, so the scrollbar's positional cue is not
     missed. */
  scrollbar-width: none;          /* Firefox */
  /* Grain lives on the root (not body) so it paints the canvas — including
     the iOS/macOS rubber-band overscroll area — instead of leaving bare
     near-black there. NOTE: do NOT put overflow on html — any overflow value
     on the root stops WebKit painting this background into the overscroll
     gutter, which is the whole point of having it here. Horizontal-overflow
     clipping is done on body instead. Body is also transformed during the
     page-zoom, so keeping the texture on html means it stays put. */
  background-color: var(--bg);
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.15'/%3E%3C/svg%3E");
  background-size: 200px 200px;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
  display: none;                  /* Chrome / Safari / newer Edge */
}
body {
  scrollbar-width: none;
}

/* Hard scroll lock applied during the page-zoom. !important ensures it
   wins over any other rule, and `touch-action: none` blocks iOS rubber-
   band / inertia that overflow:hidden alone doesn't catch. */
html.zoom-locked,
body.zoom-locked {
  overflow: hidden !important;
  touch-action: none !important;
  overscroll-behavior: none !important;
}

/* Surrounding-content fade during page-zoom. Elements above the clicked
   image get .zoom-fade-up (translate up + fade out); elements below
   get .zoom-fade-down. JS adds the classes when zoom starts. The
   `body.is-zoomed` selector controls when the destination state
   applies, so removing `is-zoomed` at the start of zoomOut reverses
   the animation. Same easing/duration as the body zoom transform. */
.zoom-fade-up,
.zoom-fade-down {
  transition:
    opacity 0.55s cubic-bezier(0.22, 0.61, 0.36, 1),
    transform 0.55s cubic-bezier(0.22, 0.61, 0.36, 1);
  will-change: opacity, transform;
}
body.is-zoomed .zoom-fade-up {
  opacity: 0;
  transform: translateY(-48px);
}
body.is-zoomed .zoom-fade-down {
  opacity: 0;
  transform: translateY(48px);
}
/* The nav is position:fixed inside <body>, which the page-zoom transforms — so
   during a zoom it scales/slides out of place (off-screen, or overlapping the
   zoomed image depending on scroll). It's not useful while viewing a zoomed
   image, so hide it for the duration. `visibility` (not opacity) because the
   hero-rise animation's `forwards` fill pins opacity:1 and would override it.
   Only active while .is-zoomed is set, so it can't affect normal behavior. */
body.is-zoomed #nav-circle {
  visibility: hidden;
}

body {
  margin: 0;
  /* Safety net against horizontal scroll (e.g. the gallery arrows' hit-area
     overhang). Lives on body, not html, so the root's overscroll texture is
     untouched. */
  overflow-x: hidden;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

img { display: block; max-width: 100%; height: auto; }

a {
  color: var(--text);
  text-decoration: none;
  transition: color 0.2s var(--ease);
}
a:hover { color: var(--accent); }

.container {
  max-width: var(--container);
  margin: 0 auto;
  padding: 0 24px;
}
/* Gallery prev/next arrows live in the gutter OUTSIDE the content column
   (left/right: -52px). They stay outside down to 720px — the point where the
   nav pill becomes the nav circle and the arrows move inside the image. But
   between 721px and ~1180px the default 24px gutter is too thin for the arrows
   to fit without spilling off-screen, so widen every container's side padding
   across that range. This shifts the whole site's alignment inward there (by
   design) so the outside arrows have room and never cause horizontal scroll. */
@media (min-width: 721px) and (max-width: 1180px) {
  .container { padding: 0 64px; }
}

.muted { color: var(--text-muted); font-weight: 400; }

/* =====================================================
   NAV CIRCLE — column of dots/labels; collapses on scroll
   ===================================================== */
/* =====================================================
   NAV — each row is [dot] + [label]. Dots stay put across all states; only
   labels fade/slide in and out, so transitions feel coherent (vs. the old
   "labels appear, dots disappear" swap that snapped between two layouts).

   States:
   - .at-top         (welcome page)   → transparent (no glass), labels visible
   - .collapsed      (scrolled idle)  → glass pill, labels collapsed to dots
   - default scrolled (hover/proximity) → dots + labels both visible
   ===================================================== */
/* One layout, one position. The nav is a dark liquid-glass pill anchored
   at the content's right margin (just inside the content edge), with
   labels collapsed by default and revealed on hover-proximity. Items are
   always row-reverse (label LEFT of dot) so the dots stay lined up against
   the pill's right edge regardless of label visibility.

   .at-top is the welcome variant — same geometry, but transparent (no
   glass) with auto-visible labels, so the welcome page reads as airy text
   rather than a UI chip.

   Welcome → scrolled (forward): the labels retract with their normal
   cascade; the glass fade-in is delayed until that cascade finishes, so the
   pill only appears once it's already at its final dot-size — you never see
   it closing.
   Scrolled → welcome (reverse): the glass fades straight out, then the
   labels open afterward as transparent airy text (no box to see open). */
.nav-circle {
  position: fixed;
  /* Aligned with the top of the gameplay video on a project page (JS
     overrides this to track .hero-bio on the welcome page). */
  top: 200px;
  /* Pill sits 16px inside the content's right edge so it doesn't kiss the
     content margin. On viewports where the content already runs near the
     window edge, fall back to a 16px gap from the viewport edge. */
  right: max(calc(50% - 500px), 16px);
  left: auto;
  z-index: 60;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 10px;
  padding: 14px 18px;
  background: rgba(12, 12, 13, 0.55);
  -webkit-backdrop-filter: blur(12px) saturate(1.4);
  backdrop-filter: blur(12px) saturate(1.4);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 22px;
  box-shadow:
    0 6px 22px rgba(0, 0, 0, 0.5),
    0 0 28px rgba(154, 176, 154, 0.12);
  /* Forward transition (leaving .at-top → scrolled): the labels first retract
     with their normal cascade (~0.68s, see .collapsed .nav-label), then the
     glass fades in. The 0.68s delay holds the glass back until the pill is
     already at its final dot-size, so it appears already-collapsed with no
     closing motion. The backdrop blur is NOT animated (animating it re-blurs
     the region behind the pill every frame — the most expensive thing the nav
     can do): it snaps on with 0s duration, but on the same 0.68s delay so the
     blurred box doesn't appear and shrink during the cascade. */
  transition:
    gap 0.25s cubic-bezier(0.4, 0, 0.2, 1),
    padding 0.4s cubic-bezier(0.22, 0.61, 0.36, 1),
    background 0.35s cubic-bezier(0.22, 0.61, 0.36, 1) 0.68s,
    border-color 0.35s cubic-bezier(0.22, 0.61, 0.36, 1) 0.68s,
    box-shadow 0.35s cubic-bezier(0.22, 0.61, 0.36, 1) 0.68s,
    -webkit-backdrop-filter 0s linear 0.68s,
    backdrop-filter 0s linear 0.68s;
}

.nav-circle.at-top {
  /* Welcome variant — transparent, no glass. Same geometry/position as the
     scrolled pill so only the glass (and labels) change between states. */
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  /* Reverse transition (entering .at-top): the glass dissolves immediately
     so the pill is gone before the labels reopen below it. backdrop-filter is
     not transitioned (see scrolled state) — the blur drops instantly while the
     background fades out. */
  transition:
    gap 0.25s cubic-bezier(0.4, 0, 0.2, 1),
    padding 0.4s cubic-bezier(0.22, 0.61, 0.36, 1),
    background 0.35s cubic-bezier(0.22, 0.61, 0.36, 1),
    border-color 0.35s cubic-bezier(0.22, 0.61, 0.36, 1),
    box-shadow 0.35s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.nav-circle-item {
  display: flex;
  /* Single direction across all states — dots stay anchored against the
     pill's right edge, labels grow leftward as they expand. */
  flex-direction: row-reverse;
  align-items: center;
  gap: 16px;
  padding: 0;
  color: var(--text-muted);
  font-size: 1.25rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  text-decoration: none;
  white-space: nowrap;
  /* Y shift drives the per-row vertical spread when hover-expanded; X is
     unused now (kept as a no-op for compatibility with the active-row
     cascade-stagger that other rules read off). */
  --shift-y: 0px;
  transform: translateY(var(--shift-y));
  transition-property: color, transform;
  transition-duration: 0.2s, 0.55s;
  transition-timing-function: var(--ease), cubic-bezier(0.4, 0, 0.2, 1);
  transition-delay: 0s, calc(var(--abs-n, 0) * 0.07s);
}
.nav-circle-item:hover { color: var(--text); }
.nav-circle-item.active { color: var(--text); }

/* Collapsing direction: when the destination is .collapsed, run the
   cascade INWARD — outermost rows tuck back first, active last. */
.nav-circle.collapsed .nav-circle-item {
  transition-delay: 0s, calc(var(--inv-abs-n, 0) * 0.07s);
}

/* The dot is the active indicator (replaces the old left border line). */
.nav-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.32);
  flex-shrink: 0;
  transition:
    background 0.3s var(--ease),
    transform 0.3s var(--ease),
    box-shadow 0.3s var(--ease),
    opacity 0.3s var(--ease),
    width 0.35s var(--ease),
    height 0.35s var(--ease),
    margin 0.35s var(--ease);
}
.nav-circle-item.active .nav-dot {
  background: var(--accent);
  transform: scale(1.35);
  box-shadow: 0 0 10px rgba(154, 176, 154, 0.55);
  animation: navDotBreathe 5s ease-in-out infinite;
}
@keyframes navDotBreathe {
  0%, 100% {
    transform: scale(1.3);
    box-shadow: 0 0 8px rgba(154, 176, 154, 0.45);
  }
  50% {
    transform: scale(1.55);
    box-shadow: 0 0 22px rgba(154, 176, 154, 0.95);
  }
}
@media (prefers-reduced-motion: reduce) {
  .nav-circle-item.active .nav-dot { animation: none; }
}

/* The label slides + fades in. max-width animates so the column visually
   shrinks to just-dots when labels are hidden. The cascade-from-active
   stagger (via --abs-n) is shared with the dot transforms, so dot and
   label radiate outward together. */
.nav-label {
  display: inline-block;
  overflow: hidden;
  max-width: 240px;
  opacity: 1;
  transform: translateX(0);
  transition-property: opacity, max-width, transform, margin;
  transition-duration: 0.3s, 0.4s, 0.4s, 0.4s;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  transition-delay: calc(var(--abs-n, 0) * 0.07s);
}

/* Scrolled idle — labels collapsed away. Cascade INWARD: outermost
   labels disappear first, active label disappears last. The dot/label gap
   in the row-reverse layout lives on the RIGHT of the label, so
   margin-right is what closes it. The label slides LEFT (translateX -6px)
   as it shrinks, tucking back toward the dot. */
.nav-circle.collapsed .nav-label {
  opacity: 0;
  max-width: 0;
  transform: translateX(-6px);
  margin-right: -16px;
  transition-delay: calc(var(--inv-abs-n, 0) * 0.07s);
}

/* Reverse (scrolled → welcome): labels reopen AFTER the glass has dissolved
   (0.4s base delay), then cascade from active outward, appearing as airy
   text rather than inside a pill. */
.nav-circle.at-top .nav-label {
  transition-delay: calc(0.4s + var(--abs-n, 0) * 0.07s);
}

/* Small desktop (mouse, below the content's max-width): the airy welcome
   labels would crowd the narrow hero, and the scrolled nav is already dots-only
   here (hover-expand is disabled under 1080px). So keep the nav a compact
   liquid-glass dots pill even at the top — same look as when scrolled.
   `pointer: fine` keeps this off touch devices, which use the circle nav. */
@media (min-width: 721px) and (max-width: 1079px) and (pointer: fine) {
  .nav-circle.at-top {
    background: rgba(12, 12, 13, 0.55);
    border-color: rgba(255, 255, 255, 0.08);
    box-shadow:
      0 6px 22px rgba(0, 0, 0, 0.5),
      0 0 28px rgba(154, 176, 154, 0.12);
    -webkit-backdrop-filter: blur(12px) saturate(1.4);
    backdrop-filter: blur(12px) saturate(1.4);
  }
  .nav-circle.at-top .nav-label {
    opacity: 0;
    max-width: 0;
    transform: translateX(-6px);
    margin-right: -16px;
  }
  /* Hover-to-open: the compact pill shows no labels by default here, so
     hovering directly over it reveals them (and the pill grows to fit). Unlike
     the proximity expand (disabled under 1080px), this only fires when the
     cursor is ON the pill — so it never auto-crowds the text as the mouse
     passes nearby. Placed last so it wins over the at-top/collapsed hides. */
  .nav-circle:hover .nav-label {
    opacity: 1;
    max-width: 240px;
    transform: translateX(0);
    margin-right: 0;
  }
}

/* Below the content's max-width, the side gutter collapses to the container's
   padding, which widens to 64px in this range (see .container above). The base
   `right` floors at 16px from the viewport edge, which would let the pill drift
   out past the content. Re-derive it from the 64px padding so the pill stays
   the same 16px inside the content's right edge as it does on wide screens. */
@media (min-width: 721px) and (max-width: 1180px) {
  .nav-circle { right: max(calc(50% - 460px), 80px); }
}

/* Circle nav for every touch device (any orientation) and narrow viewports.
   The desktop pill relies on hover-proximity — a no-op on touch — and its tall
   label column gets cut off on short landscape phones, so touch always gets the
   tap-to-open circle instead. `pointer: coarse` catches phones/tablets at any
   width; `max-width` keeps the circle on narrow mouse windows too. */
@media (max-width: 720px), (pointer: coarse) {
  /* Small dark-glass circle in the bottom-right thumb zone with a sage dot in
     the middle. Tap to expand into the full menu (dots + labels). */
  .nav-circle,
  .nav-circle.at-top {
    /* Bottom-right thumb zone (one-handed reach). Anchored by `bottom` so the
       expanded menu grows UPWARD from the button instead of pushing off the
       bottom edge. */
    top: auto;
    bottom: 20px;
    left: auto;
    right: 20px;
    transform: none;
    /* Mobile pill is right-anchored; dots line up against the pill's right
       edge with labels extending leftward inside the pill. */
    align-items: flex-end;
    width: 78px;
    height: 78px;
    padding: 0;
    gap: 0;
    background: rgba(12, 12, 13, 0.45);
    -webkit-backdrop-filter: blur(12px) saturate(1.4);
    backdrop-filter: blur(12px) saturate(1.4);
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-radius: 50%;
    box-shadow:
      0 6px 20px rgba(0, 0, 0, 0.5),
      0 0 24px rgba(154, 176, 154, 0.12);
    cursor: none;
    overflow: hidden;
    transition:
      width 0.4s cubic-bezier(0.22, 0.61, 0.36, 1),
      height 0.4s cubic-bezier(0.22, 0.61, 0.36, 1),
      border-radius 0.4s cubic-bezier(0.22, 0.61, 0.36, 1),
      padding 0.4s cubic-bezier(0.22, 0.61, 0.36, 1),
      gap 0.4s cubic-bezier(0.22, 0.61, 0.36, 1);
  }
  /* Collapsed state: hide all rows; show a single sage dot in the center
     via pseudo-element. The dot inherits the same breathing animation. */
  .nav-circle:not(.expanded) .nav-circle-item {
    display: none;
  }
  .nav-circle:not(.expanded)::before {
    content: '';
    position: absolute;
    inset: 0;
    margin: auto;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: var(--accent);
    box-shadow: 0 0 14px rgba(154, 176, 154, 0.7);
    animation: navDotBreathe 5s ease-in-out infinite;
  }

  /* Expanded — open into the full menu. Matches the desktop pill's box
     model: content-width (hugs the longest label, no dead space on the
     left), flex-end alignment so dots sit against the right edge, and the
     same 14px/18px padding + 10px gap. */
  .nav-circle.expanded,
  .nav-circle.at-top.expanded {
    width: fit-content;
    height: auto;
    padding: 14px 18px;
    gap: 10px;
    border-radius: 22px;
    align-items: flex-end;
  }
  .nav-circle.expanded .nav-circle-item {
    display: flex;
    /* Pill is right-anchored: keep label-left / dot-right so all dots
       align against the pill's right edge regardless of label length. */
    flex-direction: row-reverse;
    font-size: 1.25rem;
    --shift-y: 0px;
    transition: opacity 0.18s var(--ease);
  }
  .nav-circle.expanded .nav-label,
  .nav-circle.at-top.expanded .nav-label {
    opacity: 1;
    max-width: 220px;
    transform: translateX(0);
    margin-left: 0;
    margin-right: 0;
  }
  /* Closing: JS holds .expanded so the box stays put while the rows fade out,
     then drops both classes — so tapping away animates instead of blinking. */
  .nav-circle.expanded.is-closing .nav-circle-item {
    opacity: 0;
  }

}

/* =====================================================
   CURSOR — simple sage arrow, tracks the mouse 1:1
   ===================================================== */
@media (hover: hover) and (pointer: fine) {
  body { cursor: none; }
  a, button, .gallery-nav, .nav-circle-item, .gallery img { cursor: none; }
}

#cursor {
  position: fixed;
  left: 0;
  top: 0;
  width: 22px;
  height: 22px;
  pointer-events: none;
  z-index: 1000;
  transform: translate3d(-100px, -100px, 0);
  opacity: 0;
  /* Tilted arrow shape — apex at top-left, where the actual mouse sits */
  clip-path: polygon(0% 0%, 100% 38%, 56% 56%, 38% 100%);
  background: var(--accent);
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
  /* Keep the cursor on its own compositor layer so repositioning it on every
     mousemove is a pure GPU transform — it never repaints the page beneath. */
  will-change: transform;
  /* Snappy size change on hover; no bouncy easing, no transform transition. */
  transition: width 0.12s ease-out, height 0.12s ease-out, opacity 0.18s ease;
}

#cursor.visible { opacity: 1; }
#cursor.hover { width: 28px; height: 28px; }
#cursor.click { width: 18px; height: 18px; }

@media (hover: none), (pointer: coarse) {
  #cursor { display: none; }
}

/* =====================================================
   CLICK RING — single sage halo emanates from click point.
   JS spawns one .click-ring div at (clientX, clientY) on every
   mousedown and removes it after the animation. Pure visual feedback;
   never blocks input (pointer-events: none).
   ===================================================== */
.click-ring {
  position: fixed;
  left: 0;
  top: 0;
  width: 18px;
  height: 18px;
  margin-left: -9px;
  margin-top: -9px;
  border-radius: 50%;
  border: 1.5px solid rgba(154, 176, 154, 0.7);
  background: radial-gradient(circle, rgba(154, 176, 154, 0.18) 0%, rgba(154, 176, 154, 0) 70%);
  pointer-events: none;
  z-index: 999;
  opacity: 0.9;
  transform: translate3d(0, 0, 0) scale(0.4);
  animation: clickRingExpand 520ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
  will-change: transform, opacity;
}
@keyframes clickRingExpand {
  0%   { opacity: 0.9; transform: translate3d(var(--cx), var(--cy), 0) scale(0.4); }
  100% { opacity: 0;   transform: translate3d(var(--cx), var(--cy), 0) scale(4.2); }
}
@media (hover: none), (pointer: coarse) {
  .click-ring { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .click-ring { display: none; }
}


/* =====================================================
   HERO
   ===================================================== */
.hero {
  padding: 80px 0 32px;
}
@media (max-width: 720px) {
  .hero { padding: 56px 0 24px; }
}
.hero-main {
  display: block;
}

/* About section headshot — natural aspect ratio, accent line flush along the
   bottom edge of the image. */
.about-image {
  margin-bottom: 24px;
  display: flex;
  /* Left-aligned in the two-column layout so the headshot lines up with the
     "About Me" heading and the left edge of the content. */
  justify-content: flex-start;
}
.about-image img {
  width: 180px;
  height: auto;
  background: transparent;
  border: none;
  border-bottom: 2px solid var(--accent);
  display: block;
}
@media (max-width: 820px) {
  .about-image img { width: 140px; }
}

.hero-name {
  font-family: var(--font-display);
  font-size: clamp(2.5rem, 6vw, 4.5rem);
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.02em;
  margin: 0 0 12px;
}

.hero-role {
  font-size: 1.25rem;
  color: var(--accent);
  margin: 0 0 32px;
  font-weight: 500;
}

@keyframes heroRise {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}
@keyframes nameShimmer {
  from { background-position: 100% 0; }
  to   { background-position: 0% 0; }
}
.hero-name,
.hero-role,
.hero-bio,
.nav-circle {
  opacity: 0;
  animation: heroRise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
.hero-name {
  background-image: linear-gradient(
    100deg,
    var(--text) 35%,
    #ffffff 50%,
    var(--text) 65%
  );
  background-size: 250% 100%;
  background-position: 100% 0;
  background-repeat: no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
  animation:
    heroRise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) 0.1s forwards,
    nameShimmer 1.8s cubic-bezier(0.22, 0.61, 0.36, 1) 1.2s forwards;
}
.hero-role   { animation-delay: 0.22s; }
.hero-bio    { animation-delay: 0.34s; }
.nav-circle  { animation-delay: 0.46s; }
@media (prefers-reduced-motion: reduce) {
  .hero-name, .hero-role, .hero-bio, .nav-circle {
    opacity: 1;
    animation: none;
  }
}

.hero-bio {
  max-width: 620px;
  font-size: 1.0625rem;
  color: var(--text);
  margin: 0 0 36px;
  line-height: 1.65;
}

/* Specialty list under the hero tagline */
.hero-skills {
  max-width: 620px;
  margin: 0 0 36px;
  opacity: 0;
  animation: heroRise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) 0.4s forwards;
}
.hero-skills li {
  font-size: 1.0625rem;
  color: var(--text);
  margin-bottom: 8px;
}
@media (prefers-reduced-motion: reduce) {
  .hero-skills { opacity: 1; animation: none; }
}

/* =====================================================
   PROJECTS
   ===================================================== */
.projects {
  padding: 0;
}
.project {
  padding: 72px 0 100px;
}
@media (max-width: 720px) {
  .project { padding: 48px 0 64px; }
}

.project-header {
  margin-bottom: 32px;
  max-width: var(--container-narrow);
}
.project-studio {
  font-size: 0.78rem;
  color: var(--accent);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  font-weight: 600;
  margin: 0 0 12px;
}
.project-title {
  font-family: var(--font-display);
  font-size: clamp(2.25rem, 5vw, 3.5rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.05;
  margin: 0 0 12px;
}
.project-meta {
  font-family: var(--font-sans);
  /* Eyebrow scale — see .section-label. Quiet uppercase label, not body-size. */
  font-size: 0.8rem;
  color: var(--text-muted);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  font-weight: 600;
  line-height: 1.4;
  margin: 0;
}

/* Description on the left, engine on the right */
.project-intro {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  gap: 48px;
  align-items: start;
  margin-bottom: 48px;
}
.project-intro .project-body {
  margin-bottom: 0;
  max-width: none;
}
.project-intro .project-meta {
  align-self: start;
  text-align: right;
}
@media (max-width: 720px) {
  .project-intro {
    grid-template-columns: 1fr;
    gap: 16px;
  }
  /* Engine name moves above the description and stays right-aligned. */
  .project-intro .project-meta {
    order: -1;
    text-align: right;
  }
}
/* Video — embedded YouTube player; play button covers it until clicked */
.project-video {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  margin-bottom: 48px;
  border-radius: 22px;
  overflow: hidden;
  isolation: isolate;
}
.project-video iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  z-index: 1;
}
.project-video .video-play {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  padding: 0;
  margin: 0;
  cursor: none;
  background-color: var(--surface);
  background-size: cover;
  background-position: center;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1;
  border-radius: inherit;
}
.project-video .play-icon {
  width: 88px;
  height: 88px;
  filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.55));
  transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.project-video .video-play:hover .play-icon {
  transform: scale(1.08);
}

/* Body copy */
.project-body {
  max-width: var(--container-narrow);
  margin-bottom: 48px;
}
.project-body p {
  font-size: 1.0625rem;
  line-height: 1.7;
  color: var(--text);
  margin: 0 0 16px;
}
.project-body p:last-child { margin-bottom: 0; }

/* Two columns: contributions / extras */
.project-columns {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 64px;
  margin-bottom: 56px;
}
@media (max-width: 720px) {
  .project-columns { grid-template-columns: 1fr; gap: 40px; }
}

.section-label {
  /* Eyebrow scale — deliberately smaller than body (1.0625rem). All-caps at
     body size reads as heavy/shouty and flattens the hierarchy; a smaller
     letter-spaced cap reads as a quiet label. Matches the studio eyebrow. */
  font-size: 0.8rem;
  color: var(--text-muted);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  font-weight: 600;
  margin: 0 0 20px;
}
.section-label.spaced { margin-top: 36px; }

.contributions, .plain-list, .tag-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.contributions li {
  position: relative;
  padding-left: 22px;
  margin-bottom: 10px;
  line-height: 1.55;
  color: var(--text);
}
.contributions li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.7em;
  width: 12px;
  height: 1px;
  background: var(--accent);
}

.plain-list li {
  margin-bottom: 10px;
  color: var(--text);
}

/* Vertical link list — arrow + link with accent hover */
.link-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.link-list li {
  margin-bottom: 10px;
  line-height: 1.5;
}
.link-list a {
  display: inline-flex;
  align-items: baseline;
  gap: 8px;
  color: var(--text);
  text-decoration: none;
  transition: color 0.2s var(--ease);
}
.link-list a:hover { color: var(--accent); }
.link-list .arrow {
  color: var(--accent);
  flex-shrink: 0;
  transition: transform 0.25s var(--ease);
}
.link-list a:hover .arrow {
  transform: translateX(3px);
}

.tag-list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.tag-list li {
  padding: 8px 14px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 999px;
  font-size: 0.9rem;
}

/* Ratings (Eriksholm Steam/PS5 screenshots) */
.ratings {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  align-items: center;
}

.rating-card {
  display: inline-block;
  border-radius: 14px;
  overflow: hidden;
}
.rating-card img {
  display: block;
  max-height: 96px;
  width: auto;
}
/* Landscape phone where the contributions/ratings columns have collapsed into
   one stacked column (≤720px): vertical room is scarce, so sit the two rating
   widgets side by side instead of stacked. When the columns are still side by
   side (wider screens) the ratings are left as-is. */
@media (max-width: 720px) and (orientation: landscape) {
  .ratings { flex-wrap: nowrap; }
  .ratings .rating-card { flex: 1 1 0; min-width: 0; }
  .ratings .rating-card img { width: 100%; height: auto; max-height: none; }
}

/* Awards (transparent PNGs — white card backdrop so they pop) */
.awards-label {
  text-align: center;
  margin: 16px 0 24px;
}
.awards-row {
  display: flex;
  justify-content: center;
  align-items: stretch;
  gap: 32px;
  flex-wrap: nowrap;
  max-width: var(--container-narrow);
  margin: 0 auto 56px;
}
.awards-row img {
  flex: 1 1 0;
  max-width: 240px;
  height: 150px;
  width: 100%;
  min-width: 0;
  object-fit: contain;
  background: none;
}
@media (max-width: 720px) {
  .awards-row {
    gap: 10px;
    flex-wrap: nowrap;
  }
  .awards-row img {
    flex: 1 1 0;
    min-width: 0;
    max-width: none;
    height: 110px;
  }
}

/* =====================================================
   SCROLL-IN REVEAL
   ===================================================== */
.reveal {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.7s cubic-bezier(0.22, 0.61, 0.36, 1),
              transform 0.7s cubic-bezier(0.22, 0.61, 0.36, 1);
  will-change: opacity, transform;
}
.reveal.visible {
  opacity: 1;
  transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* Gameplay GIF — autoplaying loop card */
.project-gif {
  position: relative;
  margin: 0 0 32px;
  border-radius: 22px;
  overflow: hidden;
  isolation: isolate;
}
/* Mobile: the engine name sits directly under the video, so tighten the
   video's bottom margin to ~a third (32px → 11px) so the label hugs it.
   Placed after the base rule above so it wins on source order. */
@media (max-width: 720px) {
  .project-gif { margin-bottom: 11px; }
}
.project-gif img,
.project-gif video {
  width: 100%;
  aspect-ratio: 16 / 9;
  display: block;
  object-fit: cover;
  pointer-events: none;
}
.project-gif video::-webkit-media-controls,
.project-gif video::-webkit-media-controls-enclosure,
.project-gif video::-webkit-media-controls-panel,
.project-gif video::-webkit-media-controls-play-button,
.project-gif video::-webkit-media-controls-start-playback-button {
  display: none !important;
  -webkit-appearance: none !important;
}
/* Portrait only: keep the video from eating the whole tall screen. In
   landscape this cap would fight the 16/9 aspect-ratio (capped height + full
   width = a wide sliver), so it's scoped out of landscape. */
@media (max-width: 720px) and (orientation: portrait) {
  .project-gif img,
  .project-gif video {
    max-height: 50vh;
  }
}

/* Gallery — single-image carousel */
.gallery-wrapper {
  position: relative;
  margin-bottom: 0;
  border-radius: 22px;
  isolation: isolate;
}
.gallery {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  border-radius: 22px;
}
.gallery::-webkit-scrollbar { display: none; }
.gallery img {
  flex: 0 0 100%;
  scroll-snap-align: center;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  cursor: none;
  background: var(--surface);
  user-select: none;
  -webkit-user-drag: none;
}
.gallery.dragging,
.gallery.dragging img { cursor: none; }


/* Carousel chevrons — sit OUTSIDE the gallery on either side. */
.gallery-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 5;
  background: transparent;
  border: 0;
  padding: 0;
  font-family: var(--font-sans);
  font-size: 5.5rem;
  line-height: 1;
  font-weight: 300;
  color: var(--text-muted);
  cursor: none;
  will-change: transform;
  transition:
    color 0.2s var(--ease),
    transform 0.2s var(--ease),
    opacity 0.2s var(--ease);
}
/* Invisible hit-area extension: prevents the scale(1.15) hover from
   pushing the cursor outside the element's bounds and causing jitter. */
.gallery-nav::before {
  content: '';
  position: absolute;
  inset: -20px;
}
.gallery-nav:hover:not(:disabled) {
  color: var(--text);
  transform: translateY(-50%) scale(1.15);
}
.gallery-nav:disabled {
  opacity: 0;
  pointer-events: none;
}
.gallery-prev { left: -52px; }
.gallery-next { right: -52px; }
/* Arrows move inside the image only at the nav-circle breakpoint (720px).
   Above it they stay outside in the gutter, which the widened .container
   padding (see the 721–1180px rule) keeps wide enough to fit them. */
@media (max-width: 720px) {
  .gallery-prev { left: 4px; }
  .gallery-next { right: 4px; }
  .gallery-nav { font-size: 3.25rem; color: var(--text); }
}

/* Counter sits BELOW the gallery as plain text — no overlay.
   Carries the bottom margin that the wrapper used to own. */
.gallery-counter {
  margin: 14px 0 56px;
  text-align: center;
  font-size: 0.875rem;
  letter-spacing: 0.06em;
  color: var(--text-muted);
  transition: opacity 0.3s var(--ease);
}

/* Counter overlaid on the image while zoomed. Lives inside .gallery-wrapper
   so it tracks the image across orientations. JS toggles .visible to fade it
   in on zoom and on each navigation, then back out. The below-carousel
   .gallery-counter is hidden during zoom so the overlay takes over. */
.zoom-counter {
  position: absolute;
  left: 50%;
  bottom: 16px;
  transform: translateX(-50%);
  z-index: 6;
  padding: 5px 13px;
  border-radius: 999px;
  background: rgba(12, 12, 13, 0.6);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  color: var(--text);
  font-size: 0.85rem;
  letter-spacing: 0.06em;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.35s var(--ease);
}
.zoom-counter.visible { opacity: 1; }
body.is-zoomed .gallery-counter { opacity: 0; }

/* Reviews */
.reviews {
  display: grid;
  grid-template-columns: 1fr;
  gap: 16px;
  margin-bottom: 56px;
  max-width: 880px;
}
.review-quote {
  display: block;
  color: inherit;
  text-decoration: none;
  border-radius: var(--radius);
}
.reviews blockquote {
  margin: 0;
  padding: 24px 28px;
  background: var(--surface);
  border-left: 2px solid var(--accent);
  border-radius: var(--radius);
  height: 100%;
  will-change: transform;
  transition:
    transform 0.25s var(--ease),
    background-color 0.25s var(--ease);
}
.review-quote:hover blockquote {
  transform: translateX(6px);
  background: var(--surface-2);
}
.reviews blockquote p {
  margin: 0 0 12px;
  font-family: var(--font-display);
  font-size: 1.125rem;
  font-style: italic;
  line-height: 1.5;
  color: var(--text);
}
.reviews blockquote footer {
  font-size: 0.875rem;
  color: var(--text-muted);
  display: flex;
  align-items: center;
  gap: 10px;
}
.reviews .score {
  color: var(--accent);
  font-weight: 600;
  background: var(--accent-soft);
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 0.8rem;
}

/* External links row */
.project-links {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 28px;
}
.project-links a {
  color: var(--text-muted);
  font-size: 0.95rem;
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
.project-links a:hover { color: var(--accent); }
.project-links .arrow {
  color: var(--accent);
  transition: transform 0.25s var(--ease);
}
.project-links a:hover .arrow {
  transform: translateX(3px);
}

/* =====================================================
   ABOUT
   ===================================================== */
.about {
  padding: 120px 0;
}
@media (max-width: 720px) {
  .about { padding: 64px 0; }
}
.about-grid {
  display: grid;
  grid-template-columns: 280px 1fr;
  gap: 80px;
}
@media (max-width: 820px) {
  .about-grid { grid-template-columns: 1fr; gap: 32px; }
}
.about-body p {
  font-size: 1.0625rem;
  line-height: 1.7;
  margin: 0;
}

/* Contact stack under the headshot — left-aligned items in a centered column
   so the arrows line up vertically across all three links. */
.about-contact {
  display: flex;
  flex-direction: column;
  gap: 14px;
  align-items: flex-start;
  width: max-content;
  margin: 36px 0 0;
}
.about-contact a {
  display: inline-flex;
  align-items: baseline;
  gap: 10px;
  font-size: 1.0625rem;
  font-weight: 500;
  color: var(--text);
  letter-spacing: 0.005em;
  transition: color 0.2s var(--ease);
}
.about-contact a:hover { color: var(--accent); }
.about-contact a.copied { color: var(--accent); }
.about-contact .arrow {
  color: var(--accent);
  flex-shrink: 0;
  transition: transform 0.25s var(--ease);
}
.about-contact a:hover .arrow {
  transform: translateX(3px);
}
@media (max-width: 820px) {
  .about-side {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 24px;
  }
  .about-image {
    margin-bottom: 0;
    flex-shrink: 0;
  }
  .about-contact {
    margin: 0;
    width: auto;
  }
}

/* =====================================================
   GRACEFUL IMAGE FALLBACK
   ===================================================== */
img {
  background: linear-gradient(135deg, #1a1a1d 0%, #232327 100%);
}

/* =====================================================
   SAGE BACKLIGHT on enlargeable media

   Static sage glow behind media cards. This was previously a perpetual 7s
   box-shadow "breathing" pulse (mediaGlowPulse) composed from three @property
   variables, with hover/zoom swells layered on. That animated a ~250px blur
   shadow on every card every frame — even off-screen ones — which is a
   CPU-bound paint that pegged weaker machines and starved the main thread
   (the cause of the mouse-cursor lag). A static glow is painted once and
   cached by the compositor, so it costs nothing per frame while keeping the
   sage backlight look.
   ===================================================== */
.project-gif,
.gallery-wrapper,
.project-video {
  box-shadow:
    0 0 90px rgba(154, 176, 154, 0.07),
    0 0 40px rgba(154, 176, 154, 0.10);
}

/* JS toggles .is-zooming-target on the element being viewed in the page-zoom.
   A slightly stronger static glow makes the zoomed image read as "in focus".
   No transition — the swap is instant, so there's no per-frame shadow repaint. */
.is-zooming-target {
  box-shadow:
    0 0 120px rgba(154, 176, 154, 0.12),
    0 0 60px rgba(154, 176, 154, 0.16);
}

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
  html { scroll-behavior: auto; }
}
