
:root {
  --bg: #05070d;
  --bg-elevated: rgba(175, 199, 255, 0.06);

  /* Typographic color system — five tiers instead of one flat "dim" color,
     so hierarchy comes from color as well as size/weight. */
  --text: #f3f5f7; /* primary headings */
  --text-secondary: #b8c7d9; /* body copy, descriptions */
  --accent: #afc7ff; /* accent text, icons, active states — icy blue */
  --accent-2: #b8a6f2; /* lavender — paired with --accent for gradients/glows */
  --text-label: #7f9bc0; /* small labels, eyebrows, nav */
  --text-muted: #71809a; /* metadata: dates, categories, footer */

  --line: rgba(184, 208, 240, 0.12);
  --line-strong: rgba(184, 208, 240, 0.24);

  --font-display: "Fraunces", Georgia, serif;
  --font-body: "Space Grotesk", "Helvetica Neue", Arial, sans-serif;

  --gutter: clamp(1.5rem, 5vw, 4rem);
  --section-space: clamp(5rem, 12vw, 9rem);
  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Per-scene atmosphere — overridden by body[data-scene="..."] below.
     --overlay-opacity dims the fixed sky/hills/moon (it sits below them in
     z-index). --world-brightness is read by .world itself (see below),
     which is the actual containing layer for every platform AND the cat —
     applying it there, once, dims/relights the whole side-world together
     instead of leaving the platforms undimmed while only the cat reacted. */
  --overlay-opacity: 0.74;
  --bg-shift: 0px;
  --world-brightness: 1;
}

/* A real brightness arc, not a flat vignette: bright at the hero, a peak
   at the interlude ("Still curious."), then ONE consistent dull plateau —
   same darkness the whole way from education through experience, no
   wavering in between — before recovering to a second peak at contact. */
body[data-scene="hero"] { --overlay-opacity: 0.7; --world-brightness: 1; }
body[data-scene="about"] { --overlay-opacity: 0.72; --world-brightness: 0.92; }
body[data-scene="education"] { --overlay-opacity: 0.9; --bg-shift: -4px; --world-brightness: 0.62; }
body[data-scene="interlude"] { --overlay-opacity: 0.46; --bg-shift: -10px; --world-brightness: 1.2; }
body[data-scene="stack"] { --overlay-opacity: 0.9; --world-brightness: 0.62; }
body[data-scene="projects"] { --overlay-opacity: 0.9; --world-brightness: 0.62; }
body[data-scene="current"] { --overlay-opacity: 0.9; --world-brightness: 0.62; }
body[data-scene="experience"] { --overlay-opacity: 0.9; --bg-shift: 3px; --world-brightness: 0.62; }
body[data-scene="contact"] { --overlay-opacity: 0.52; --bg-shift: 6px; --world-brightness: 1.15; }

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  overflow-x: hidden;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

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

a {
  color: inherit;
  text-decoration: none;
}

::selection {
  background: var(--accent);
  color: #05070d;
}

/* ==========================================================================
   Night world — fixed, viewport-anchored layers behind the entire page:
   a plain sky gradient, a moon, two distant hill silhouettes, twinkling
   stars, a vignette, and a pointer-following glow. All negative z-index so
   normal in-flow content (header/main/footer) always paints above them.
   Quiet by design — no particle systems, nothing above ~2px per element.
   ========================================================================== */

/* Shifted off a flat cold navy toward a warmer indigo-plum — echoes the
   lavender/rose flowers and cream cat instead of sitting apart from them,
   with a faint warm blush low on the horizon (where hills meet sky) tying
   the whole palette together without turning the sky itself warm. */
.sky {
  position: fixed;
  inset: 0;
  z-index: -6;
  background:
    radial-gradient(140% 60% at 50% 100%, rgba(210, 160, 190, 0.1), transparent 60%),
    linear-gradient(180deg, #0c0d1f 0%, #171432 42%, #26203f 75%, #1c1730 100%);
}

.moon {
  position: fixed;
  top: 9%;
  right: 11%;
  z-index: -5;
  width: clamp(56px, 9vw, 96px);
  height: clamp(56px, 9vw, 96px);
  border-radius: 50%;
  background: radial-gradient(circle at 35% 35%, #fbf4ee, #e6d6e8 55%, #b8a3d4 100%);
  box-shadow: 0 0 60px 18px rgba(207, 168, 224, 0.16), 0 0 140px 60px rgba(160, 140, 210, 0.08);
}

.moon::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background:
    radial-gradient(circle at 30% 65%, rgba(170, 150, 195, 0.35) 0 12%, transparent 13%),
    radial-gradient(circle at 62% 30%, rgba(170, 150, 195, 0.3) 0 15%, transparent 16%),
    radial-gradient(circle at 68% 68%, rgba(170, 150, 195, 0.25) 0 9%, transparent 10%);
}

/* A couple of soft, wide cloud smudges drifting very slowly left to right —
   pure CSS (two pseudo-elements on one wrapper), no extra markup, no JS. */
.clouds {
  position: fixed;
  inset: 0;
  z-index: -5;
  overflow: hidden;
  pointer-events: none;
}

.clouds::before,
.clouds::after {
  content: "";
  position: absolute;
  border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(175, 199, 255, 0.09), transparent 70%);
  filter: blur(4px);
  animation: driftCloud 150s linear infinite;
}

.clouds::before {
  top: 12%;
  left: -25%;
  width: 320px;
  height: 80px;
}

.clouds::after {
  top: 30%;
  left: -45%;
  width: 240px;
  height: 64px;
  opacity: 0.75;
  animation-duration: 190s;
  animation-delay: -60s;
}

@keyframes driftCloud {
  from { transform: translateX(0); }
  to { transform: translateX(140vw); }
}

/* A few shooting stars, each on its own long loop timed differently so
   they read as occasional discoveries scattered through the page, never
   all firing together. A bright glowing head (box-shadow) plus a longer,
   fading tail (the gradient body) reads more like a real streak than a
   plain line. */
