/* ==========================================================================
   Section layout
   Long sections + position:sticky inner wrappers are the structural pattern
   throughout: the section supplies scroll distance, the sticky child stays
   put while GSAP scrubs against it.
   ========================================================================== */

/* ------------------------------- Navigation ------------------------------ */

/* Colour is switched per-section by a ScrollTrigger rather than with
   mix-blend-mode: difference. Blending against arbitrary photography produces
   muddy, unpredictable contrast — over a mid-tone image the label washes out
   entirely. An explicit two-state flip is legible everywhere. */
.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  height: var(--nav-height);
  color: var(--color-white);
  transition: transform .5s var(--ease-snappy), color .4s var(--ease-snappy);
}
.nav--hidden { transform: translateY(-100%); }
.nav--on-light { color: var(--color-black); }
.nav.is-menu-open { color: var(--color-black); }

/* A floating capsule rather than a full-bleed bar, filled with the theme's
   accent. Because the fill is now always the backdrop, the labels are pinned
   to the light ground here — they no longer need the per-section light/dark
   flip, which answered to whatever section sat behind a transparent bar. */
.nav__inner {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  height: calc(100% - 2rem);
  margin: 1rem var(--base-padding);
  padding-inline: 1.2rem;
  background: var(--color-accent);
  color: var(--color-white);
  border-radius: var(--radius-pill);
  /* The hero panel is now the same accent as this pill, so without a rim the
     bar dissolves into it and the links float on bare colour. A light inset
     ring delineates it there and reads as a highlight everywhere else. */
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-white) 34%, transparent);
  transition: background .4s var(--ease-snappy);
}

/* The menu button gets its own capsule nested in the bar, outlined in the
   ground colour since an accent outline would vanish into the fill. */
.nav__menu {
  justify-self: start;
  display: inline-flex;
  align-items: center;
  padding: 1rem 2rem;
  border: 1px solid color-mix(in srgb, var(--color-white) 45%, transparent);
  border-radius: var(--radius-pill);
  transition: background .4s var(--ease-snappy), border-color .4s var(--ease-snappy);
}
.nav__menu:hover {
  border-color: var(--color-white);
  background: color-mix(in srgb, var(--color-white) 14%, transparent);
}
.nav__burger { display: block; width: 2.4rem; }
.nav__burger i {
  display: block;
  height: 1.5px;
  background: currentColor;
  transition: transform .5s var(--ease-snappy);
}
.nav__burger i + i { margin-top: .6rem; }
.nav.is-menu-open .nav__burger i:first-child { transform: translateY(.4rem) rotate(45deg); }
.nav.is-menu-open .nav__burger i:last-child  { transform: translateY(-.35rem) rotate(-45deg); }

.nav__mark {
  justify-self: center;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.8rem;
  letter-spacing: -0.02em;
}
.nav__mark-initial { display: none; }

.nav__cta {
  justify-self: end;
  display: inline-flex;
  align-items: center;
  gap: .6rem;
  font-size: 1.6rem;
  font-weight: 500;
  letter-spacing: -0.02em;
}
.nav__cta-arrow { transition: var(--link-transition); }
.nav__cta:hover .nav__cta-arrow { transform: translate(.3rem, -.3rem); }

@media (max-width: 700px) {
  .nav__mark-name { display: none; }
  .nav__mark-initial { display: block; }
  /* The capsule has to hold three items on a narrow bar, so tighten its
     insets before the centre mark starts colliding with the edges. */
  .nav__inner { margin-inline: 1.2rem; padding-inline: .6rem; }
  .nav__menu { padding: .8rem 1.2rem; }
}

/* --------------------------------- Menu ---------------------------------- */

.menu {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: var(--color-white);
  color: var(--color-black);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: calc(var(--nav-height) + 4rem) var(--base-padding) 4rem;
  clip-path: inset(0 0 100% 0);
  visibility: hidden;
  transition: clip-path .8s var(--ease-snappy), visibility 0s linear .8s;
}
.menu.is-open {
  clip-path: inset(0 0 0 0);
  visibility: visible;
  transition: clip-path .8s var(--ease-snappy), visibility 0s;
}

/* --- Menu artwork ---------------------------------------------------------
   The 鍾 piece sits in the empty right side of the overlay, drawn on a
   transparent canvas so it reads as an object resting on the page rather than
   as a framed panel. The metal is kept in the dark-to-mid range for exactly
   that reason — see the gradient note in menu-art.js.

   Square, because the WebGL scene is square; the driver in menu-art.js sizes
   the drawing buffer from the smaller side of this box. */
.menu__zhong {
  position: absolute;
  /* Centred on 46%, not 50%: the usable band is between the nav pill and the
     meta row, and that band's middle sits above the viewport's. */
  top: 46%;
  right: var(--base-padding);
  transform: translateY(-50%);
  /* Sized to fill the empty right side. The vh term is the binding one on a
     normal laptop and is what keeps the bottom clear of the meta rule. */
  width: min(48vw, 64vh);
  aspect-ratio: 1;
  height: auto;
  z-index: 0;
  pointer-events: none;
  /* No plate and no frame: the canvas is transparent now, so the glyph sits
     directly on the menu's ground. */
  /* Fades in behind the panel drop rather than appearing with it, so the
     overlay lands first and the artwork arrives a beat later. */
  opacity: 0;
  transition: opacity .6s var(--ease-snappy);
}
/* [data-ready] is set by menu-art.js only after the first successful render.
   Without it the panel stays invisible, so a browser with no WebGL gets the
   empty space it had before rather than an unexplained black square. */
.menu.is-open .menu__zhong[data-ready] {
  opacity: 1;
  transition-delay: .35s;
}

/* Below this the links and the meta rows want the whole panel, and a 38vw
   square would either crowd them or shrink to a token. */
@media (max-width: 900px) {
  .menu__zhong { display: none; }
}

/* The links must sit above the artwork. */
.menu__links, .menu__meta { position: relative; z-index: 1; }

.menu__links { display: flex; flex-direction: column; gap: 1rem; }
.menu__link {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(3.6rem, 6vw, 6.4rem);
  line-height: 1.05;
  letter-spacing: -0.03em;
  opacity: 0;
  transform: translateY(var(--title-from-y));
}
.menu.is-open .menu__link {
  opacity: 1;
  transform: none;
  transition: opacity var(--title-duration) var(--ease-snappy),
              transform var(--title-duration) var(--ease-snappy);
}
.menu.is-open .menu__link:nth-child(1) { transition-delay: calc(var(--stack-delay) + 0 * var(--title-stagger)); }
.menu.is-open .menu__link:nth-child(2) { transition-delay: calc(var(--stack-delay) + 1 * var(--title-stagger)); }
.menu.is-open .menu__link:nth-child(3) { transition-delay: calc(var(--stack-delay) + 2 * var(--title-stagger)); }
.menu.is-open .menu__link:nth-child(4) { transition-delay: calc(var(--stack-delay) + 3 * var(--title-stagger)); }
/* Written out per position because CSS cannot multiply by an element's own
   index. Two spare rules beyond the current five, so adding a menu item does
   not silently drop it to delay 0 — which reads as that one link flashing in
   ahead of the rest rather than as a missing rule. */