.shooting-star {
  position: fixed;
  z-index: -2;
  width: 120px;
  height: 2px;
  pointer-events: none;
  /* Bright head at the END of the gradient (the leading edge for --a/--c,
     which travel toward positive x/y) — the tail (fading toward
     transparent) trails behind at the start. --b travels the opposite
     way and gets its own reversed gradient below, not this one. */
  background: linear-gradient(90deg, transparent, rgba(200, 220, 255, 0.5) 65%, #eef4ff);
  border-radius: 999px;
  box-shadow: 0 0 8px 1px rgba(220, 232, 255, 0.9);
  opacity: 0;
}

.shooting-star--a { top: 14%; left: 4%; transform: rotate(24deg); animation: shootingStarA 14s linear infinite; }
.shooting-star--b {
  top: 62%;
  right: 8%;
  left: auto;
  transform: rotate(-18deg);
  /* Travels toward negative x/y, so its leading edge is the opposite end
     — bright head at the START of the gradient instead. */
  background: linear-gradient(90deg, #eef4ff, rgba(200, 220, 255, 0.5) 35%, transparent);
  animation: shootingStarB 19s linear infinite;
  animation-delay: -6s;
}
.shooting-star--c { top: 30%; left: 55%; transform: rotate(30deg); width: 90px; animation: shootingStarC 23s linear infinite; animation-delay: -13s; }

/* --d reuses --b's reversed-direction keyframes (own position/timing/size
   only), so it needs the same reversed head/tail gradient. */
.shooting-star--d {
  top: 8%;
  right: 20%;
  left: auto;
  transform: rotate(-18deg);
  background: linear-gradient(90deg, #eef4ff, rgba(200, 220, 255, 0.5) 35%, transparent);
  width: 100px;
  animation: shootingStarB 21s linear infinite;
  animation-delay: -3s;
}
.shooting-star--e { top: 70%; left: 35%; transform: rotate(30deg); width: 70px; animation: shootingStarC 17s linear infinite; animation-delay: -9s; }

/* translate() runs in the coordinate system rotate() just established, so
   translating along X ONLY (Y always 0) is what keeps the star travelling
   exactly along its own tilted length — rotate() alone then supplies the
   real on-screen diagonal. The earlier version translated along both X and
   Y post-rotation, which pushed the star off its own axis and read as an
   unnatural sideways drift rather than a straight streak.
   The 87%→98%→100% distances (9.6 → 72 → 84 vmax) are spaced to keep
   roughly the same vmax-per-1% pace throughout, instead of a fast streak
   suddenly stalling right as it fades — vmax (not vw/vh) so the travel
   distance clears the screen the same way regardless of aspect ratio. */
@keyframes shootingStarA {
  0%, 85% { opacity: 0; transform: rotate(24deg) translate(0, 0); }
  87% { opacity: 1; transform: rotate(24deg) translate(9.6vmax, 0); }
  98% { opacity: 1; transform: rotate(24deg) translate(72vmax, 0); }
  100% { opacity: 0; transform: rotate(24deg) translate(84vmax, 0); }
}

/* Same shape as shootingStarA, mirrored for --b's opposite travel
   direction (see its own reversed head/tail gradient above). */
@keyframes shootingStarB {
  0%, 85% { opacity: 0; transform: rotate(-18deg) translate(0, 0); }
  87% { opacity: 1; transform: rotate(-18deg) translate(-9.6vmax, 0); }
  98% { opacity: 1; transform: rotate(-18deg) translate(-72vmax, 0); }
  100% { opacity: 0; transform: rotate(-18deg) translate(-84vmax, 0); }
}

/* --c used to reuse shootingStarA, but that hard-codes rotate(24deg) at
   every step, silently overriding --c's own rotate(30deg) for as long as
   the animation ran. Same shape, --c's actual angle. */
@keyframes shootingStarC {
  0%, 85% { opacity: 0; transform: rotate(30deg) translate(0, 0); }
  87% { opacity: 1; transform: rotate(30deg) translate(9.6vmax, 0); }
  98% { opacity: 1; transform: rotate(30deg) translate(72vmax, 0); }
  100% { opacity: 0; transform: rotate(30deg) translate(84vmax, 0); }
}

/* Distant hill silhouettes — two layers, nudged a few px per scene (see
   --bg-shift, body[data-scene] above) for a faint sense of depth without
   ever amounting to real parallax. The near layer's jagged top edge reads
   as a faint treeline rather than smooth dunes. */
.hills {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: -4;
  height: 22vh;
  min-height: 140px;
  pointer-events: none;
  transform: translateY(var(--bg-shift, 0px));
  transition: transform 1.2s var(--ease);
}

.hills__far,
.hills__near {
  position: absolute;
  inset: 0;
}

.hills__far {
  background: #191531;
  opacity: 0.75;
  clip-path: polygon(0 60%, 12% 42%, 26% 55%, 40% 32%, 55% 50%, 70% 27%, 85% 45%, 100% 36%, 100% 100%, 0 100%);
}

.hills__near {
  background: #110e22;
  clip-path: polygon(
    0 85%, 6% 62%, 10% 78%, 16% 56%, 22% 76%, 30% 52%, 36% 74%,
    44% 50%, 50% 72%, 58% 48%, 64% 70%, 72% 46%, 78% 68%,
    86% 52%, 92% 70%, 100% 60%, 100% 100%, 0 100%
  );
}

.bg-overlay {
  position: fixed;
  inset: 0;
  z-index: -3;
  opacity: var(--overlay-opacity, 1);
  transition: opacity 1s var(--ease);
  background:
    radial-gradient(120% 55% at 50% 0%, rgba(10, 7, 16, 0.05), transparent 60%),
    linear-gradient(
      180deg,
      rgba(10, 7, 16, 0.14) 0%,
      rgba(10, 7, 16, 0.32) 40%,
      rgba(10, 7, 16, 0.5) 100%
    );
}

/* A handful of fixed, CSS-only twinkling points, plus a few larger slow
   "motes" of light — no particle library, no per-frame JS. script.js sets
   each one's position/timing once at load via inline style, then the
   browser handles the rest. */
.stars {
  position: fixed;
  inset: 0;
  z-index: -2;
  overflow: hidden;
}

.star {
  position: absolute;
  width: 2px;
  height: 2px;
  border-radius: 50%;
  background: var(--text);
  opacity: 0;
  animation: twinkle 6s ease-in-out infinite;
}

/* Fireflies rather than plain stars — a warmer lavender-white glow with a
   little horizontal drift alongside the vertical rise. */
.star--mote {
  width: 3px;
  height: 3px;
  background: #d9c9ff;
  box-shadow: 0 0 7px 2px rgba(200, 190, 255, 0.6);
  animation: twinkle-mote 11s ease-in-out infinite;
}

@keyframes twinkle {
  0%, 100% { opacity: 0; }
  50% { opacity: 0.7; }
}

@keyframes twinkle-mote {
  0%, 100% { opacity: 0; transform: translate(0, 0); }
  50% { opacity: 0.6; transform: translate(6px, -12px); }
}

/* Soft radial light that follows the pointer (fine-pointer devices only —
   see script.js). Sits just under the content, above the rest of the sky. */
.bg-light {
  position: fixed;
  inset: 0;
  z-index: -1;
  opacity: 0;
  transition: opacity 0.4s var(--ease);
  background: radial-gradient(
    700px circle at var(--mx, 50%) var(--my, 50%),
    rgba(175, 199, 255, 0.08),
    transparent 70%
  );
}

.bg-light.is-active {
  opacity: 1;
}

/* ==========================================================================
   Navigation
   ========================================================================== */

.nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.5rem var(--gutter);
  background: transparent;
  transition: background-color 0.4s var(--ease), border-color 0.4s var(--ease);
  border-bottom: 1px solid transparent;
}

.nav.is-solid {
  background: rgba(5, 7, 13, 0.82);
  border-bottom-color: var(--line);
}

.nav__brand {
  font-family: var(--font-display);
  font-size: 1.25rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--accent);
}

.nav__links {
  display: flex;
  gap: clamp(1.25rem, 2vw, 2rem);
  font-size: 0.9rem;
  color: var(--accent);
  transition: color 0.6s linear;
}

/* While the nav is still transparent (top of the hero, before scrolling
   triggers .is-solid), its links sit directly over the night sky with
   nothing behind them — so they use the brighter --accent tone instead of
   the dimmer --text-label. Once .is-solid lands, the opaque backdrop makes
   this moot and the plain --text-label above takes back over. */
.nav:not(.is-solid) .nav__links a {
  color: var(--accent);
  transition: color 0.6s linear;
}

.nav__links a {
  position: relative;
  padding-bottom: 4px;
  transition: color 0.25s var(--ease);
}

.nav__links a::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.3s var(--ease);
}

/* Matches the `.nav:not(.is-solid) …` selector's specificity above (both
   are three classes deep) so hover/active still wins by source order —
   without this, the adaptive rule would keep a hovered nav link dim while
   the nav is still transparent. */
.nav .nav__links a:hover,
.nav .nav__links a:focus-visible,
.nav .nav__links a.is-active {
  color: var(--text);
}

.nav__links a.is-active::after {
  transform: scaleX(1);
}

/* Sized down from the base .btn for a compact navbar; still inherits the
   ghost border, hover-light, and magnetic pull (.btn in script.js already
   queries every .btn, this one included). */
.nav__resume {
  padding: 0.45rem 1.1rem;
  font-size: 0.85rem;
  border-radius: 999px;
  white-space: nowrap;
}

.nav__toggle {
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 5px;
  /* min-width/height (not the old fixed 28px width) give this a ~44×44 tap
     target — the recommended minimum touch-target size — while the lines
     below stay a fixed, smaller visual width; box-sizing is border-box
     globally, so padding alone would have shrunk the icon instead of
     growing the hit area. */
  min-width: 44px;
  min-height: 44px;
  background: none;
  border: none;
  cursor: pointer;
  padding: 8px;
}

.nav__toggle span {
  display: block;
  height: 1px;
  width: 28px;
  background: var(--text);
  transition: transform 0.3s var(--ease), opacity 0.3s var(--ease);
}

.nav-mobile {
  position: fixed;
  inset: 0;
  z-index: 99;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  gap: 1.5rem;
  /* Cleared space for the fixed header (~5.5rem) plus the usual gutter, so
     the link list never sits under the brand/Resume/toggle row — with 8
     items now (incl. Resume), a vertically centered list risked spilling
     up behind the header on shorter screens. overflow-y is a safety net
     if a future addition still makes the list taller than the viewport. */
  padding: 6.5rem var(--gutter) var(--gutter);
  overflow-y: auto;
  transform: translateY(-100%);
  transition: transform 0.4s var(--ease);
}

.nav-mobile.is-open {
  transform: translateY(0);
}

.nav-mobile a {
  font-family: var(--font-display);
  font-size: 2rem;
  color: var(--accent);
}

/* ==========================================================================
   Hero
   ========================================================================== */

/* A normal full-height section — no scroll-track, no scroll-jacking. Sits
   over the same fixed night-world layers (sky/moon/hills/stars/.world) as
   the rest of the page — the hero is simply the first place they're seen,
   not a separate instance of them. */
.hero {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
  padding: 0 var(--gutter);
  overflow: hidden;
}

.hero__inner {
  max-width: 46rem;
}

/* Staged entrance on load: each element fades/lifts in with its own delay,
   so the hero reveals in sequence rather than appearing all at once. Pure
   CSS — no scroll listener, no JS timing. */
@keyframes heroReveal {
  from {
    opacity: 0;
    transform: translateY(18px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.hero-reveal {
  opacity: 0;
  animation: heroReveal 0.9s var(--ease) forwards;
}

.hero-reveal--1 { animation-delay: 0.05s; }
.hero-reveal--4 { animation-delay: 0.55s; }
.hero-reveal--5 { animation-delay: 0.7s; }
.hero-reveal--6 { animation-delay: 1s; }

.hero__status {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  width: fit-content;
  font-size: 0.85rem;
  /* Same adaptive color as the tagline — this chip only ever appears in
     the hero, so it can use the variable directly without needing the
     :not(.is-solid) scoping the nav links need. */
  color: var(--text-label);
  transition: color 0.6s linear;
  margin: 0 0 1.5rem;
  padding: 0.4rem 0.9rem 0.4rem 0.7rem;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
}

.hero__status-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 8px 1px rgba(175, 199, 255, 0.7);
  animation: statusPulse 2.4s ease-in-out infinite;
}

@keyframes statusPulse {
  0%, 100% { opacity: 0.5; transform: scale(0.85); }
  50% { opacity: 1; transform: scale(1); }
}

.hero__name {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(2.75rem, 8vw, 5.5rem);
  line-height: 1.05;
  margin: 0 0 1.5rem;
  letter-spacing: -0.01em;
  text-shadow: 0 4px 30px rgba(5, 7, 13, 0.4);
}

.hero__name-word {
  display: inline-block;
  opacity: 0;
  /* Plain accent color is the baseline/fallback — browsers without
     background-clip: text support (see below) just get this, which is
     already a fully legible result on its own. */
  color: var(--accent);
  animation: heroReveal 0.8s var(--ease) forwards;
  animation-delay: calc(0.25s + var(--i) * 0.15s);
}

/* A subtle icy-blue → lavender gradient fill on the name. Gated behind
   @supports so the plain color above remains a fully legible fallback
   where background-clip: text isn't available, instead of invisible
   transparent text. */
@supports (background-clip: text) or (-webkit-background-clip: text) {
  .hero__name-word {
    background-image: linear-gradient(100deg, #dce7ff 0%, var(--accent) 55%, var(--accent-2) 100%);
    background-clip: text;
    -webkit-background-clip: text;
    color: transparent;
    -webkit-text-fill-color: transparent;
  }
}

.hero__tagline {
  font-size: clamp(1rem, 1.5vw, 1.25rem);
  color: var(--accent);
  max-width: 38ch;
  margin: 0 0 2.5rem;
  line-height: 1.6;
  text-shadow: 0 2px 16px rgba(5, 7, 13, 0.35);
}

.hero__cta {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  justify-content: center;
}

.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  padding: 0.85rem 1.75rem;
  font-size: 0.95rem;
  border-radius: 2px;
  overflow: hidden;
  transition: background-color 0.3s var(--ease), border-color 0.3s var(--ease),
    color 0.3s var(--ease), transform 0.2s var(--ease);
}

.btn::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    120px circle at var(--mx, 50%) var(--my, 50%),
    rgba(175, 199, 255, 0.35),
    transparent 70%
  );
  opacity: 0;
  transition: opacity 0.3s var(--ease);
  pointer-events: none;
}

.btn:hover::before {
  opacity: 1;
}

.btn:active {
  transform: scale(0.97);
}

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

.btn--primary:hover,
.btn--primary:focus-visible {
  background: var(--accent);
}

.btn--ghost {
  border: 1px solid var(--line-strong);
}

.btn--ghost:hover,
.btn--ghost:focus-visible {
  border-color: var(--text);
}

.hero__scroll {
  position: absolute;
  bottom: 2.5rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 0.75rem;
  font-size: 0.8rem;
  color: var(--text-label);
  transition: color 0.6s linear;
}

.hero__scroll-line {
  width: 1px;
  height: 2.5rem;
  background: var(--line-strong);
  position: relative;
  overflow: hidden;
}

.hero__scroll-line::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 40%;
  background: var(--text);
  animation: scrollLine 2s ease-in-out infinite;
}

@keyframes scrollLine {
  0% { transform: translateY(-100%); }
  100% { transform: translateY(250%); }
}

/* ==========================================================================
   Section shell
   ========================================================================== */

.section {
  position: relative;
  padding: var(--section-space) var(--gutter);
  /* Narrower and centered on purpose (down from 78rem) — the world's side
     ledges live in the margin this opens up outside the content column, so
     the cat has genuine room to jump and be noticed instead of crowding
     the text. */
  max-width: 64rem;
  margin: 0 auto;
  /* Clears the fixed nav when jumping here via an anchor link (clicking a
     nav item, or any #hash navigation) — without this, the section's own
     top padding is the only clearance, which shrinks below the nav's own
     height at narrow viewports (--section-space's minimum is smaller than
     12vw there), letting the heading land partly behind the nav. */
  scroll-margin-top: 5.5rem;
  /* Anchors the zoom to the section's top edge instead of its center
     (the default) — sections run 500-1250px tall, and scaling that from
     the center makes the heading (sitting right at the top) travel a
     large chunk of that height on its own, on top of the translateY,
     reading as if it starts small down near the middle of the section
     and rises into place. Pinning the origin to the top keeps the
     heading essentially still; the visible growth happens below it,
     where the content actually needs to arrive. */
  transform-origin: top center;
}

/* Cinematic depth: each section zooms in over its whole entry (the entire
   scroll distance it takes to arrive), holds at full scale while it's the
   one in view, then zooms back out over its whole exit — driven by the
   browser's native scroll-linked animations, not a scroll listener, so
   it's continuous and reverses cleanly scrolling back up. Entry and exit
   are two separate animations/timelines rather than one keyframe spread
   across "cover": tying zoom-in to a single combined enter+leave range
   left it compressed into the sliver of scroll where the section is still
   mostly clipped by the viewport edge, so it registered as an obvious
   shrink on the way out but a barely-there flicker on the way in. Splitting
   them gives the zoom-in the entire entry distance to play out, so it's
   just as visible as the zoom-out. No blur — it read as a rendering
   glitch on text rather than a depth cue, so scale/translate/opacity
   carry the whole effect. Only transform and opacity move — compositor-
   only, nothing here touches layout. Unsupported browsers (no @supports
   match) fall back to the JS-driven version below (initSectionDepthFallback
   in script.js); both respect prefers-reduced-motion (further down). */
@supports (animation-timeline: view()) {
  /* fill-mode is "forwards", deliberately not "both": with two animations
     sharing one property, a filled-but-inactive animation still outranks
     an actively-animating one declared earlier, purely by being later in
     the list — so a "both"-filled zoom-out sat there holding scale(1)
     for the *entire* entry phase (since exit hasn't started yet), and
     because it's listed second it won that tug-of-war outright, hiding
     zoom-in's real growth the whole time. Dropping the backwards half
     means zoom-out contributes nothing at all until its own exit range
     actually begins, so zoom-in's growth is free to show during entry;
     it still hands off cleanly once exit genuinely starts. Linear timing
     (not an eased curve) spreads that growth evenly across the whole
     entry/exit distance instead of front-loading it into an early sliver
     that reads as a flicker rather than a scroll-linked zoom. Ranges are
     "cover 0%/40%/60%/100%" rather than the named entry/exit ranges (too
     short to register — capped at roughly one viewport height no matter
     how tall the section is) or a flat 50/50 split of the whole
     scroll-through (longer, but left no real "at rest" zone: a section
     scrolled to a perfectly normal, fully-readable position could still
     be sitting at a non-1 scale, which renders visibly soft — a sub-1
     transform forces the browser to resample the whole layer, softening
     edges and text the same way a low-res image does when stretched, and
     that's what actually read as "blurry" on the Currently Working On
     cards). The middle 20% is a genuine plateau, pinned at exactly
     scale(1), so any ordinary scrolled-to-a-section position renders
     crisp; only the outer 40% on each side is actually mid-transition. */
  .section {
    animation: section-zoom-in linear forwards, section-zoom-out linear forwards;
    animation-timeline: view(), view();
    animation-range: cover 0% cover 40%, cover 60% cover 100%;
  }

  /* The interlude is the one deliberately full-screen, breath-taking beat
     in the page, so its depth swing is deeper than an ordinary section's. */
  .interlude {
    animation: section-zoom-in-strong linear forwards,
      section-zoom-out-strong linear forwards;
    animation-timeline: view(), view();
    animation-range: cover 0% cover 40%, cover 60% cover 100%;
  }

  /* Contact is the last stop on the page — there's no next scene for it
     to lose ground to, so it only zooms in on approach and then just
     stays put rather than receding again right as the user arrives. */
  .contact {
    animation: section-zoom-in linear forwards;
    animation-timeline: view();
    animation-range: cover 0% cover 40%;
  }
}

@keyframes section-zoom-in {
  from {
    transform: scale(0.76) translateY(6vh);
    opacity: 0.28;
  }
  to {
    transform: scale(1) translateY(0);
    opacity: 1;
  }
}

@keyframes section-zoom-out {
  from {
    transform: scale(1) translateY(0);
    opacity: 1;
  }
  to {
    transform: scale(0.76) translateY(-6vh);
    opacity: 0.28;
  }
}

@keyframes section-zoom-in-strong {
  from {
    transform: scale(0.65) translateY(8vh);
    opacity: 0.15;
  }
  to {
    transform: scale(1) translateY(0);
    opacity: 1;
  }
}

@keyframes section-zoom-out-strong {
  from {
    transform: scale(1) translateY(0);
    opacity: 1;
  }
  to {
    transform: scale(0.65) translateY(-8vh);
    opacity: 0.15;
  }
}

.section__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.5rem 2rem;
  border-bottom: 1px solid var(--line);
  padding-bottom: 1.5rem;
  margin-bottom: clamp(2.5rem, 6vw, 4rem);
}