.menu.is-open .menu__link:nth-child(5) { transition-delay: calc(var(--stack-delay) + 4 * var(--title-stagger)); }
.menu.is-open .menu__link:nth-child(6) { transition-delay: calc(var(--stack-delay) + 5 * var(--title-stagger)); }
.menu.is-open .menu__link:nth-child(7) { transition-delay: calc(var(--stack-delay) + 6 * var(--title-stagger)); }

.menu__meta {
  display: flex;
  gap: 6rem;
  flex-wrap: wrap;
  padding-top: 4rem;
  border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
}
.menu__meta .t-eyebrow { margin-bottom: .8rem; }

/* --- Theme swatches ------------------------------------------------------
   Each button carries its own palette as inline --sw-* values so the chip
   previews the scheme without the theme being applied.
   ------------------------------------------------------------------------ */

.menu__themes { flex: 1 1 24rem; }
.themes { display: flex; flex-wrap: wrap; gap: .8rem; }
.theme {
  display: inline-flex;
  align-items: center;
  gap: .8rem;
  padding: .8rem 1.4rem .8rem .8rem;
  border-radius: var(--radius-pill);
  font-size: 1.4rem;
  font-weight: 500;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 20%, transparent);
  transition: box-shadow .3s var(--ease-snappy), background .3s var(--ease-snappy);
}
.theme:hover { box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 45%, transparent); }
.theme.is-active {
  background: color-mix(in srgb, currentColor 8%, transparent);
  box-shadow: inset 0 0 0 1.5px currentColor;
}

/* Ground with an accent quadrant — enough to read the scheme at 20px. */
.theme__chip {
  width: 2rem;
  height: 2rem;
  border-radius: 50%;
  background:
    conic-gradient(var(--sw-accent) 0deg 90deg, var(--sw-ink) 90deg 180deg, var(--sw-ground) 180deg 360deg);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 25%, transparent);
}

/* --------------------------------- Hero ---------------------------------- */

/* 100vh pin + 40vh of scroll-away. The tail was a full screen, and since the
   content fades out across the pin, most of it was empty. */
.hero { height: 140vh; position: relative; }
.hero__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
  display: flex;
  align-items: center;
}

/* Starts inset with big rounded corners, scrubs open to full-bleed. */
.hero__media {
  position: absolute;
  inset: 0;
  border-radius: var(--radius-media);
  overflow: hidden;
  background: var(--color-accent);
  will-change: transform, border-radius;
}
/* Centre-weighted scrim, keyed to the theme's ink rather than a fixed
   blue-black so it stays coherent when the palette changes. The photography is
   light in places and the headline sits over the middle of it, so a plain
   bottom gradient isn't enough to carry the type. */
/* .hero__scrim was removed along with the photograph — it existed to carry
   cream type over photography, which a flat accent panel does not need. */

.hero__content {
  position: relative;
  z-index: 2;
  width: 100%;
  text-align: center;
  color: var(--color-white);
}
/* Sized so the longest authored line still fits on one row — splitLines()
   breaks on the <br>s, so a line that wrapped would reveal two rows as a
   single unit and lose the stagger. */
.hero__title {
  max-width: 24ch;
  margin-inline: auto;
  font-size: clamp(2.8rem, 5.6vw, 9rem);
}
/* Generous, because .lt trims the headline's box to its baseline and the
   descenders on "energy-storage" would otherwise crowd this line. */
.hero__sub { margin-top: 3.2rem; opacity: .75; }

@media (max-width: 600px) {
  .hero__title { font-size: clamp(2.2rem, 6.6vw, 3.4rem); }
}

/* --- Audience switcher ---------------------------------------------------
   Plain text above the statement it rewrites — no chrome, so it reads as a
   quiet aside rather than competing with the headline. Selection is carried
   by opacity alone: the active label sits at full strength and the rest fall
   back, which is legible over photography without needing a border or fill.
   ------------------------------------------------------------------------ */

.audience {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 2.8rem;
  margin-bottom: 3.2rem;
}
.audience__btn {
  font-size: 1.7rem;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--color-white);
  opacity: .45;
  transition: opacity .3s var(--ease-snappy);
}
.audience__btn:hover { opacity: .8; }
.audience__btn.is-active { opacity: 1; }

@media (max-width: 700px) {
  .audience { gap: 1.6rem; margin-bottom: 2.4rem; }
  .audience__btn { font-size: 1.4rem; }
}

.hero__scrollcue {
  position: absolute;
  right: var(--base-padding);
  bottom: 12vh;
  z-index: 2;
  color: var(--color-white);
  display: flex;
  align-items: center;
  gap: 1.2rem;
  opacity: .7;
}
.hero__scrollcue-line {
  display: block;
  width: 6rem;
  height: 1px;
  background: currentColor;
  transform-origin: left center;
  animation: cue 2.4s var(--ease-in-out-quart) infinite;
}
@keyframes cue {
  0%, 100% { transform: scaleX(.3); }
  50%      { transform: scaleX(1); }
}

@media (max-width: 700px) {
  .hero__scrollcue { display: none; }
}

/* ------------------------------ Selected work ----------------------------
   Image reel. The section supplies (count + 1) screens of scroll distance;
   its sticky child holds still while JS maps that progress onto each item's
   position along a bottom-right → centre → top-left path.
   ------------------------------------------------------------------------ */

/* --reel-bg / --reel-fg are written by reel() as the section crossfades from
   the blue arrival panel to cream. They fall back to the page colours, which
   is what the stacked and no-JS cases get. */
.reel {
  position: relative;
  background: var(--reel-bg, var(--bg));
  color: var(--reel-fg, var(--fg));
  height: calc((var(--reel-count) + 2) * 100vh);
}

/* The lifting panel. Sits above everything in the sticky and slides up out of
   frame, uncovering the blue arrival panel and the heading beneath it. */
.reel__lift {
  position: absolute;
  inset: 0;
  z-index: 6;
  background: var(--color-white);
  border-radius: 0 0 4.8rem 4.8rem;
  will-change: transform;
}
.reel__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