.section__label {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.section__label--center {
  justify-content: center;
  margin-bottom: 1.25rem;
}

.section__label-num {
  font-family: var(--font-display);
  font-size: 1.1rem;
  color: var(--text-label);
  transition: color 0.6s linear;
}

.section__label-line {
  width: 2.5rem;
  height: 1px;
  background: var(--accent);
  transition: width 0.4s var(--ease);
}

/* A small discoverable detail: hovering the section header extends the
   line. The extra `.reveal-line` in the selector keeps this winning over
   the entrance rule below regardless of source order (both are otherwise
   equal specificity). */
.section__head:hover .section__label-line.reveal-line,
.section__label--center:hover .section__label-line.reveal-line {
  width: 4rem;
}

.section__label-text {
  font-family: var(--font-display);
  font-size: clamp(1.75rem, 3.5vw, 2.5rem);
  color: var(--text);
  text-shadow: 0 0 28px rgba(175, 199, 255, 0.22);
}

.section__intro {
  color: var(--text-secondary);
  font-size: 1rem;
  margin: 0;
}

/* ==========================================================================
   Interlude — after About, before Education. A deliberate breathing moment:
   the overlay lightens (see body[data-scene="interlude"]) — the world
   framing the screen (.world, fixed) continues on as it does everywhere
   else, so this reads as a beat to breathe rather than an empty gap.
   ========================================================================== */

.interlude {
  position: relative;
  min-height: 70vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--section-space) var(--gutter);
  text-align: center;
}

.interlude__text {
  font-family: var(--font-display);
  font-weight: 340;
  font-size: clamp(2.25rem, 6vw, 4rem);
  line-height: 1.15;
  color: var(--text);
  text-shadow: 0 4px 40px rgba(5, 7, 13, 0.6);
  margin: 0;
}

/* ==========================================================================
   World layer — ONE persistent, viewport-fixed layer (not per-section) of
   small grassy ledges plus the cat mascot (cat.js), framing the left/right
   edges of the screen while the portfolio's own sections scroll and zoom
   through the middle. z-index -1 puts it behind the sky/moon/hills/stars'
   own negative-z-index stack but still behind every real content element
   (which paints as z-index:auto, always above negative-z-index siblings in
   the same stacking context — body, here). Purely decorative and inert:
   pointer-events are off except on the cat itself, which re-enables them
   for its "poke" reaction.
   ========================================================================== */

.world {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: visible;
}

/* Small patches of moonlit ground rather than floating discs — irregular
   moss/grass silhouettes, a couple of tiny flowers and a grass-blade
   fringe baked into one layered background (no extra elements needed for
   those), a soft moonlight glow on the upper edge (::before), and — on
   some — a hanging vine (::after). Muted, natural greens throughout;
   nothing neon. Rooted to the LEFT edge with a flat vertical seam at x:0
   (reinforced by the dark soil gradient in the first background layer),
   dissolving into an irregular, jagged silhouette everywhere else — see
   .platform--mirror for the right-edge version. Width/height scale with
   the viewport (clamp) rather than a fixed px, so they stay genuinely
   walkable on a wide monitor without crowding a laptop-width one. */
.platform {
  position: absolute;
  width: clamp(84px, 9vw, 190px);
  /* The l1/l2/l3/r1/r2/r3 rules below set a wider `width`, with a px floor
     (e.g. 320px) that was sized for desktop and doesn't shrink on a narrow
     phone — past that floor a platform can end up wider than the screen
     itself, so its floating tip reaches the FAR edge and gets clipped too,
     leaving both sides looking flatly cut instead of one rooted edge. This
     caps the actual rendered width regardless of which `width` wins above,
     guaranteeing room for the tip on any viewport. */
  max-width: 65vw;
  height: clamp(22px, 2.6vw, 34px);
  left: 0;
  /* Dims/relights with the scene (see --world-brightness, near :root)  —
     applied per platform rather than on the shared .world wrapper: that
     wrapper is a position:fixed box pinned exactly to the viewport edges,
     and giving IT a filter was clipping anything (like a platform's own
     dangling vine, or --l3 sitting close to the bottom edge) that painted
     past its bounds in some browsers. A filter on this smaller, ordinary
     box has no such edge to clip against. */
  filter: brightness(var(--world-brightness, 1));
  /* Landing/walk-sway bounce stays quick (transform); the scene-brightness
     fade (filter) is deliberately much slower and matches .cat/.hills/
     .bg-overlay's own timing, so the whole world dims/relights together
     as one visible, unhurried change rather than a snap. */
  transition: transform 0.35s var(--ease), filter 1.2s var(--ease);
}

/* The jagged grass silhouette lives on ::before, deliberately separate
   from .platform itself: clip-path clips an element's ENTIRE rendered
   subtree, pseudo-elements included, even ones positioned outside its own
   box via a negative offset. With the shape on .platform directly, the
   dangling vine (::after, bottom:-20px) was being silently clipped away
   the instant it left the platform's own box — nothing was wrong with the
   vine, it was being erased by its sibling's clip-path. Keeping the shape
   on ::before and leaving .platform itself unclipped is what lets ::after
   actually hang below it. */