/* Sized by fitText() at runtime rather than a fixed clamp, so the word fills
   the width whatever it says. --hx/--hy carry its scroll-driven offset; the
   -50% stays in CSS so the two never fight over one transform string. */
.reel__heading {
  position: absolute;
  inset: 0 var(--base-padding);
  z-index: 4;
  margin: 0;
  /* Flex-centred rather than top:50% + translateY(-50%), so the scroll-driven
     transform is pure length + scale. Mixing a percentage translate with a
     scale makes the centring drift as the scale changes. */
  display: flex;
  align-items: center;
  justify-content: center;
  white-space: nowrap;
  line-height: 0.85;
  --hx: 0px;
  --hy: 0px;
  --hs: 1;
  transform: translate3d(var(--hx), var(--hy), 0) scale(var(--hs));
  transform-origin: center center;
  will-change: transform, opacity;
  /* Purely decorative, and it spans the whole stage at z-index 4 — directly
     over where each project's copy sits. Fading it to 0 hides it but leaves it
     hit-testing, so it was intercepting clicks on the active project's
     "View project" link. Nothing in here is interactive. */
  pointer-events: none;
}

.reel__stage { position: absolute; inset: 0; }

/* The item generates no box, so its media and copy each position against the
   stage independently — which is what keeps the copy free of the media's
   scale. On small screens it becomes a real block again and the natural
   image-then-text order falls out for free. */
.reel__item { display: contents; }

/* --- Media --------------------------------------------------------------- */

.reel__media {
  position: absolute;
  top: 50%;
  left: 50%;
  width: min(44vw, 62vh);
  aspect-ratio: 1 / 1.15;
  margin: 0;
  border-radius: var(--radius-media);
  overflow: hidden;
  background: color-mix(in srgb, var(--fg) 8%, transparent);
  /* JS writes translate/scale into these; the -50% centring stays in CSS so
     the two never fight over the same transform string. */
  --rx: 0px;
  --ry: 0px;
  --rs: 1;
  transform: translate3d(-50%, -50%, 0) translate3d(var(--rx), var(--ry), 0) scale(var(--rs));
  transform-origin: center center;
  will-change: transform, opacity;
}
.reel__media img { width: 100%; height: 100%; object-fit: cover; }

/* The reel frame is portrait (1:1.15) but the Jeep shot is 1440x678 — a 2.12:1
   landscape — so cover keeps only the middle ~41% of it. Centred, that clips
   the front wheel against the frame edge; nudging the window right by 6 points
   holds the whole front of the vehicle and still reads the dust plume. */
.reel__media img[src$="jeep.jpg"] { object-position: 56% 50%; }

/* --- Copy ---------------------------------------------------------------- */

.reel__copy {
  position: absolute;
  top: 50%;
  right: var(--base-padding);
  width: min(34rem, 26vw);
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  gap: 1.2rem;
  opacity: 0;
  pointer-events: none;
  will-change: opacity;
}
.reel__copy.is-active { pointer-events: auto; }
.reel__index { opacity: .45; font-variant-numeric: tabular-nums; }
.reel__meta { opacity: .55; }
.reel__desc { opacity: .8; }
.reel__link { margin-top: .8rem; font-weight: 550; }

/* --- Corner labels ------------------------------------------------------- */

.reel__labels {
  position: absolute;
  left: var(--base-padding);
  right: var(--base-padding);
  bottom: 3rem;
  z-index: 4;
  display: flex;
  justify-content: space-between;
  opacity: .55;
  pointer-events: none;
}
.reel__counter { font-variant-numeric: tabular-nums; }

/* --- Hero traces (every stacked width) -----------------------------------
   sections.js moves three Background traces in here so they back the headline
   instead of stacking below it. Applies at the same 900px breakpoint the
   Background section uses for its stacked fallback: above that the traces are
   the scroll-driven stage and belong where they are; at or below it they would
   otherwise pile up under the hero on their own.
   ------------------------------------------------------------------------ */

.hero__traces { display: none; }

@media (max-width: 900px) {
  .hero__traces {
    display: block;
    position: absolute;
    inset: 0;
    pointer-events: none;
    /* No z-index needed: this is inside .hero__media, which already sits
       below .hero__content. Clipping comes from the panel's own overflow. */
  }

  /* INK, not cream. Drawn in the page's ink the strokes sit darker than the
     accent panel and read as line art; in cream they only ever lightened the
     ground and washed out however opaque they got. Weights are back to
     hairline — the earlier attempt at "bolder" thickened the strokes, which
     made them heavier without making them any clearer. */
  .hero__traces .bg__art {
    position: absolute;
    /* All four offsets reset. The desktop rules set top/left (or top/right)
       and those leak in here — with both top and bottom specified the element
       is over-constrained and top silently wins, which left optimus floating
       119px above the corner it was told to sit in. */
    top: auto;
    right: auto;
    bottom: auto;
    left: auto;
    margin: 0;
    width: auto;
    height: auto;
    /* Full-strength ink, hairline weight — crisp line art on colour rather
       than a wash. Measured contrast against the accent panel:

         electric 4.26   ember 4.35   moss 3.47   dusk 2.84

       against 1.6-1.8 for the earlier .38 wash, which is why nothing popped.
       Lightening was the other option and it cannot work here: a cream trace
       lands within 1.6 of the cream headline, so the two compete for the same
       value and the type stops leading. Ink keeps the headline 15-16x clear.

       Dusk is the floor at 2.84 — its accent is the darkest of the four, so
       dark-on-dark has least room. These are decorative and aria-hidden, so
       no text-contrast rule applies. */
    opacity: 1;
    /* Per-theme; see --trace-ink in tokens.css for the measured values. */
    color: var(--trace-ink, var(--color-black));
    visibility: visible;
    transform: none;
    --trace-outline: 1.5;
    --trace-primary: 1.2;
    --trace-detail: 1.1;
    --face-reveal: 1;
  }

  /* Sized against BOTH axes. On a landscape tablet a vw-only width made the
     corvette 700px wide and taller than the viewport, so it filled the frame
     and buried the headline. The vh term caps it by height; whichever limit
     bites first wins. Aspect ratios are the traces' own — a mismatched box
     letterboxes the SVG and the drawing drifts off its placement. */
  .hero__traces .bg__art--corvette {
    aspect-ratio: 1362 / 1155;
    width: min(76%, 34vh * 1.1792);
    left: 2%;
    top: 3%;
  }

  /* No fade: dropping optimus into the corner moved it clear of the truck, so
     the mask that used to keep them from tangling had nothing left to solve. */
  .hero__traces .bg__art--cybertruck {
    aspect-ratio: 784 / 900;
    width: min(60%, 38vh * 0.8711);
    right: -10%;
    top: 42%;
  }
  .hero__traces .bg__art--optimus {
    aspect-ratio: 1045 / 783;
    width: min(70%, 30vh * 1.3346);
    left: 0;
    bottom: 0;                  /* hard into the panel's bottom-left corner */
  }

  /* Dropped from the stacked layout entirely — they are not moved to the hero,
     so leaving them in flow would strand two lone drawings in Background. */
  .bg__art--charger,
  .bg__art--cell { display: none; }
}

/* --- Stacked fallback ----------------------------------------------------
   The reel needs viewport height and horizontal room to read, and it only
   advances if scroll is driving it. Small screens and reduced-motion both
   fall back to a plain stacked list — reduced motion especially, since
   without the scrub every project after the first would sit parked
   off-stage as an invisible thumbnail forever.
   ------------------------------------------------------------------------ */

@media (max-width: 900px), (prefers-reduced-motion: reduce) {
  /* Release the compositor hints. In this mode the reel is a plain stacked
     list — nothing here animates — but the standing will-change declarations
     above still promoted every media frame, copy block and the heading to
     its own retained layer for the lifetime of a very tall document. That is
     pure memory on exactly the devices least able to spare it. */
  .reel__lift,
  .reel__heading,
  .reel__media,
  .reel__copy { will-change: auto; }

  .reel { height: auto !important; padding: 8rem 0; }
  .reel__sticky { position: static; height: auto; overflow: visible; }
  .reel__heading {
    position: static;
    display: block;
    inset: auto;
    margin: 0 0 4rem;
    padding-inline: var(--base-padding);
    text-align: left;
    white-space: normal;
    line-height: 1;
    transform: none;
  }
  .reel__stage { position: static; }
  .reel__item { display: block; }

  .reel__media {
    position: static;
    width: auto;
    margin: 0 var(--base-padding) 2.4rem;
    aspect-ratio: 4 / 3;
    transform: none !important;
    opacity: 1 !important;
  }
  .reel__copy {
    position: static;
    width: auto;
    margin: 0 var(--base-padding) 6rem;
    transform: none;
    opacity: 1 !important;
    pointer-events: auto;
  }
  .reel__labels,
  .reel__lift { display: none; }
}

/* ------------------------------- Background ------------------------------
   A pinned stage: the section supplies the scroll distance, its sticky child
   holds still, and each role takes the screen with its own artwork. Height
   comes from --role-count in the markup.
   ------------------------------------------------------------------------ */

/* Arrives as a rounded panel sliding up over the hero. The negative margin is
   the whole effect: the section overlaps the hero's last few centimetres and
   its own scroll carries it up, so there is no extra scroll distance and no
   separate stage to sit through. */
.bg {
  position: relative;
  z-index: 2;
  height: calc((var(--role-count) + 1) * 100vh);
  margin-top: calc(var(--radius-media) * -1);
  border-radius: var(--radius-media) var(--radius-media) 0 0;
  background: var(--bg);
}

.bg__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

/* --- Artwork -------------------------------------------------------------
   Each piece is absolutely placed and cross-fades with its role. Several
   deliberately run past the viewport edge — the sources are cropped
   compositions, so bleeding continues that rather than fighting it.
   ------------------------------------------------------------------------ */

.bg__art {
  --trace-outline: 3.4;
  --trace-primary: 2.7;
  --trace-detail: 2.5;
  --face-reveal: 0;
  position: absolute;
  opacity: 0;
  visibility: hidden;
  color: var(--fg);
  transition: opacity .5s var(--ease-snappy), visibility 0s linear .5s;
  pointer-events: none;
}
.bg__art.is-active {
  opacity: 1;
  visibility: visible;
  transition: opacity .5s var(--ease-snappy), visibility 0s;
}

/* Strokes are a FIXED pixel weight (non-scaling-stroke), so on a large display
   the scaled-up drawing makes a 2px line read as a hairline. Bump the weight in
   step with the viewport so the line-art keeps the same visual heft everywhere. */
@media (min-width: 1300px) {
  .bg__art { --trace-outline: 4; --trace-primary: 3.2; --trace-detail: 3; }
}
@media (min-width: 1700px) {
  .bg__art { --trace-outline: 4.8; --trace-primary: 3.9; --trace-detail: 3.6; }
}
@media (min-width: 2200px) {
  .bg__art { --trace-outline: 5.6; --trace-primary: 4.6; --trace-detail: 4.2; }
}

.bg__art svg { width: 100%; height: 100%; display: block; overflow: visible; }

/* Weights are screen pixels — see non-scaling-stroke. Kept close together:
   the tracer tags paths by length rank, not by whether a contour is genuinely
   outer, so a wide spread puts heavy strokes in arbitrary-looking places. */
.bg__art svg path {
  stroke-width: var(--trace-detail);
  vector-effect: non-scaling-stroke;
}
.bg__art svg path[data-category='primary'] { stroke-width: var(--trace-primary); }
.bg__art svg path[data-category='outline'] { stroke-width: var(--trace-outline); }

/* The faceplate fill is geometry, not strokes, so it must not inherit them. */
.bg__art svg [data-face-fill] { stroke: none; }

/* Sits low enough to clear the section heading, which its spoiler was cutting
   through. The box matches the trace's 1362/1155 aspect so the drawing fills
   it exactly — with a mismatched box the SVG letterboxes and the ink drifts
   away from wherever the box was placed. */
.bg__art--corvette {
  left: -4vw;
  top: 57%;
  transform: translateY(-50%);
  width: var(--corvette-w);
  height: calc(var(--corvette-w) / 1.179);
}

/* Optimus is the Tesla frame's hero: it holds the left and reaches across.
   Width is capped by viewport height so the head is never clipped, and the
   box matches the trace's own 1.335 aspect exactly — any mismatch would
   letterbox the SVG and slide the hand out from under the copy. */
.bg__sticky {
  --optimus-w: min(72vw, 100vh);
  --optimus-x: -6vw;
  --corvette-w: min(54vw, 84vh);
  --cybertruck-w: min(50vw, 66vh);
  --charger-w: min(92vw, 112vh);
  --cell-w: min(26vw, 42vh);
  --tesla-copy-w: min(36vw, 50rem);
}

/* Sits low in the frame: at 50% the head rides up into the section heading. */
.bg__art--optimus {
  left: var(--optimus-x);
  top: 60%;
  transform: translateY(-50%);
  width: var(--optimus-w);
  height: calc(var(--optimus-w) / 1.335);
}

/* General Motors: a second, smaller subject in the opposite corner from the
   Corvette. Deliberately run past the bottom-right edge so it reads as cropped
   rather than parked, and kept clear of the copy's longest line. */