.platform::before {
  content: "";
  position: absolute;
  inset: 0;
  /* Flat vertical seam at x:0 (left edge, untouched by the polygon below)
     where it roots into the screen edge; every other edge is an irregular
     hand-picked outline so the free tip reads as a natural, uneven ledge
     rather than a rounded pill. border-radius can only bend 4 corners into
     smooth ellipses — never enough points to look genuinely uneven — so the
     silhouette needs a clip-path polygon instead. box-shadow is swapped for
     filter: drop-shadow() below because box-shadow is clipped away by
     clip-path (it's generated from the untouched rectangular box, which
     clip-path then cuts to nothing outside the polygon); drop-shadow runs
     after clipping and hugs the actual jagged shape.
     Exactly 2 lobes, each 7 points tracing a shallow arc (a coarse polygon
     approximation of a circle segment — a lobe built from only 2-3 points
     meeting at one vertex still reads as a tooth, no matter how irregular
     its spacing). The two lobes share nearly the same base x (92%), so the
     join between them is a short, shallow straight run instead of a notch
     cut back deep toward the seam — a deep notch there read as a crack
     splitting the ledge in two, not a natural edge. */
  clip-path: polygon(
    0% 0%, 60% 0%,
    92% 4%, 96% 6.14%, 98.93% 12%, 100% 20%, 98.93% 28%, 96% 33.86%, 92% 36%,
    92% 40%, 96% 43.75%, 98.93% 54%, 100% 68%, 98.93% 82%, 96% 92.25%, 92% 96%,
    75% 100%, 0% 100%
  );
  filter: drop-shadow(0 10px 10px rgba(5, 7, 13, 0.6));
  background:
    radial-gradient(ellipse at 25% 10%, rgba(175, 199, 255, 0.3), transparent 65%),
    linear-gradient(90deg, #1c140e 0%, rgba(28, 20, 14, 0) 20%),
    radial-gradient(circle at 14% 24%, #cfa8e0 0 3px, transparent 4px),
    radial-gradient(circle at 26% 22%, #e3a9c4 0 2.5px, transparent 3.5px),
    radial-gradient(circle at 40% 34%, #9fd0f2 0 2px, transparent 3px),
    radial-gradient(circle at 55% 12%, #cfa8e0 0 2.5px, transparent 3.5px),
    radial-gradient(circle at 62% 14%, #9fd0f2 0 2.5px, transparent 3.5px),
    radial-gradient(circle at 72% 28%, #e3a9c4 0 2px, transparent 3px),
    radial-gradient(circle at 80% 32%, #cfa8e0 0 2px, transparent 3px),
    radial-gradient(circle at 90% 20%, #9fd0f2 0 2px, transparent 3px),
    repeating-linear-gradient(96deg, rgba(150, 196, 140, 0.4) 0 2px, transparent 2px 8px),
    linear-gradient(160deg, #3d4a35 0%, #24301f 55%, #1a2318 100%);
  pointer-events: none;
}

/* The right-edge mirror: same idea, flipped — flush seam at x:100%. */
.platform--mirror {
  left: auto;
  right: 0;
}

.platform--mirror::before {
  /* Mirrored seam: flat vertical edge at x:100% (rooted), uneven tip on the
     left. Own 2-lobe sizing (not the base rule mirrored), so --r2 doesn't
     read as a copy of --l1. */
  clip-path: polygon(
    100% 0%, 50% 0%,
    7% 4%, 3.5% 6.68%, 0.94% 14%, 0% 24%, 0.94% 34%, 3.5% 41.32%, 7% 44%,
    7% 48%, 3.5% 51.22%, 0.94% 60%, 0% 72%, 0.94% 84%, 3.5% 92.78%, 7% 96%,
    27% 100%, 100% 100%
  );
  background:
    radial-gradient(ellipse at 75% 10%, rgba(175, 199, 255, 0.3), transparent 65%),
    linear-gradient(270deg, #1c140e 0%, rgba(28, 20, 14, 0) 20%),
    radial-gradient(circle at 86% 24%, #cfa8e0 0 3px, transparent 4px),
    radial-gradient(circle at 74% 22%, #e3a9c4 0 2.5px, transparent 3.5px),
    radial-gradient(circle at 60% 34%, #9fd0f2 0 2px, transparent 3px),
    radial-gradient(circle at 45% 12%, #cfa8e0 0 2.5px, transparent 3.5px),
    radial-gradient(circle at 38% 14%, #9fd0f2 0 2.5px, transparent 3.5px),
    radial-gradient(circle at 28% 28%, #e3a9c4 0 2px, transparent 3px),
    radial-gradient(circle at 20% 32%, #cfa8e0 0 2px, transparent 3px),
    radial-gradient(circle at 10% 20%, #9fd0f2 0 2px, transparent 3px),
    repeating-linear-gradient(96deg, rgba(150, 196, 140, 0.4) 0 2px, transparent 2px 8px),
    linear-gradient(160deg, #3d4a35 0%, #24301f 55%, #1a2318 100%);
}

/* A subtle bounce + brief glow-up when the cat lands (cat.js toggles this
   class briefly) — reads as the grass and flowers giving a little under
   its weight, not a mechanical platform reaction. */
.platform.is-landed {
  animation: platformSettle 0.4s var(--ease);
}

@keyframes platformSettle {
  0% { transform: scaleY(1); filter: brightness(1); }
  35% { transform: scaleY(0.88); filter: brightness(1.25); }
  100% { transform: scaleY(1); filter: brightness(1); }
}

/* A gentle sway while the cat is walking across it (cat.js toggles this
   class for the duration of the walk phase only) — grass/flowers stirring
   underfoot, not a mechanical wobble. */
.platform.is-active {
  animation: platformSway 1.1s ease-in-out infinite;
}

@keyframes platformSway {
  0%, 100% { transform: rotate(0deg); }
  50% { transform: rotate(0.6deg); }
}

/* Six ledges by default (index.html): three per side, at different screen
   heights, so the cat has a real network of short local hops plus an
   occasional longer crossing between sides — see cat.js's weighted target
   selection. --l1/--r1 are the widest, main ledges; --l2/--l3/--r2/--r3
   smaller stepping ledges. Deliberately uneven lengths — long/medium/short
   mixed across both sides rather than mirrored tiers, so the two edges
   don't read as symmetric bookends.
   Bold, mostly-fixed sizes now rather than tightly margin-scaled ones — a
   viewport-driven calc() kept the "long" ledges too close to their own
   floor to read as dramatically longer at ordinary desktop widths, which
   was the actual point. A little vw is mixed in purely so they keep
   growing on very wide monitors, not to shrink them back down. */
.platform--l1 { top: 21vh; width: clamp(320px, 26vw, 620px); height: clamp(30px, 2.8vw, 42px); }
.platform--l2 { top: 45vh; width: clamp(220px, 16vw, 340px); height: clamp(20px, 2.2vw, 30px); }
.platform--l3 { top: 73vh; width: clamp(320px, 22vw, 460px); height: clamp(24px, 2.6vw, 36px); }
.platform--r1 { top: 28vh; width: clamp(260px, 19vw, 400px); height: clamp(22px, 2.4vw, 32px); }
.platform--r2 { top: 54vh; width: clamp(360px, 28vw, 680px); height: clamp(30px, 2.8vw, 42px); }
.platform--r3 { top: 82vh; width: clamp(170px, 12vw, 260px); height: clamp(18px, 2vw, 26px); }

/* Each ledge's free-floating tip gets its own mismatched top/bottom radius
   pair (the seam-side corners stay flat, set on .platform::before /
   .platform--mirror::before above) — six identical jagged bumps in a row
   would read as a repeated stamp rather than six natural ledges, so each
   gets its own hand-picked polygon (seam edge left untouched at x:0 / x:100%
   for the mirrored ones). --l1/--r2 use the default set on ::before itself. */
.platform--l2::before {
  /* Exactly 2 lobes, own sizing distinct from every other ledge — see the
     base rule's comment for why 7-point arcs and a shared-base-x join. */
  clip-path: polygon(
    0% 0%, 50% 0%,
    90% 4%, 95% 7.22%, 98.66% 16%, 100% 28%, 98.66% 40%, 95% 48.78%, 90% 52%,
    90% 60%, 95% 62.41%, 98.66% 69%, 100% 78%, 98.66% 87%, 95% 93.59%, 90% 96%,
    70% 100%, 0% 100%
  );
}
.platform--l3::before {
  clip-path: polygon(
    0% 0%, 65% 0%,
    94% 4%, 97% 5.88%, 99.2% 11%, 100% 18%, 99.2% 25%, 97% 30.12%, 94% 32%,
    94% 34%, 97% 38.02%, 99.2% 49%, 100% 64%, 99.2% 79%, 97% 89.98%, 94% 94%,
    72% 100%, 0% 100%
  );
}
.platform--r1::before {
  clip-path: polygon(
    100% 0%, 55% 0%,
    9% 4%, 4.5% 7.75%, 1.21% 18%, 0% 32%, 1.21% 46%, 4.5% 56.25%, 9% 60%,
    9% 70%, 4.5% 71.88%, 1.21% 77%, 0% 84%, 1.21% 91%, 4.5% 96.12%, 9% 98%,
    30% 100%, 100% 100%
  );
}
.platform--r3::before {
  clip-path: polygon(
    100% 0%, 50% 0%,
    11% 4%, 5.5% 5.61%, 1.47% 10%, 0% 16%, 1.47% 22%, 5.5% 26.39%, 11% 28%,
    11% 30%, 5.5% 34.29%, 1.47% 46%, 0% 62%, 1.47% 78%, 5.5% 89.71%, 11% 94%,
    32% 100%, 100% 100%
  );
}

/* Vines hanging all across the underside, not one bunch off a corner.
   ::after now spans the platform's FULL width (rather than being one 2px
   vine that box-shadow could only ever repeat copies of from one origin);
   each of the 7 strands below is its own pair of background layers — a
   thin stem (linear-gradient) plus a two-tone flower at its tip (a small
   radial-gradient with a bright center fading to a petal color, so it
   actually reads as a flower rather than a flat dot) — independently
   positioned and independently long. */
.platform::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: 54px;
  pointer-events: none;
  background-repeat: no-repeat;
  background-image:
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #cfa8e0 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.7), transparent),
    radial-gradient(circle, #fff8 0%, #9fd0f2 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #e3a9c4 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.7), transparent),
    radial-gradient(circle, #fff8 0%, #cfa8e0 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #9fd0f2 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.7), transparent),
    radial-gradient(circle, #fff8 0%, #e3a9c4 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #cfa8e0 35%, transparent 68%);
  background-position:
    6% 0, 6% 20px,
    20% 0, 20% 32px,
    34% 0, 34% 24px,
    48% 0, 48% 40px,
    62% 0, 62% 18px,
    76% 0, 76% 34px,
    90% 0, 90% 26px;
  background-size:
    2px 22px, 9px 9px,
    2px 34px, 8px 8px,
    2px 26px, 9px 9px,
    2px 42px, 8px 8px,
    2px 20px, 9px 9px,
    2px 36px, 8px 8px,
    2px 28px, 9px 9px;
  transform-origin: top center;
  animation: vineIdleSway 4.5s ease-in-out infinite;
}

@keyframes vineIdleSway {
  0%, 100% { transform: rotate(-1deg); }
  50% { transform: rotate(1deg); }
}

/* A quick, more energetic swing right as the cat lands nearby — overrides
   the ambient sway for the moment .is-landed is present (see cat.js),
   then falls back to it automatically once the class is removed. */
.platform.is-landed::after {
  animation: vineLandSway 0.6s ease-out;
}

@keyframes vineLandSway {
  0% { transform: rotate(-1deg); }
  30% { transform: rotate(-5deg); }
  60% { transform: rotate(3deg); }
  100% { transform: rotate(-1deg); }
}

/* The two main ledges get an even fuller, longer-reaching set of vines. */
.platform--tuft::after {
  height: 74px;
  background-position:
    5% 0, 5% 30px,
    16% 0, 16% 46px,
    27% 0, 27% 34px,
    38% 0, 38% 56px,
    49% 0, 49% 26px,
    60% 0, 60% 48px,
    71% 0, 71% 36px,
    82% 0, 82% 52px,
    93% 0, 93% 30px;
  background-size:
    2px 32px, 9px 9px,
    2px 48px, 8px 8px,
    2px 36px, 9px 9px,
    2px 58px, 8px 8px,
    2px 28px, 9px 9px,
    2px 50px, 8px 8px,
    2px 38px, 9px 9px,
    2px 54px, 8px 8px,
    2px 32px, 9px 9px;
  background-image:
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #cfa8e0 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.7), transparent),
    radial-gradient(circle, #fff8 0%, #9fd0f2 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #e3a9c4 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.7), transparent),
    radial-gradient(circle, #fff8 0%, #cfa8e0 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #9fd0f2 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.7), transparent),
    radial-gradient(circle, #fff8 0%, #e3a9c4 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #cfa8e0 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.7), transparent),
    radial-gradient(circle, #fff8 0%, #9fd0f2 35%, transparent 68%),
    linear-gradient(180deg, rgba(150, 196, 140, 0.75), transparent),
    radial-gradient(circle, #fff8 0%, #e3a9c4 35%, transparent 68%);
}

/* On the "firefly" ledges, the whole hanging cluster gently glows instead
   of a separate bug-shaped dot — reads as a firefly resting among the
   flowers rather than competing with them. */
.platform--glow::after {
  animation: vineIdleSway 4.5s ease-in-out infinite, glowPulse 3.6s ease-in-out infinite;
}

@keyframes glowPulse {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 1; }
}

/* A couple of small warm lantern glows resting near the world's edges —
   fixed, static, no DOM per section. */
.lantern {
  position: fixed;
  z-index: -1;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #ffd9a8;
  box-shadow: 0 0 10px 3px rgba(255, 200, 140, 0.45);
  animation: glowPulse 4.4s ease-in-out infinite;
}

.lantern--a { left: 6%; top: 34vh; }
.lantern--b { right: 7%; top: 64vh; animation-delay: -2.2s; }


/* A few short-lived dust motes spawned on landing (cat.js) — capped at a
   handful per jump and removed right after their own animation, never
   accumulating. */
.cat-dust {
  position: absolute;
  width: 4px;
  height: 4px;
  border-radius: 2px 60% 2px 60%;
  background: rgba(160, 202, 150, 0.85);
  animation: dustPuff 0.5s ease-out forwards;
}

@keyframes dustPuff {
  0% { transform: translate(0, 0) scale(1); opacity: 0.8; }
  100% { transform: translate(var(--dx, 10px), -14px) scale(0.3); opacity: 0; }
}

/* The cat — a canvas character drawn by cat.js, moved between sections'
   .world layers and between platforms within a section entirely by JS
   (a walk shuffle + a real rAF-driven jump arc; see cat.js). Only opacity
   transitions in CSS — left/top are set every animation tick by JS, so a
   CSS transition on them would just fight the per-frame updates. */
.cat {
  position: absolute;
  height: clamp(64px, 9vw, 100px);
  width: auto;
  aspect-ratio: 220 / 180;
  left: 2%;
  top: 80%;
  transform-origin: 50% 85%;
  pointer-events: auto;
  cursor: default;
  filter: brightness(var(--world-brightness, 1)) drop-shadow(0 8px 10px rgba(5, 7, 13, 0.5));
  transition: opacity 0.35s var(--ease), filter 1.2s var(--ease);
}

@keyframes catPoke {
  0%, 100% { transform: scale(1); }
  40% { transform: scale(1.14) translateY(-4px); }
}

.cat.is-poked {
  animation: catPoke 0.32s var(--ease);
}

/* ==========================================================================
   About
   ========================================================================== */

.about__grid {
  display: grid;
  grid-template-columns: 1.1fr 0.9fr;
  gap: clamp(2rem, 6vw, 5rem);
  align-items: start;
}

.about__lead {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.about__statement {
  font-family: var(--font-display);
  font-size: clamp(1.4rem, 2.6vw, 2.1rem);
  line-height: 1.4;
  margin: 0;
  max-width: 24ch;
  text-shadow: 0 2px 20px rgba(5, 7, 13, 0.65);
}

.about__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.about__tag {
  font-size: 0.8rem;
  color: var(--text-label);
  padding: 0.45rem 0.9rem;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  text-shadow: 0 2px 10px rgba(5, 7, 13, 0.85);
  transition: color 0.25s var(--ease), border-color 0.25s var(--ease);
}

.about__tag:hover {
  color: var(--accent);
  border-color: var(--line-strong);
}

/* An editorial "stat strip" — large values with small captions, divided by
   a single hairline rather than boxed into cards. Typography and spacing do
   the organizing; nothing needs a background to read clearly here because
   the value text itself carries a text-shadow (below) instead of sitting
   on a plate. */
.about__stats {
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem 2rem;
}

.about__stat {
  padding-left: 1.5rem;
  border-left: 1px solid var(--line-strong);
}

.about__stat:first-child {
  padding-left: 0;
  border-left: none;
}

.about__stat dt {
  color: var(--text-label);
  font-size: 0.8rem;
  margin-bottom: 0.5rem;
  text-shadow: 0 2px 10px rgba(5, 7, 13, 0.85);
  transition: color 0.6s linear;
}

.about__stat dd {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.15rem;
  max-width: 22ch;
  text-shadow: 0 2px 14px rgba(5, 7, 13, 0.85);
}

/* ==========================================================================
   Education — a timeline of cards. Same connecting-line/marker pattern as
   Experience further down the page, but each entry gets its own hover
   glow/border since these are fewer, higher-weight items.
   ========================================================================== */

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

.education__list::before {
  content: "";
  position: absolute;
  left: 10rem;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--line);
}

.education__item {
  position: relative;
  display: grid;
  grid-template-columns: 9rem 1fr;
  gap: 1.5rem;
  padding: 1.5rem 1rem;
  margin: 0 -1rem;
  border: 1px solid transparent;
  border-radius: 8px;
  transition: border-color 0.35s var(--ease), background-color 0.35s var(--ease),
    box-shadow 0.35s var(--ease);
}

.education__item::before {
  content: "";
  position: absolute;
  left: 10rem;
  top: 2.25rem;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--text-muted);
  transform: translate(-50%, -50%);
  transition: background-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}

.education__item:hover {
  border-color: var(--line-strong);
  background: var(--bg-elevated);
  box-shadow: 0 0 40px -10px rgba(175, 199, 255, 0.35);
}

.education__item:hover::before {
  background: var(--accent);
  box-shadow: 0 0 8px 1px rgba(175, 199, 255, 0.7);
}

.education__period {
  color: var(--text-muted);
  font-size: 0.9rem;
  transition: color 0.3s var(--ease);
}

.education__item:hover .education__period {
  color: var(--accent);
}

.education__institution {
  font-family: var(--font-display);
  font-size: 1.3rem;
  margin: 0 0 0.35rem;
  transition: color 0.3s var(--ease);
}

.education__item:hover .education__institution {
  color: var(--accent);
}

.education__degree {
  color: var(--text-secondary);
  margin: 0 0 0.75rem;
  font-size: 0.95rem;
}

.education__score {
  display: inline-block;
  font-size: 0.8rem;
  color: var(--text-label);
  padding: 0.3rem 0.8rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease),
    text-shadow 0.3s var(--ease);
}

.education__item:hover .education__score {
  color: var(--accent);
  border-color: var(--line-strong);
  text-shadow: 0 0 12px rgba(175, 199, 255, 0.5);
}

/* ==========================================================================
   Tech Stack
   ========================================================================== */

.stack__list {
  display: flex;
  flex-direction: column;
}

/* An outlined "ghost" index number sits beside each category instead of a
   card border — a typographic device instead of a container, so the three
   categories read as a numbered editorial sequence rather than identical
   boxes stacked on top of each other. A grid (rather than the index being
   absolutely positioned against the group's own top-left corner) puts the
   number and the category title in the same row and lets `align-items`
   center them against each other exactly, instead of guessing a fixed
   top-offset that only lined up for one font size. */
.stack__group {
  display: grid;
  grid-template-columns: 2.5rem 1fr;
  align-items: center;
  column-gap: 1rem;
  padding: 2rem 0;
  border-top: 1px solid var(--line);
}

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

.stack__index {
  grid-column: 1;
  grid-row: 1;
  font-family: var(--font-display);
  font-size: 1.85rem;
  line-height: 1;
  color: transparent;
  -webkit-text-stroke: 1px var(--line-strong);
  pointer-events: none;
}

.stack__category {
  grid-column: 2;
  grid-row: 1;
  display: block;
  font-family: var(--font-display);
  font-size: 1.4rem;
  color: var(--text);
  text-shadow: 0 2px 14px rgba(5, 7, 13, 0.8);
}

/* A grid, not flex-wrap: flex-wrap lets each row wrap independently based
   on its own item widths, so nothing lines up row to row (the previous
   version had "Java"/"JavaScript" sitting under neither of the row
   above's items) or group to group. Fixed column tracks give every row —
   in every category — the same alignment. */
.stack__items {
  grid-column: 1 / -1;
  grid-row: 2;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  /* Grid items stretch to fill their column by default, which would blow
     the pill shape out into a full-width bar — start-align instead so
     each stays content-sized while the column tracks themselves still
     line up uniformly down the grid. */
  justify-items: start;
  gap: 0.75rem 1rem;
  list-style: none;
  margin: 1.4rem 0 0;
  padding: 0;
}

.stack__item {
  position: relative;
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  font-size: 1.05rem;
  padding: 0.4rem 0.9rem 0.4rem 1.4rem;
  border: 1px solid transparent;
  border-radius: 999px;
  text-shadow: 0 2px 10px rgba(5, 7, 13, 0.8);
  transition: color 0.25s var(--ease), border-color 0.25s var(--ease),
    transform 0.25s var(--ease);
}

.stack__item::before {
  content: "";
  position: absolute;
  left: 0.6rem;
  top: 50%;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--accent);
  transform: translateY(-50%) scale(0);
  transition: transform 0.25s var(--ease);
}

.stack__item:hover,
.stack__item:focus-visible {
  color: var(--accent);
  border-color: var(--line-strong);
  transform: translateY(-2px);
}

.stack__item:hover::before,
.stack__item:focus-visible::before {
  transform: translateY(-50%) scale(1);
}

.stack__note {
  font-size: 0.75rem;
  color: var(--text-muted);
  max-width: 0;
  overflow: hidden;
  white-space: nowrap;
  opacity: 0;
  transition: max-width 0.3s var(--ease), opacity 0.2s var(--ease);
}

.stack__item:hover .stack__note,
.stack__item:focus-visible .stack__note {
  max-width: 12rem;
  opacity: 1;
}

/* ==========================================================================
   Projects
   ========================================================================== */

.projects__list {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

/* Self-contained panels rather than a hairline-divided list — each project
   gets its own quiet glass surface, an index pill standing in for a
   "featured" tag, and tech shown as pills rather than a plain caption
   line. Kept dark and understated (a faint tint, not a bright card) so it
   sits inside the same navy/icy-blue world as the rest of the page. */
.project {
  /* Renders as an <a> when the project has a link (the whole card is now
     the click target, no separate "Code" line) — anchors are inline by
     default, so this needs an explicit block display to lay out the same
     as the plain <article> it falls back to otherwise. */
  display: block;
  position: relative;
  padding: clamp(1.75rem, 3.5vw, 2.5rem);
  background: rgba(175, 199, 255, 0.035);
  border: 1px solid var(--line);
  border-radius: 20px;
  overflow: hidden;
  transition: transform 0.4s var(--ease), border-color 0.4s var(--ease),
    background-color 0.4s var(--ease);
}

.project::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    420px circle at var(--mx, 50%) var(--my, 50%),
    rgba(175, 199, 255, 0.14),
    transparent 65%
  );
  opacity: 0;
  transition: opacity 0.4s var(--ease);
  pointer-events: none;
  z-index: 0;
}

.project:hover {
  transform: translateY(-4px);
  border-color: var(--line-strong);
  background: rgba(175, 199, 255, 0.06);
}

.project:hover::before {
  opacity: 1;
}

.project__image {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  /* contain, not cover — the whole screenshot shows uncropped; any
     letterboxing is filled by this background rather than left blank. */
  object-fit: contain;
  background: rgba(5, 7, 13, 0.4);
  border-radius: 12px;
  border: 1px solid var(--line);
  margin-bottom: 1.25rem;
}

.project__head {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  margin-bottom: 1.25rem;
}

.project__index {
  font-family: var(--font-body);
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  color: var(--text-label);
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  padding: 0.3rem 0.85rem;
  transition: color 0.6s linear;
}

.project__name {
  position: relative;
  z-index: 1;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(1.6rem, 2.8vw, 2.1rem);
  margin: 0 0 0.75rem;
  text-shadow: 0 2px 18px rgba(5, 7, 13, 0.85);
}

.project__desc {
  position: relative;
  z-index: 1;
  color: var(--text-secondary);
  max-width: 52ch;
  margin: 0 0 1.25rem;
  text-shadow: 0 2px 14px rgba(5, 7, 13, 0.8);
}

.project__tech {
  position: relative;
  z-index: 1;
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.project__tech li {
  font-size: 0.8rem;
  color: var(--text-muted);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.3rem 0.85rem;
}

/* ==========================================================================
   Currently Working On
   ========================================================================== */

.current__list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1.25rem;
}

.current__item {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  padding: 1.5rem;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: rgba(5, 7, 13, 0.4);
  transition: border-color 0.35s var(--ease), background-color 0.35s var(--ease),
    transform 0.35s var(--ease), box-shadow 0.35s var(--ease);
}

.current__item:hover {
  border-color: var(--line-strong);
  background: rgba(5, 7, 13, 0.55);
  transform: translateY(-4px);
  box-shadow: 0 12px 36px -12px rgba(175, 199, 255, 0.3);
}

.current__image {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: contain;
  background: rgba(5, 7, 13, 0.4);
  border-radius: 8px;
  border: 1px solid var(--line);
  margin-bottom: 0.5rem;
}

.current__item-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 0.5rem;
}

.current__number {
  font-family: var(--font-display);
  color: var(--text-label);
  font-size: 1.1rem;
  transition: color 0.6s linear;
}

.current__title {
  font-family: var(--font-display);
  font-size: 1.4rem;
  margin: 0 0 0.35rem;
  transition: color 0.3s var(--ease);
}

.current__item:hover .current__title {
  color: var(--accent);
}

.current__category {
  display: block;
  color: var(--text-muted);
  font-size: 0.85rem;
  margin-bottom: 0.75rem;
}

.current__desc {
  margin: 0;
  color: var(--text-secondary);
  font-size: 0.95rem;
}

.current__status {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.8rem;
  white-space: nowrap;
  color: var(--text-muted);
}

.current__status::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 8px 1px rgba(175, 199, 255, 0.7);
  animation: statusPulse 2.4s ease-in-out infinite;
}

/* ==========================================================================
   Experience
   ========================================================================== */

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

.experience__list::before {
  content: "";
  position: absolute;
  left: 10rem;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--line);
}

.experience__item {
  position: relative;
  display: grid;
  grid-template-columns: 9rem 1fr auto;
  gap: 1.5rem;
  padding: 1.5rem 1rem;
  margin: 0 -1rem;
  border-top: 1px solid var(--line);
  align-items: baseline;
  border-radius: 4px;
  transition: background-color 0.3s var(--ease);
}

/* A small marker on the shared connecting line, per entry — brightens on
   hover so "the line reacts" without needing to split it into segments. */
.experience__item::before {
  content: "";
  position: absolute;
  left: 10rem;
  top: 50%;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--text-muted);
  transform: translate(-50%, -50%);
  transition: background-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}

.experience__item:hover::before {
  background: var(--accent);
  box-shadow: 0 0 8px 1px rgba(175, 199, 255, 0.6);
}

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

.experience__item:hover {
  background: var(--bg-elevated);
}

.experience__period {
  color: var(--text-muted);
  font-size: 0.9rem;
  transition: color 0.3s var(--ease);
}

.experience__item:hover .experience__period {
  color: var(--accent);
}

.experience__role {
  font-family: var(--font-display);
  font-size: 1.2rem;
  margin: 0 0 0.35rem;
  transition: color 0.3s var(--ease);
}

.experience__item:hover .experience__role {
  color: var(--accent);
}

.experience__desc {
  margin: 0;
  color: var(--text-secondary);
  font-size: 0.95rem;
  max-width: 52ch;
}

.experience__org {
  font-size: 0.9rem;
  color: var(--text-muted);
  text-align: right;
}

/* ==========================================================================
   Contact
   ========================================================================== */

.contact {
  text-align: center;
  padding-top: var(--section-space);
  padding-bottom: calc(var(--section-space) * 1.2);
}

.contact__eyebrow {
  color: var(--text-label);
  margin: 0 0 1rem;
  transition: color 0.6s linear;
}

.contact__headline {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(2rem, 5vw, 3.5rem);
  line-height: 1.2;
  margin: 0 0 3rem;
}

.contact__cta {
  display: flex;
  justify-content: center;
  gap: 1rem;
  flex-wrap: wrap;
  margin: 0 0 3rem;
}

.contact__links {
  display: flex;
  justify-content: center;
  gap: clamp(1.5rem, 4vw, 3rem);
  flex-wrap: wrap;
  font-size: 1.05rem;
}

.contact__links a {
  position: relative;
  padding-bottom: 3px;
  transition: color 0.25s var(--ease);
}

.contact__links a::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.3s var(--ease);
}

.contact__links a:hover,
.contact__links a:focus-visible {
  color: var(--accent);
}

.contact__links a:hover::after,
.contact__links a:focus-visible::after {
  transform: scaleX(1);
}

/* ==========================================================================
   Footer
   ========================================================================== */

.footer {
  padding: 2rem var(--gutter) 3rem;
  text-align: center;
  color: var(--text-muted);
  font-size: 0.85rem;
}

/* ==========================================================================
   Custom cursor — a bright core that tracks the pointer tightly, and a
   softer aura that lags a beat behind it for a trailing, energy-particle
   feel. Both are independent fixed elements (script.js positions each with
   its own transform) so the trail is real, not just a CSS transition.
   Only ever created (in script.js) on fine-pointer devices, so touch never
   pays for it; this CSS just hides the native cursor while it's active.
   ========================================================================== */

html.has-custom-cursor,
html.has-custom-cursor a,
html.has-custom-cursor button {
  cursor: none;
}

.cursor-core,
.cursor-aura,
.cursor-spark {
  position: fixed;
  top: 0;
  left: 0;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  pointer-events: none;
  will-change: transform;
}

.cursor-core {
  z-index: 1000;
  width: 12px;
  height: 12px;
  background: #f3f8ff;
  box-shadow: 0 0 16px 3px rgba(175, 199, 255, 0.85);
  transition: width 0.25s var(--ease), height 0.25s var(--ease);
}

.cursor-aura {
  z-index: 999;
  width: 68px;
  height: 68px;
  background: radial-gradient(circle, rgba(175, 199, 255, 0.32), transparent 72%);
  transition: width 0.35s var(--ease), height 0.35s var(--ease),
    background 0.35s var(--ease);
}

/* A single small trailing "spark" — the third, slowest-settling point in
   the core → aura → spark chain, giving a faint afterimage without ever
   spawning more than these three fixed elements. */
.cursor-spark {
  z-index: 998;
  width: 4px;
  height: 4px;
  background: rgba(175, 199, 255, 0.8);
  box-shadow: 0 0 6px 1px rgba(175, 199, 255, 0.6);
}

/* Generic interactive hover (links, buttons) */
.cursor-aura.is-hovering {
  width: 92px;
  height: 92px;
  background: radial-gradient(circle, rgba(175, 199, 255, 0.45), transparent 72%);
}

.cursor-core.is-hovering {
  width: 7px;
  height: 7px;
}

/* Project-card hover — cursor becomes a small lens with a "View" cue */
.cursor-aura.is-project {
  width: 120px;
  height: 120px;
  background: radial-gradient(circle, rgba(175, 199, 255, 0.16), transparent 75%);
  border: 1px solid rgba(175, 199, 255, 0.4);
}

.cursor-aura__label {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-body);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  color: var(--text);
  opacity: 0;
  transition: opacity 0.25s var(--ease);
}

.cursor-aura.is-project .cursor-aura__label {
  opacity: 1;
}

/* ==========================================================================
   Reveal-on-scroll
   ========================================================================== */

.reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 0.7s var(--ease), transform 0.7s var(--ease);
  /* Elements in a rendered list (project cards, tech-stack rows, timeline
     entries, …) get --stagger-index set inline by script.js, so the group
     cascades in rather than arriving as one flat block. Plain .reveal
     elements never set it, so this is a no-op for them. */
  transition-delay: calc(var(--stagger-index, 0) * 0.08s);
}

.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* The section-label accent line "draws" in (width, not fade) as its header
   enters the viewport — driven by the same IntersectionObserver as .reveal. */
.reveal-line {
  width: 0;
}

.reveal-line.is-visible {
  width: 2.5rem;
}

/* Word-by-word reveal for the site's few big cinematic statements (hero
   name, contact headline, interlude line) — each .reveal-word span stays
   hidden until its wrapper gains .is-visible, then plays the same
   fade/lift-in used for the hero name, staggered by --i. */
.reveal-word {
  display: inline-block;
  opacity: 0;
}

.word-reveal.is-visible .reveal-word {
  animation: heroReveal 0.7s var(--ease) forwards;
  animation-delay: calc(var(--i, 0) * 0.08s);
}

/* ==========================================================================
   Focus visibility
   ========================================================================== */

a:focus-visible,
button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* ==========================================================================
   Responsive
   ========================================================================== */

@media (max-width: 860px) {
  /* Tablet: simplify the environment rather than remove it — a smaller
     moon/hill band and slightly smaller platforms, so they read as detail
     rather than competing with the (now narrower) content column. */
  .moon {
    box-shadow: 0 0 40px 12px rgba(175, 199, 255, 0.14), 0 0 90px 40px rgba(140, 160, 220, 0.06);
  }

  .hills {
    height: 16vh;
  }

  /* Fewer ledges on tablet — the clamp()s on .platform already shrink the
     rest; just drop the lowest ledge on each side. */
  .platform--l3,
  .platform--r3 {
    display: none;
  }

  .nav__links {
    display: none;
  }

  .nav__toggle {
    display: flex;
  }

  /* The nav is taller here (~101px: the Resume button plus its own
     padding, vs. a plain text row on desktop) than the 5.5rem (88px)
     clearance .section assumes — short by just enough that a jump
     straight to a section (nav click, or scrollIntoView) tucked its
     heading a few pixels behind the fixed nav. */
  .section {
    scroll-margin-top: 6.5rem;
  }

  .interlude {
    min-height: 50vh;
  }

  .about__grid {
    grid-template-columns: 1fr;
  }

  /* Currently Working On already uses repeat(auto-fit, minmax(...)), so it
     collapses to a single column on its own here. Education and Experience
     are the two intentionally kept as timelines/tables. */
  .education__item,
  .experience__item {
    grid-template-columns: 1fr;
    gap: 0.35rem;
    margin: 0;
  }

  /* About's stat strip uses left-border dividers between inline siblings,
     which only makes sense side by side — stack them instead, with a top
     divider so a wrapped item never shows a stray leftover border-left. */
  .about__stats {
    flex-direction: column;
    gap: 1.25rem;
  }

  .about__stat {
    padding-left: 0;
    padding-top: 1.25rem;
    border-left: none;
    border-top: 1px solid var(--line-strong);
  }

  .about__stat:first-child {
    padding-top: 0;
    border-top: none;
  }

  /* The ghost-number's column is sized for the desktop category text;
     shrink it a touch on a phone. */
  .stack__group {
    grid-template-columns: 2rem 1fr;
  }

  .stack__index {
    font-size: 1.5rem;
  }

  .project {
    padding: 1.5rem;
    border-radius: 16px;
  }

  .education__list::before,
  .education__item::before,
  .experience__list::before,
  .experience__item::before {
    display: none;
  }

  /* No hover on touch — the note stays visible rather than hidden behind
     a gesture nobody will discover. */
  .stack__note {
    max-width: none;
    opacity: 1;
    overflow: visible;
    white-space: normal;
  }

  .experience__org {
    text-align: left;
  }
}

/* Mobile: keep the cat and just one ledge per side — a small sign of the
   world rather than a competing layer — and shrink the hills. cat.js reads
   the same breakpoint to slow down how often it hops.
   --l3/--r3 are already hidden by the 860px query above (still in effect
   here too, since this is a narrower max-width), so this only needs to drop
   the second ledge on each side — l2/r2 — and must NOT also hide l1, or the
   left side is left with zero visible ledges (l1 hidden here, l2 hidden
   here, l3 hidden above) while the right keeps r1. With no ledge to land
   on, cat.js's visiblePlatforms() had nothing left to jump the cat to. */
@media (max-width: 640px) {
  .platform--l2,
  .platform--r2 {
    display: none;
  }

  /* Three ledges on phone instead of two — --l3 is hidden by the 860px
     query above; bring it back here so the cat has a real 3-way rotation
     to jump between instead of ping-ponging the same two spots every time.
     --l1/--r1 are both "top tier" ledges on desktop (21vh/28vh, only 7vh
     apart) — fine with 4 others filling the rest of the height, but reads
     as one cramped cluster with only two left. Spread all three evenly
     across the full height instead. */
  .platform--l1 {
    top: 22vh;
  }

  .platform--r1 {
    top: 52vh;
  }

  .platform--l3 {
    display: block;
    top: 82vh;
  }

  .hills {
    height: 12vh;
    min-height: 90px;
  }

  .cat {
    height: clamp(52px, 15vw, 70px);
  }
}

/* A phone in landscape is short (~375–430px tall) but the hero's content
   (status chip + name + tagline + CTAs) is sized for a tall, portrait
   viewport — vertically centered there, it overflows a short one both
   upward (the status chip ends up behind the fixed nav) and downward (the
   scroll hint collides with the CTA buttons). Compact and top-align it
   instead, and drop the scroll hint — there's no room for a decorative cue
   when the whole hero barely fits already. */
@media (max-height: 500px) and (max-width: 900px) {
  .hero {
    justify-content: flex-start;
    padding-top: 6rem;
    padding-bottom: 1.5rem;
  }

  .hero__status {
    margin-bottom: 0.75rem;
  }

  .hero__name {
    font-size: clamp(2rem, 6vw, 3rem);
    margin-bottom: 0.5rem;
  }

  .hero__tagline {
    margin-bottom: 1rem;
  }

  .hero__scroll {
    display: none;
  }
}

/* ==========================================================================
   Reduced motion
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  .reveal {
    opacity: 1;
    transform: none;
    filter: none;
    transition: none;
  }

  .section,
  .interlude {
    animation: none;
    transform: none;
    opacity: 1;
  }

  /* Specificity note: .word-reveal.is-visible .reveal-word (below, in the
     normal-motion rules) is more specific than a bare .reveal-word, so it
     would otherwise keep winning here and the animation would still run
     mid-way. !important is the deliberate, narrow fix for that — reduced
     motion must always win. */
  .reveal-word {
    opacity: 1 !important;
    animation: none !important;
  }

  .hero__scroll-line::after,
  .hero__status-dot,
  .current__status::before,
  .star,
  .star--mote {
    animation: none;
  }

  .star {
    opacity: 0.4;
  }

  .star--mote {
    opacity: 0.35;
  }

  .bg-light,
  .cursor-spark,
  .shooting-star {
    display: none;
  }

  .clouds::before,
  .clouds::after,
  .platform::after,
  .platform--glow::after,
  .lantern {
    animation: none;
  }

  /* The cat stays (simplified, not removed): one still frame per section,
     placed instantly on scene change, no blink/look/hop/poke — see cat.js,
     which checks the same prefers-reduced-motion query before scheduling
     any of those. */
  .cat {
    transition: none;
    animation: none;
    filter: none;
  }

  .platform {
    transition: none;
  }

  /* Staged hero/word reveals and the section-label line-draw become instant
     rather than animated. */
  .hero-reveal,
  .hero__name-word {
    opacity: 1;
    transform: none;
    filter: none;
    animation: none;
  }

  .reveal-line {
    width: 2.5rem;
    transition: none;
  }

  .project,
  .project__name,
  .project::before,
  .current__item,
  .about__stat,
  .stack__group,
  .experience__item,
  .experience__item::before,
  .education__item,
  .education__item::before,
  .education__institution,
  .education__period,
  .education__score,
  .hero__name,
  .hero__name-word,
  .hero__tagline,
  .hero__status,
  .btn,
  .btn::before,
  .nav,
  .nav-mobile,
  .nav__toggle span,
  .nav__links a,
  .nav:not(.is-solid) .nav__links a,
  .hero__scroll,
  .stack__item,
  .stack__item::before,
  .stack__note,
  .section__label-line,
  .hills,
  .bg-overlay {
    transition: none;
  }

  .project:hover,
  .current__item:hover {
    transform: none;
  }
}