.bg__art--cell {
  right: -9vw;
  bottom: -12vh;
  top: auto;
  width: var(--cell-w);
  height: calc(var(--cell-w) / 0.642);
}

/* Stellantis: head-on and symmetrical, so it is centred rather than pushed to
   an edge, and sized to fill most of the frame. */
.bg__art--charger {
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: var(--charger-w);
  height: calc(var(--charger-w) / 1.687);
}

/* Holds the right the way the Corvette holds the left, bleeding off the edge.
   Box matches the trace's 784/900 aspect so it fills rather than letterboxes. */
.bg__art--cybertruck {
  right: -9vw;
  top: 50%;
  transform: translateY(-50%);
  width: var(--cybertruck-w);
  height: calc(var(--cybertruck-w) / 0.871);
}

/* --- Copy ---------------------------------------------------------------
   The stage spans the whole frame; the copy block moves to sit clear of
   whichever artwork is on screen.
   ------------------------------------------------------------------------ */

.bg__stage { position: absolute; inset: 0; }

.bg__head {
  position: absolute;
  top: calc(var(--nav-height) + 2rem);
  left: 2vw;
  right: var(--base-padding);
}
.bg__note { margin-top: 1.6rem; opacity: .55; }

.bg__roles {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  min-height: 34rem;
  z-index: 1;
  transition: left .6s var(--ease-snappy), width .6s var(--ease-snappy);
}

/* Optimus's hand closes over the copy, so the copy needs to win. A soft-edged
   wash of the page ground lifts it off the linework without reading as a box —
   the strokes still pass behind it, which is what sells the grab. */
.bg__roles::before {
  content: '';
  position: absolute;
  inset: -5rem -7rem;
  z-index: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity .5s var(--ease-snappy);
  background: radial-gradient(ellipse at 45% 50%,
    var(--bg) 0%, var(--bg) 41%, transparent 72%);
}
.bg__sticky[data-active='1'] .bg__roles::before,
.bg__sticky[data-active='2'] .bg__roles::before { opacity: .93; }

.role { z-index: 1; }

/* General Motors: the Corvette holds the left, so the copy sits right. */
.bg__sticky[data-active='0'] .bg__roles { left: 52vw; width: 42vw; }
/* Tesla: viewport-centred, then nudged right. The copy is left-aligned and its
   lines wrap short, so its visual mass sits left of the block's true centre;
   the offset compensates without letting it run under the Cybertruck. */
.bg__sticky[data-active='1'] .bg__roles {
  left: calc(50vw - var(--tesla-copy-w) / 2 + 9vw);
  width: var(--tesla-copy-w);
}
/* Stellantis: the Charger fills the frame behind, so the copy holds the left
   over it rather than beside it. */
.bg__sticky[data-active='2'] .bg__roles { left: 6vw; width: min(40vw, 54rem); }

.role {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  opacity: 0;
  visibility: hidden;
  transform: translateY(2.4rem);
  transition: opacity .5s var(--ease-snappy),
              transform .5s var(--ease-snappy),
              visibility 0s linear .5s;
  border: 0;
  padding: 0;
}
.role.is-active {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition: opacity .5s var(--ease-snappy),
              transform .5s var(--ease-snappy),
              visibility 0s;
}

.role__top {
  display: flex;
  gap: 1.6rem;
  margin-bottom: 2.4rem;
  opacity: .5;
  font-variant-numeric: tabular-nums;
}
/* Logo and company name as one lockup. The mark is inlined SVG rather than
   an <img> so it can take currentColor and follow the theme; the source files
   carried an opaque backing plate and a dozen trace shades, both stripped.
   Colour comes from --fg, not the accent the name uses — a brand mark tinted
   purple or orange reads as decoration rather than as a logo. */
.role__name {
  display: flex;
  align-items: center;
  gap: 1.2rem;
  margin-bottom: 1.6rem;
}
.role__logo {
  flex: none;
  width: auto;
  height: clamp(2.8rem, 3.4vw, 4rem);
  color: var(--fg);
  opacity: .8;
}
.role__company { margin-bottom: 0; }
.role__title { margin-bottom: .8rem; }
.role__place { opacity: .55; margin-bottom: 2rem; }
.role__desc { opacity: .75; max-width: 46ch; }

.bg__counter {
  position: absolute;
  left: 2vw;
  bottom: 3rem;
  opacity: .5;
  font-variant-numeric: tabular-nums;
  /* Optimus's legs run through this corner; the same soft ground wash used
     behind the role copy keeps the counter readable over them. */
  isolation: isolate;
  padding: .6rem 1.4rem;
  margin: -.6rem -1.4rem;
  background: radial-gradient(ellipse at center,
    var(--bg) 0%, var(--bg) 40%, transparent 78%);
}

/* --- Stacked fallback ----------------------------------------------------
   Small screens and reduced motion get every role in flow with its artwork
   complete above it. Without scroll driving it, a one-at-a-time stage would
   strand the other roles off screen.
   ------------------------------------------------------------------------ */

@media (max-width: 900px), (prefers-reduced-motion: reduce) {
  .bg { height: auto !important; padding: 8rem 0; }
  .bg__sticky { position: static; height: auto; overflow: visible; }

  .bg__art {
    --trace-outline: 2.4;
    --trace-primary: 1.9;
    --trace-detail: 1.8;
    --face-reveal: 1;
    position: static;
    opacity: 1;
    visibility: visible;
    transform: none;
    width: auto;
    height: auto;
    margin: 0 var(--base-padding) 3rem;
  }
  .bg__art--corvette   { aspect-ratio: 1362 / 1155; }
  .bg__art--optimus    { aspect-ratio: 1045 / 783; }
  .bg__art--cybertruck { aspect-ratio: 784 / 900; }
  .bg__art--charger    { aspect-ratio: 1162 / 689; }
  .bg__art--cell       { aspect-ratio: 698 / 1087; max-width: 30rem; }

  .bg__stage { position: static; padding: 0 var(--base-padding); }
  .bg__head { position: static; margin-bottom: 4rem; }
  .bg__roles { position: static; transform: none; min-height: 0; width: auto !important; left: auto !important; }
  /* Nothing overlaps the copy when stacked, and with .bg__roles no longer
     positioned the absolute scrim would escape its box and force h-overflow. */
  .bg__roles::before { display: none; }
  .bg__counter { background: none; padding: 0; margin: 0; }

  .role {
    position: static;
    opacity: 1;
    visibility: visible;
    transform: none;
    padding: 4rem 0;
    border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent);
  }
  .role:last-child { border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent); }

  .bg__counter { display: none; }
}

/* ---------------------------- About + Contact ---------------------------- */

.entry { padding-bottom: 14rem; }
.entry__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gutter-width);
}
.entry__card {
  background: var(--bg);
  color: var(--fg);
  border-radius: var(--radius-media);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.entry__card--about {
  background: color-mix(in srgb, var(--color-black) 6%, transparent);
}
/* The contact card is shorter than its sibling; centring its body fills the
   stretched height instead of leaving a void beneath the buttons. */
.entry__card--contact .entry__body { justify-content: center; }
.entry__media { aspect-ratio: 16 / 11; overflow: hidden; }
/* portrait2 is 1094x1262 (0.87) inside a 16/11 frame, so cover keeps only the
   top ~60% of the image. The face sits at 24% of the source; anchoring near the
   top lands it around 38% of the visible window — head clear, crop at mid-torso. */
.entry__media img { width: 100%; height: 100%; object-fit: cover; object-position: center 4%; }
/* flex:1 lets the body fill a card stretched to match its taller sibling,
   which is what gives the contact card's centred content something to centre in. */
.entry__body { padding: 4rem; display: flex; flex-direction: column; gap: 2rem; flex: 1; }
.entry__text { opacity: .8; max-width: 46ch; }
/* Deliberately not margin-top:auto — pinning the buttons to the bottom of a
   card stretched to match its taller sibling leaves a large empty field. */
.entry__actions { display: flex; flex-wrap: wrap; gap: 1.2rem; padding-top: 1.2rem; }

@media (max-width: 900px) {
  .entry { padding-bottom: 8rem; }
  .entry__grid { grid-template-columns: 1fr; }
  .entry__body { padding: 3rem 2.4rem; }
}

/* ---------------------------- Accent headings -----------------------------
   Section and item headings carry the theme accent, so they retint with the
   scheme like the nav pill and the hero.

   Deliberately scoped to headings sitting on the page ground. Measured
   contrast of accent-on-cream across the four schemes is 3.67 - 5.48, all
   clearing AA for large text; accent-on-ink is 2.84 for Dusk, which fails.
   So anything on a dark or accent surface — the reel's giant "Projects" over
   its blue arrival panel, the footer, the hero headline — is left alone.
   ------------------------------------------------------------------------ */

.bg__heading,                 /* "Background" */
.role__company,               /* General Motors / Tesla / Stellantis */
.reel__title,                 /* project titles */
/* :not() rather than naming the About card: the Contact card carries
   .is-accent, so an unguarded rule painted accent-on-accent and the heading
   vanished entirely. The guard also covers any dark or accent card added
   later. */
.entry__card:not(.is-accent):not(.is-dark) h2 {
  color: var(--color-accent);
}

/* --------------------------- Stadium banner -------------------------------
   Full-bleed band after About. The SVG uses preserveAspectRatio="slice" so it
   always spans the full width, trimming top and bottom rather than
   letterboxing when the band is shorter than the artwork's own 1400:933.
   ------------------------------------------------------------------------ */

.stadium {
  position: relative;
  overflow: hidden;
  background: var(--bg);
  color: var(--fg);
  line-height: 0;                       /* kills the inline gap under the svg */
}

.stadium__art {
  width: 100%;
  height: clamp(240px, 52vw, 640px);
}
.stadium__art svg { display: block; width: 100%; height: 100%; }

/* Filled contours, so there is no stroke to weight. The wipe is a single
   clip-path on one already-rasterised element — cheap to re-composite, and
   diagonal so it reads as the drawing arriving rather than a curtain. */
.stadium__art {
  --rev: 0%;
  clip-path: polygon(0 0, var(--rev) 0, calc(var(--rev) - 9%) 100%, 0 100%);
  /* No standing will-change here. As a permanent hint it promoted a dense
     1440x640 drawing to its own layer at load — forcing it to rasterise while
     it was still four screens below the fold, which cost frames during the
     intro. ScrollTrigger only needs the clip while the band is on screen. */
}

/* Breathing room before the About cards. The band used to sit last before the
   footer, which brought its own top padding, so it never needed this; butted
   straight against the cards it read as one block. Matches .entry's own
   padding-bottom so the section is framed evenly top and bottom. */
.stadium { margin-bottom: 14rem; }

@media (max-width: 900px) {
  .stadium { margin-bottom: 8rem; }   /* tracks .entry's mobile padding */
}

@media (max-width: 700px) {
  .stadium__art { height: clamp(200px, 62vw, 320px); }
}

/* ---------------------------- Close (flowers + About/Contact) ------------
   The final section: interactive flowers fill the left half (full-bleed to
   the viewport edge), and the About + Contact cards stack on the right. Dark
   ground because the flowers use additive blending. Hands off dark→dark into
   the footer. -------------------------------------------------------------- */

.close {
  position: relative;
  overflow: hidden;
  color: var(--color-white);
  background: radial-gradient(120% 100% at 30% 45%, #0b0d16 0%, #05060a 72%);
}
/* flowers are a full-section background layer — they flow across the whole
   section (behind the cards), never clipped to a column */
.close__flowers {
  position: absolute;
  inset: 0;
  z-index: 0;
}
.close__hint {
  position: absolute; left: clamp(2rem, 5vw, 8rem); bottom: 3rem;
  margin: 0; color: rgba(245, 244, 223, .38);
  font-size: 1.3rem; letter-spacing: .12em; text-transform: uppercase; line-height: 1;
  pointer-events: none; z-index: 2;
}
/* cards float on top, at the right, aligned to the site's right margin. The
   section's height is driven by the cards + padding, so it fits with no slack. */
.close__cards {
  position: relative;
  z-index: 1;
  width: min(44%, 560px);
  margin-inline-start: auto;
  margin-inline-end: var(--base-padding);
  padding-block: 4.5rem;
  display: flex;
  flex-direction: column;
  gap: 1.6rem;
}
.close__cards .entry__card { flex: 0 0 auto; }
/* defined edges so each card reads as a card floating on the flowers; the
   About card also needs an opaque-enough ground so its text stays legible
   over the particles behind it */
.close__cards .entry__card--about {
  background: color-mix(in srgb, var(--color-black) 55%, transparent);
  border: 1px solid rgba(245, 244, 223, .14);
  backdrop-filter: blur(6px);
}
.close__cards .entry__card--about .entry__media { aspect-ratio: 16 / 8; }
.close__cards .entry__body { padding: 2.4rem; gap: 1.2rem; }

@media (max-width: 900px) {
  .close__cards {
    width: auto;
    margin-inline: var(--base-padding);
    padding-block: 6rem;
  }
}

/* No scroll to drive the wipe, so the drawing is simply present. */
@media (prefers-reduced-motion: reduce) {
  .stadium__art { --rev: 118%; }
}

/* ----------------------------------- Rail ---------------------------------
   Second projects format. One sticky frame; scroll changes which project is
   active rather than moving anything across the screen. Four projects cost
   (4 * 0.5 + 1) = 3 screens here, against 6 in the reel.

   Everything that changes is opacity or a small transform, so switching is
   compositor work and there is no layout per frame.
   ------------------------------------------------------------------------ */

.rail {
  position: relative;
  /* Above the reel, which this deliberately overlaps — see margin-top. */
  z-index: 1;
  /* The ground lives on .rail__sticky, not here. Because this section is
     pulled up over the reel, an opaque background on the SECTION would slide
     up across the reel's last project and cover it while its copy was still
     fully visible. The sticky carries the ground and fades in at the handoff
     instead, so the reel is never obscured mid-project. */
  background: transparent;
  color: var(--fg);
  /* One screen for the frame itself, plus dwell per project. */
  height: calc((var(--rail-count) * 0.5 + 1) * 100vh);

  /* Pulled up by exactly one viewport so the rail pins at the instant the
     reel's last project finishes, instead of after it.

     Measured: the reel's animation completed 900px before the rail could
     engage. That gap is structural, not a tuning value — a sticky child has to
     scroll its own height out of its parent before the next sticky can pin, so
     the two frames could never meet. Overlapping by that exact height removes
     the empty screen; the rail's opaque background covers the reel's tail as
     it rides up behind. */
  margin-top: -100vh;

  /* That overlap means this section's own box covers the reel's last screen.
     The box paints nothing (background is transparent above) but it still
     hit-tests, so it caught clicks meant for the reel. Everything interactive
     here lives inside .rail__sticky, which re-enables its own hits once it is
     actually visible — a child can opt back in even when an ancestor opts out.
     Reset to auto in the stacked and reduced-motion blocks below, where there
     is no overlap and no trigger running to turn it back on. */
  pointer-events: none;
}

.rail__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  background: var(--bg);
  /* Raised by the trigger in sections.js over the final 250px of approach, so
     the switch happens exactly as the reel ends rather than a screen later. */
  opacity: 0;
  /* Starts non-interactive, and stays that way until that trigger says it is
     actually visible. Opacity alone does not stop hit-testing, and this
     element is pulled a full viewport up over the reel — so while invisible it
     was still the topmost thing at the reel's last project, eating clicks on
     its link. sections.js flips this back to auto at the handoff. */
  pointer-events: none;
  display: flex;
  align-items: center;
  overflow: hidden;
}

.rail__grid {
  display: grid;
  /* The list is sized to its own content, not given an equal share. As a 1fr
     column it was 396px wide for ~280px of text, and that 116px of dead space
     read as a gap on the left with nothing matching it on the right. Sizing to
     max-content makes the visible gaps the real ones, so the three columns
     land symmetrically.

     The gutter is the section's own, not --gutter-width: at 16px the copy sat
     closer to the image than the page edge was to the list, which put more air
     outside the layout than inside it. */
  grid-template-columns: max-content minmax(0, 1fr) minmax(24rem, 34rem);
  align-items: center;
  gap: clamp(2.4rem, 4.5vw, 6.4rem);
  width: 100%;
}

/* --- left: the tracking list --- */

.rail__nav {
  grid-column: 1;
  grid-row: 1;
  align-self: center;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1.2rem;
}

.rail__tab {
  position: relative;
  appearance: none;
  background: none;
  border: 0;
  padding: 0 0 0 2.4rem;
  font: inherit;
  /* Sized against .rail__desc opposite it: the list led at 23px against
     16px, so the left column read as the headline and the right as a
     footnote. 20 vs 18 keeps the list dominant without the imbalance. */
  font-size: clamp(1.6rem, 1.3vw, 2rem);
  line-height: 1.25;
  letter-spacing: -0.01em;
  text-align: left;
  color: color-mix(in srgb, var(--fg) 38%, transparent);
  cursor: pointer;
  transition: color .4s var(--ease-snappy);
}

/* The marker is a pseudo-element so it cannot affect the text's layout —
   the list must not reflow as the active item changes. */
.rail__tab::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: .8rem;
  height: .8rem;
  margin-top: -.4rem;
  border-radius: 50%;
  background: currentColor;
  opacity: 0;
  transform: scale(.4);
  transition: opacity .4s var(--ease-snappy), transform .4s var(--ease-snappy);
}

.rail__tab[aria-current='true'] { color: var(--fg); }
.rail__tab[aria-current='true']::before { opacity: 1; transform: scale(1); }
.rail__tab:hover { color: color-mix(in srgb, var(--fg) 70%, transparent); }
.rail__tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 6px; }

/* Back-links to the reel. Dimmer than the switchers and never carrying the
   active dot — they leave this section rather than change it, so showing them
   as selectable state would be a lie. The arrow appears on hover to say so. */
.rail__tab--back { color: color-mix(in srgb, var(--fg) 26%, transparent); }
.rail__tab--back:hover { color: color-mix(in srgb, var(--fg) 60%, transparent); }
.rail__tab--back::after {
  content: "\2191";                  /* up arrow: this goes back up the page */
  margin-left: .6rem;
  font-size: .8em;
  opacity: 0;
  display: inline-block;
  transform: translate3d(-.3rem, .2rem, 0);
  transition: opacity .3s var(--ease-snappy), transform .3s var(--ease-snappy);
}
.rail__tab--back:hover::after { opacity: .8; transform: none; }

.rail__nav-rule {
  width: 3.2rem;
  height: 1px;
  margin: .8rem 0 .8rem 2.4rem;      /* aligned to the tabs' text, past the dot */
  background: color-mix(in srgb, var(--fg) 20%, transparent);
}

/* --- centre + right: one article per project, split across two columns ---
   display:contents lets each article's figure and copy land in different grid
   columns while staying siblings in the DOM. Every item is placed in the SAME
   cell, so they overlap and crossfade without absolute positioning — and on
   mobile the article collapses back to a block with its copy directly beneath
   its own image. A two-wrapper layout (all images, then all copy) could not do
   that: on mobile it separated every project from its description. */

.rail__item { display: contents; }

.rail__media {
  grid-column: 2;
  grid-row: 1;
  align-self: center;
  justify-self: center;
  margin: 0;
  /* Height-driven so the frame is stable whatever the middle track measures,
     and centred within it. At 78vh the image stood 669px against a 265px list
     and a 178px caption — dominant enough that the outer columns read as
     stranded rather than as part of one composition. */
  /* Width-driven, so aspect-ratio always governs. Setting height and letting
     max-width clamp the other axis broke the frame's proportions on narrower
     desktops — at 1100px it squashed to 0.67 instead of 0.8. Deriving the
     width from the height cap keeps 4:5 at every size. */
  /* 3:2, not 4:5. Every cover image in this rail is landscape (1.2 to 3.3),
     so a portrait frame left them floating in a tall empty box once they were
     switched to contain. 3:2 is close to the median of the set, which keeps
     the letterboxing small for most and modest for the widest. */
  width: min(100%, calc(min(62vh, 56rem) * 3 / 2));
  aspect-ratio: 3 / 2;
  height: auto;
  border-radius: var(--radius-card);
  overflow: hidden;
  opacity: 0;
  /* Scale rather than move: an image sliding in would need travel, which is
     the scroll cost this format exists to avoid. */
  transform: scale(1.03);
  transition: opacity .55s var(--ease-snappy), transform .55s var(--ease-snappy);
}
.rail__media[data-active] { opacity: 1; transform: scale(1); }
/* contain, not cover. The frame is 4:5 portrait but every cover image here is
   landscape — the widest is 3.3:1 — so cover kept a narrow centre strip and
   sliced the subject off both sides. contain fits the whole image and centres
   it, whatever its proportions. The sunk ground and inset padding make the
   letterboxing read as a mount rather than as a gap. */
.rail__media {
  background: var(--sunk);
  box-shadow: inset 0 0 0 1px var(--rule);
}
.rail__media img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: clamp(1.2rem, 2vw, 2.4rem);
  display: block;
}

/* --- Contact-sheet cover -------------------------------------------------
   The Miscellaneous entry is a gallery, so its cover is the set rather than
   one representative photo. Eight tiles in the same 4:5 frame: 2 x 4 on
   desktop, re-flowed to 4 x 2 where the frame turns landscape, so the cells
   never end up extremely wide in one orientation or extremely tall in the
   other. The frame keeps its own border-radius and overflow, so the tiles
   need none of their own. */
.rail__media--sheet {
  display: grid;
  /* 4 x 2 to suit the 3:2 frame. Under the old portrait frame this was 2 x 4;
     carried over unchanged it produced 3:1 letterbox-shaped thumbnails. */
  grid-template-columns: repeat(4, 1fr);
  /* Explicit rows, not grid-auto-rows: 1fr. An implicit 1fr row and a child
     asking for height:100% are circular, so the row silently fell back to
     content height and every tile took its own image's proportions — eight
     different cell heights instead of a sheet. Naming the tracks breaks the
     cycle and divides the frame evenly. */
  grid-template-rows: repeat(2, 1fr);
  gap: 2px;
}
/* min-height/width:0 stops a large image forcing its track past its 1fr share,
   which is what would push the last row out through the bottom of the frame. */
.rail__media--sheet img {
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  object-fit: cover;
  /* The contain-and-inset treatment above is for a single cover image; these
     are grid thumbnails and must fill their cells edge to edge. */
  padding: 0;
}

.rail__pane {
  grid-column: 3;
  grid-row: 1;
  align-self: center;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1.6rem;
  opacity: 0;
  transform: translate3d(0, 1.2rem, 0);
  pointer-events: none;
  transition: opacity .45s var(--ease-snappy), transform .45s var(--ease-snappy);
}
.rail__pane[data-active] { opacity: 1; transform: none; pointer-events: auto; }

.rail__index { opacity: .5; }
/* The rail already names the active project on desktop, so the pane's own
   title is redundant there — but the rail is hidden on narrow screens and in
   reduced motion, where the pane would otherwise show a description with
   nothing to say what it describes. */
.rail__title { display: none; }
.rail__desc { max-width: 38ch; font-size: 1.8rem; line-height: 1.45; }
.rail__meta { opacity: .55; font-size: 1.5rem; }

/* Static stack on small screens: a sticky three-column frame has nowhere to
   put three columns, and pinning on touch fights the browser's own scrolling. */
@media (max-width: 900px) {
  .rail { height: auto; padding: 8rem 0; margin-top: 0; pointer-events: auto; }
  .rail__sticky { position: static; height: auto; display: block; overflow: visible; opacity: 1; pointer-events: auto; }
  .rail__grid { grid-template-columns: 1fr; gap: 4rem; }
  .rail__nav { display: none; }
  .rail__item { display: block; }
  .rail__item + .rail__item { margin-top: 6rem; }
  .rail__media { aspect-ratio: 4 / 3; max-height: none; opacity: 1; transform: none; }
  .rail__media--sheet { grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(2, 1fr); }
  .rail__pane { margin-top: 2.4rem; opacity: 1; transform: none; pointer-events: auto; }
  .rail__title { display: block; }
}

/* No scroll to drive the switch, so every project is simply present. */
@media (prefers-reduced-motion: reduce) {
  .rail { height: auto; padding: 8rem 0; margin-top: 0; pointer-events: auto; }
  .rail__sticky { position: static; height: auto; display: block; opacity: 1; pointer-events: auto; }
  .rail__grid { grid-template-columns: 1fr; gap: 4rem; }
  .rail__nav { display: none; }
  .rail__item { display: block; }
  .rail__item + .rail__item { margin-top: 6rem; }
  .rail__media { aspect-ratio: 4 / 3; max-height: none; opacity: 1; transform: none; }
  .rail__media--sheet { grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(2, 1fr); }
  .rail__pane { margin-top: 2.4rem; opacity: 1; transform: none; pointer-events: auto; }
  .rail__title { display: block; }
}

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

.footer { background: var(--bg); color: var(--fg); padding: 10rem 0 4rem; }
.footer__grid {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr 1fr;
  gap: var(--gutter-width);
  padding-bottom: 8rem;
}
.footer__tag { opacity: .55; margin-top: 1.2rem; }
.footer__col { display: flex; flex-direction: column; gap: 1.2rem; }
.footer__col .t-eyebrow { margin-bottom: .8rem; }
.footer__col--end { text-align: right; }
.footer__link { font-size: 1.6rem; width: fit-content; }

.footer__base {
  display: flex;
  justify-content: space-between;
  gap: 2rem;
  padding-top: 3rem;
  border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent);
  opacity: .5;
}

@media (max-width: 900px) {
  .footer { padding: 6rem 0 3rem; }
  .footer__grid { grid-template-columns: 1fr 1fr; gap: 4rem var(--gutter-width); padding-bottom: 5rem; }
  .footer__col--end { text-align: left; }
}

/* ----------------------------- Page transition --------------------------- */

.transition {
  position: fixed;
  inset: 0;
  z-index: 200;
  background: var(--color-accent);
  transform: translateY(100%);
  pointer-events: none;
}
