/* =============================================================
   Tokens
   ============================================================= */
:root {
  --font-display: "obviously-narrow", "Obviously", "Helvetica Neue",
    Arial, sans-serif;
  /* Gibson Book — used for nav links, timeline date + descriptions,
     and any default body copy. */
  --font-body: "gibson", "Canada Type Gibson", "Gibson", system-ui,
    -apple-system, "Segoe UI", sans-serif;

  /* =========================================================
     Color palette — mirrors the Figma design system tokens
     (file 6pYIFymIBELoG4mQRswJ0E, node 108:281). Use these
     for any new color reference. Semantic aliases below point
     to the palette so the cascade is in one place.
     ========================================================= */
  --color-red-light: #df775a;
  --color-red-dark: #a51d1d;
  --color-white: #fffbf3;
  --color-cream: #fbf3e6;
  --color-cream-dark: #f1e3c8;
  --color-black-light: #33322e;
  --color-black-lighter: #787770;
  --color-black-dark: #151314;
  --color-gold: #d3a256;
  --color-gold-dark: #c98317;

  /* Semantic aliases — describe role, alias to palette. */
  --color-ink: var(--color-black-dark);
  --color-bg: var(--color-white);
  --color-hairline: var(--color-cream-dark);

  /* Sticky header height — 2× its padding-block + brand text height.
     Uses the same clamp() values the header itself uses, so this stays
     accurate at every viewport. */
  --header-height: calc(
    clamp(12px, 3.125vw, 24px) * 2 + clamp(20px, 4.167vw, 32px)
  );
}

/* =============================================================
   Type styles — mirrors the Figma design system (file
   6pYIFymIBELoG4mQRswJ0E).

   Display = Obviously Demo Narrow Black (--font-display)
   Body    = Gibson Book (--font-body) at weight 400
             • "Emphasized" variant = Gibson Medium (weight 500)

   Apply these utility classes to any new content. The existing
   component classes (.hero__headline, .project-view__paragraph,
   etc.) already match these specs declaration-for-declaration —
   they're kept bespoke for component-specific behaviors but read
   identical to the matching utility class.
   ============================================================= */

/* Headline 1 — page-level display headline.
   Used by: .hero__headline, .outro__headline, .project-view__headline */
.text-headline-1 {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-ink);
}

/* Headline 2 — section / list-item display headline.
   Used by: .timeline__role */
.text-headline-2 {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(24px, 3.28vw, 42px);
  line-height: 1;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: var(--color-ink);
}

/* Body Jumbo — long-form reading copy.
   Used by: .project-view__paragraph */
.text-body-jumbo {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 22px;
  line-height: 1.636; /* 36/22 */
  color: var(--color-black-light);
}

/* Body Large — labels, list rows, secondary text.
   Used by: .project-view__info-label, .project-view__subhead,
            .timeline__date, .timeline__description,
            .interior-nav__project, .site-header__links a */
.text-body-large {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 18px;
  line-height: 1;
  color: var(--color-black-light);
}

/* Body Large Emphasized — bolder variant of Body Large for values
   paired with a Body Large label. Figma spec calls for Inter Medium,
   but we use Gibson Medium for type-system consistency across the
   site (per the established pattern of "Gibson everywhere for body").
   Used by: .project-view__info-value */
.text-body-large-emphasized {
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 18px;
  line-height: 1;
  color: var(--color-black-light);
}

/* Body — default body text. Inherited from <body>; the explicit
   utility class is here for any element that doesn't otherwise
   inherit from body but still needs the default body style. */
.text-body {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 16px;
  line-height: 1.5;
  color: var(--color-black-light);
}

/* =============================================================
   Lenis (smooth/lerped scroll) — recommended boilerplate
   ============================================================= */
html.lenis,
html.lenis body {
  height: auto;
}
.lenis.lenis-smooth {
  scroll-behavior: auto !important;
}
.lenis.lenis-smooth [data-lenis-prevent] {
  overscroll-behavior: contain;
}
.lenis.lenis-stopped {
  overflow: clip;
}
.lenis.lenis-smooth iframe {
  pointer-events: none;
}

/* =============================================================
   Load reveal
   -------------------------------------------------------------
   Any element with [data-reveal] starts invisible and fades in
   when JS adds `.is-loaded` to <body>. Elements stay in their
   final layout position the whole time — only opacity animates.
   Use [data-reveal-delay="ms"] for stagger.
   ============================================================= */
[data-reveal] {
  opacity: 0;
}
body.is-loaded [data-reveal] {
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  [data-reveal],
  body.is-loaded [data-reveal] {
    opacity: 1;
    transition: none !important;
  }
}

/* =============================================================
   Reset + base
   ============================================================= */
*,
*::before,
*::after {
  box-sizing: border-box;
  /* Force every element to inherit the cursor set on <html> below.
     Without this, the user-agent stylesheet's `a { cursor: pointer }`
     and similar rules would override our custom cursor on links,
     buttons, and other interactive elements. */
  cursor: inherit;
}

/* Native cursor is hidden — a JS-driven custom cursor is rendered
   instead (see scripts/cursor.js + the .custom-cursor element).
   This lets us rotate / tilt / animate the cursor based on motion,
   which the native `cursor: url(...)` property cannot do (the OS
   draws that). */
html {
  cursor: none;
}

/* =============================================================
   Custom cursor (JS-driven)
   -------------------------------------------------------------
   Single fixed div with the cursor PNG as its background. JS
   writes `transform: translate3d(mouseX, mouseY, 0) rotate(tiltDeg)`
   each frame, with `transform-origin` set to the fingertip so the
   rotation pivots at the click point.

   The click variant swap happens via CSS — `html.is-clicking`
   changes the background-image, dimensions, and transform-origin.
   ============================================================= */
.custom-cursor {
  position: fixed;
  top: 0;
  left: 0;
  width: 32px;
  height: 48px;
  background-image: url("../assets/cursor.png");
  background-size: contain;
  background-repeat: no-repeat;
  background-position: 0 0;
  pointer-events: none;
  /* Below the page-wipe (z:99999) so the wipe covers the cursor
     during return-to-home transitions, but above everything else. */
  z-index: 99998;
  /* Pivot at the fingertip — (10px, 0) in image coords for default,
     overridden in the .is-clicking state below. */
  transform-origin: 10px 0;
  /* Default position off-screen so it doesn't sit at (0, 0)
     before the first mousemove. JS overrides immediately. */
  transform: translate3d(-100px, -100px, 0);
  will-change: transform;
}

/* Click state — wider image, different hotspot. JS adds
   .is-clicking on the root on mousedown, removes on mouseup
   (with a 150ms minimum-visible window). */
html.is-clicking .custom-cursor {
  width: 42px;
  height: 48px;
  background-image: url("../assets/cursor-click.png");
  transform-origin: 17px 10px;
}

/* Pissed-off state — flipped fist with raised middle finger.
   Triggered by JS (scripts/password-gate.js) when the user has
   blown PAST the escalating-placeholder sequence and is still
   guessing wrong. The class is applied to <html> for 3 seconds,
   then removed. 25% larger than the default cursor variants for
   extra emphasis. Hotspot stays roughly at the fingertip
   (top-center) so the cursor still tracks correctly while it's
   the bird flip. */
html.is-cursor-pissed .custom-cursor {
  width: 50px;
  height: 60px;
  background-image: url("../assets/cursor-pissed.png");
  transform-origin: 25px 5px;
}

html,
body {
  margin: 0;
  padding: 0;
  /* Prevent horizontal scroll from elements that intentionally extend
     past their parent's box for animatable hover reveals — most notably:
       - the Google Primer card (.project__link[data-project="google-primer"]),
         whose desktop phone-montage uses negative `right`/`left` insets
         so the clip-path on the link can animate the on-hover spill
         outward. clip-path only clips paint, not layout, so the spilled
         image otherwise adds ~430px of horizontal overflow on every
         viewport and pushes a scrollbar / empty band on the right of
         the page.
       - the .page-wipe overlay at `transform: translateX(100%)`, which
         sits one viewport-width to the right of the viewport at rest.
       - the .proficiencies__track marquee that's 6000+px wide (already
         clipped by its own ancestor .proficiencies { overflow: hidden }
         on rest, but applied here as belt-and-suspenders).

     IMPORTANT: use `overflow-x: clip` (NOT `hidden`). Per the CSS spec,
     setting one axis to a non-`visible` value forces the other axis's
     used value to `auto`, which establishes a scroll container on the
     element. A scroll container on <html>/<body> hijacks `position:
     sticky` — `.outro__sticky` would stick to that hidden container
     instead of the real viewport and stop following the page. `clip`
     clips paint just like `hidden` but does NOT create a scroll
     container, so sticky and Lenis smooth scroll stay healthy.

     Applied to BOTH html and body so the viewport scroll container can't
     grow horizontally regardless of which element propagates overflow to
     it in any given browser. */
  overflow-x: clip;
}

body {
  min-height: 100vh;
  background-color: var(--color-bg);
  /* Body text defaults to Black/Light Black — a softer reading tone
     than the headlines, which use --color-ink (Black/Dark Black). */
  color: var(--color-black-light);
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 24px;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* Same timing/curve as the project's hover-to-fullwidth expand,
     so the bg + text color shift feels choreographed with the card growing. */
  transition:
    background-color 750ms cubic-bezier(0.65, 0, 0.35, 1),
    color 750ms cubic-bezier(0.65, 0, 0.35, 1);
}

/* When a project is hovered (or its inner link is focused), the body bg
   is colored by JS. We invert both the headline ink color AND the body
   text color to the off-white --color-bg so all text reads cleanly
   against the now-colored bg. */
/* Home only — the interior "Next project" card is also a .project, but
   there the body bg doesn't flip to the brand color, so inverting text
   would make the interior copy unreadable. */
body:not(.project-view-open):has(.project:hover),
body:not(.project-view-open):has(.project:focus-within) {
  --color-ink: var(--color-bg);
  color: var(--color-bg);
}

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

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

/* =============================================================
   Site header / primary navigation
   Reference: Figma node 72:59 (Pfolio)
   ============================================================= */
.site-header {
  /* 24px above 768px, scales as 3.125vw below, floors at 12px.
     3.125vw == 24px when viewport == 768px (continuous handoff). */
  padding-block: clamp(12px, 3.125vw, 24px);
  align-items: center;
  transition: opacity 800ms cubic-bezier(0.2, 0.8, 0.2, 1)
    var(--reveal-delay, 0ms);
}

.site-header__brand {
  font-family: var(--font-display);
  font-weight: 900;
  /* 32px above 768px, scales as 4.167vw below, floors at 20px. */
  font-size: clamp(20px, 4.167vw, 32px);
  line-height: 1;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  /* Keep "Alex Miller" on a single line at every width (it wrapped on
     narrow viewports). */
  white-space: nowrap;
  /* Display headline → Black/Dark Black (--color-ink). Inverts to
     off-white when a project is hovered (via the :has rule above). */
  color: var(--color-ink);
  text-decoration: none;
  /* Positioning context for the absolutely stacked text variant
     (default vs. hover swap on interior project pages — see the
     .site-header__brand-text rules near body.project-view-open). */
  position: relative;
  display: inline-block;
}

/* Wordmark text variants. The DEFAULT variant ("Alex Miller") is always
   in flow on both home and interior pages — it defines the link's
   content width, which guarantees the wordmark sits in the exact same
   visual position regardless of whether a project view is open. The
   HOVER variant ("Back home") sits absolutely positioned at left:0, so
   when it crossfades in on interior hover it expands rightward from the
   same left edge as "Alex Miller" without nudging anything. We pin
   white-space:nowrap on the hover variant because it's wider than
   "Alex Miller" and would otherwise wrap inside the link's content box. */
/* Both variants get a coupled opacity + translateY transition. Default
   sits at rest; hover variant starts 100% below the line. On hover, the
   default rises and fades out while the hover variant rises into place
   and fades in — a single smooth ticker swap instead of a cross-fade
   flicker. `will-change` pre-promotes both spans to their own compositor
   layer so the swap doesn't stutter on the first hover of a session. */
.site-header__brand-text {
  display: inline-block;
  transition:
    opacity 320ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 320ms cubic-bezier(0.4, 0, 0.2, 1);
  will-change: opacity, transform;
}

.site-header__brand-text--default {
  transform: translateY(0);
}

.site-header__brand-text--hover {
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
  transform: translateY(100%);
}

.site-header__nav {
  justify-self: end;
}

.site-header__links {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 16px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.site-header__icon {
  position: relative;
  display: inline-flex;
  width: 24px;
  height: 24px;
  transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* Both the mono and color icon versions sit at the same spot, overlapping.
   The color version starts invisible and crossfades in on hover. */
.site-header__icon img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  transition: opacity 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.site-header__icon__color {
  opacity: 0;
}

.site-header__icon:hover .site-header__icon__color,
.site-header__icon:focus-visible .site-header__icon__color {
  opacity: 1;
}
.site-header__icon:hover .site-header__icon__mono,
.site-header__icon:focus-visible .site-header__icon__mono {
  opacity: 0;
}

.site-header__icon:hover,
.site-header__icon:focus-visible {
  transform: translateY(-4px);
}

/* Inversion: when a project is hovered (body bg goes purple/teal/orange),
   filter:invert(1) flips the MONO icons' black/white fills so they read
   against the new bg. We don't invert the color variants — they stay
   true to their brand colors on hover (which only happens when the
   user moves their cursor onto the icon, not while hovering a project). */
body:not(.project-view-open):has(.project:hover) .site-header__icon__mono,
body:not(.project-view-open):has(.project:focus-within) .site-header__icon__mono {
  filter: invert(1);
}

@media (prefers-reduced-motion: reduce) {
  .site-header__icon {
    transition: none;
  }
  .site-header__icon:hover,
  .site-header__icon:focus-visible {
    transform: none;
  }
}

/* =============================================================
   Interior nav (project view header expansion)
   Reference: Figma node 80:452 (Pfolio)
   -------------------------------------------------------------
   Hidden entirely on the home page. On a project view page, sits
   next to the ALEX MILLER wordmark with three project links and
   three social icons. Items are invisible by default (slid 32px
   left, opacity 0) and fade + slide into place from left to right
   when the user hovers the header. Each item's stagger delay is
   driven by an inline --i variable (0..5).
   ============================================================= */
.interior-nav {
  display: none;
}

body.project-view-open .interior-nav {
  display: flex;
  align-items: center;
  gap: 32px;
}

.interior-nav__icons {
  display: flex;
  align-items: center;
  gap: 16px;
}

/* Project links — Gibson Book 18px, off-white against the colored masthead.
   (Figma showed Inter; we use Gibson for body-text consistency with the
   rest of the site.) */
.interior-nav__project {
  display: inline-block;
  font-family: var(--font-body);
  font-size: 18px;
  line-height: 1;
  font-weight: 400;
  color: var(--color-bg);
  text-decoration: none;
  white-space: nowrap;
}

.interior-nav__project.is-current {
  font-weight: 500;
  /* Active page link is inert — no hover lift. The cursor stays the
     custom finger (inherited from <html>) for visual consistency,
     and the strikethrough conveys the "current page" state. */
}

/* Strikethrough the current page's link. Applied to the inner span (not
   the parent <a>) because text-decoration on the link doesn't always
   propagate through the inline-block span — moving it here guarantees
   the line draws across the visible text in every browser. */
.interior-nav__project.is-current .interior-nav__project-text {
  text-decoration: line-through;
  text-decoration-thickness: 2px;
  text-decoration-color: currentColor;
  text-underline-offset: 0;
}

/* Inner span — handles the 4px lift + Gibson semibold on link hover.
   Two separate transform layers (link does the reveal slide-in, span
   does the lift) so they don't collide on the same property. The
   ::after pseudo reserves the semibold width so neighboring links
   don't shift when the hovered one gets bolder. */
.interior-nav__project-text {
  position: relative;
  display: inline-block;
  text-align: center;
  transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.interior-nav__project-text::after {
  content: attr(data-text);
  display: block;
  height: 0;
  visibility: hidden;
  overflow: hidden;
  user-select: none;
  pointer-events: none;
  font-weight: 600;
}

/* Lift + bold only fires on links that AREN'T the current page —
   the active page's link should feel inert (cursor doesn't indicate
   anything will happen if clicked). */
.interior-nav__project:not(.is-current):hover .interior-nav__project-text,
.interior-nav__project:not(.is-current):focus-visible .interior-nav__project-text {
  transform: translateY(-4px);
  font-weight: 600;
}

/* Icons — same crossfade pattern as the home nav, but smaller (20px per
   the Figma) and the mono variant is inverted so it reads off-white
   against the colored masthead. */
.interior-nav__icon {
  position: relative;
  display: inline-flex;
  width: 20px;
  height: 20px;
}

/* Inner span — handles the 4px lift on hover. Same two-layer pattern as
   the project links: the link's transform is reserved for the reveal
   slide-in, the span's transform handles the lift, no collision. */
.interior-nav__icon-inner {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.interior-nav__icon:hover .interior-nav__icon-inner,
.interior-nav__icon:focus-visible .interior-nav__icon-inner {
  transform: translateY(-4px);
}

.interior-nav__icon img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  transition: opacity 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.interior-nav__icon-mono {
  filter: invert(1);
}

.interior-nav__icon-color {
  opacity: 0;
}

.interior-nav__icon:hover .interior-nav__icon-color,
.interior-nav__icon:focus-visible .interior-nav__icon-color {
  opacity: 1;
}

.interior-nav__icon:hover .interior-nav__icon-mono,
.interior-nav__icon:focus-visible .interior-nav__icon-mono {
  opacity: 0;
}

/* Hover-reveal: each item starts hidden + slid 32px left, fades and
   slides into place with a per-item stagger via --i. Triggered by
   hover OR focus-within on the entire .site-header (so users can
   move their cursor / tab focus from the wordmark onto the items
   without losing the reveal state). */
.interior-nav__project,
.interior-nav__icon {
  opacity: 0;
  transform: translateX(-32px);
  pointer-events: none;
  transition:
    opacity 500ms cubic-bezier(0.2, 0.8, 0.2, 1)
      calc(var(--i, 0) * 70ms),
    transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1)
      calc(var(--i, 0) * 70ms);
}

/* Reveal trigger uses `:has(:focus-visible)` rather than
   `:focus-within` — `:focus-visible` is keyboard-only on links/
   buttons (the UA heuristic skips it for mouse clicks), so a
   mouse-clicked link doesn't keep the nav stuck open after the
   user has navigated. Hover stays as-is for mouse users; keyboard
   users still get the reveal when they tab into the header. */
.site-header:hover .interior-nav__project,
.site-header:hover .interior-nav__icon,
.site-header:has(:focus-visible) .interior-nav__project,
.site-header:has(:focus-visible) .interior-nav__icon {
  opacity: 1;
  transform: translateX(0);
  pointer-events: auto;
}

@media (prefers-reduced-motion: reduce) {
  .interior-nav__project,
  .interior-nav__icon {
    transform: none;
    transition: opacity 200ms;
  }
  .site-header:hover .interior-nav__project,
  .site-header:hover .interior-nav__icon,
  .site-header:has(:focus-visible) .interior-nav__project,
  .site-header:has(:focus-visible) .interior-nav__icon {
    transform: none;
  }
  .interior-nav__project-text,
  .interior-nav__icon-inner {
    transition: none;
  }
  .interior-nav__project:hover .interior-nav__project-text,
  .interior-nav__project:focus-visible .interior-nav__project-text,
  .interior-nav__icon:hover .interior-nav__icon-inner,
  .interior-nav__icon:focus-visible .interior-nav__icon-inner {
    transform: none;
  }
}

/* =============================================================
   Display headline sections (hero + outro)
   Reference: Figma nodes 59:284 (hero) + 72:82 (outro)
   ============================================================= */
.hero,
.outro {
  /* xl (24-col grid): 2-col empty on each side, headline spans cols 3–22. */
  grid-column: 3 / span 20;
}

/* md (12-col grid): 1-col empty on each side keeps the same proportion. */
@media (max-width: 1279px) {
  .hero,
  .outro {
    grid-column: 2 / span 10;
  }
}

/* sm + xs: full width — the grid is too narrow to inset gracefully. */
@media (max-width: 767px) {
  .hero,
  .outro {
    grid-column: 1 / -1;
  }
}

.hero {
  padding-block: 48px;
}

/* Outro: padding-top creates anticipation/buildup space before the
   headline rises into view, then min-height controls the lock duration.
   Lock ≈ min-height − padding-top − headline-height. */
.outro {
  padding-top: 50vh;
  min-height: 87vh;
}

/* Pin the headline's top to viewport center, then transform shifts it
   up by half its own height — net effect: centered vertically in the
   viewport regardless of how many lines the headline wraps to. The
   sticky element only takes up the headline's actual height in the
   document flow, so post-pin scroll-out is short.

   `top: 50vh` (instead of 50%) avoids any sub-pixel rounding ambiguity
   between the sticky containing block height and the viewport.

   `will-change: transform` + `translate3d` promote this element to its
   own compositor layer so Lenis's lerped sub-pixel scroll positions
   don't cause the headline to repaint and hiccup on every frame. */
.outro__sticky {
  position: sticky;
  top: 50vh;
  transform: translate3d(0, -50%, 0);
  will-change: transform;
}

/* =============================================================
   Timeline
   Reference: Figma node 77:195 (Pfolio)
   ============================================================= */
.timeline {
  grid-column: 3 / span 20;
  padding-block: 96px;
}

/* Scroll-triggered reveal — each [data-scroll-reveal] starts invisible
   and lifts gently into place when it enters the viewport. JS (scroll.js)
   adds .is-revealed via IntersectionObserver. */
[data-scroll-reveal] {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1),
    transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-scroll-reveal].is-revealed {
  opacity: 1;
  transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
  [data-scroll-reveal],
  [data-scroll-reveal].is-revealed {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

@media (max-width: 1279px) {
  .timeline {
    grid-column: 2 / span 10;
  }
}

@media (max-width: 767px) {
  .timeline {
    grid-column: 1 / -1;
    /* Mobile: section padding-top zeroed. Total visible gap between the
       outro headline and the first row's date text =
         outro padding-bottom (0) +
         parent grid row-gap (var(--grid-gutter)) +
         timeline padding-top (0) +
         first row padding-top (set below to 40 − var(--grid-gutter))
       = exactly 40px, robust across iPhone (390), Pixel (412), narrower
       Android (360), and the sub-480 xs breakpoint (which keeps a 16px
       grid-gutter too). */
    padding-top: 0;
  }
  .timeline__row:first-child {
    /* 40px target visible gap minus the grid row-gap that already exists
       between outro and timeline as grid items. */
    padding-top: calc(40px - var(--grid-gutter));
    border-top: 0;
  }
  /* Skip the scroll-reveal initial-state offset on timeline rows on
     mobile: the rows live below the fold, and the 16px `translateY` on
     [data-scroll-reveal] makes the unrevealed first row visually 16px
     lower than its layout box. That broke the precise 40px gap between
     the outro headline and the first row's date during the scroll-into-
     view period. Mobile users see the timeline almost immediately after
     scrolling past the outro, so the reveal-from-below transition
     doesn't add much; better to skip it for a stable layout. */
  .timeline__row[data-scroll-reveal] {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

.timeline__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.timeline__row {
  display: grid;
  grid-template-columns: 145px 371px 1fr;
  gap: 24px;
  align-items: center;
  padding-block: 48px;
  border-top: 1px solid var(--color-hairline);
}

.timeline__row:last-child {
  border-bottom: 1px solid var(--color-hairline);
}

.timeline__date {
  font-family: var(--font-body);
  font-size: 18px;
  line-height: 1;
}

.timeline__role {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  /* 42px at 1280px+, scales fluidly with 3.28vw below, floors at 24px.
     3.28vw == 42px when viewport == 1280px (continuous handoff). */
  font-size: clamp(24px, 3.28vw, 42px);
  line-height: 1;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  /* Display headline → Black/Dark Black. */
  color: var(--color-ink);
}

.timeline__description {
  margin: 0;
  font-family: var(--font-body);
  font-size: 18px;
  line-height: 1.4;
}

/* md (≤1279): description drops below date+role; date stays as a sidebar */
@media (max-width: 1279px) {
  .timeline__row {
    grid-template-columns: 120px 1fr;
    grid-template-rows: auto auto;
    column-gap: 24px;
    row-gap: 12px;
    align-items: start;
    padding-block: 36px;
  }
  .timeline__date {
    grid-column: 1;
    grid-row: 1 / 3;
  }
  .timeline__role {
    grid-column: 2;
    grid-row: 1;
  }
  .timeline__description {
    grid-column: 2;
    grid-row: 2;
  }
}

/* sm + xs: full stack — date above role above description */
@media (max-width: 767px) {
  .timeline__row {
    grid-template-columns: 1fr;
    grid-template-rows: auto;
    gap: 8px;
    padding-block: 28px;
  }
  .timeline__date,
  .timeline__role,
  .timeline__description {
    grid-column: 1;
    grid-row: auto;
  }
  .timeline__description {
    margin-top: 4px;
  }
}

/* =============================================================
   Proficiencies — full-bleed dark band below the timeline
   Reference: Figma node 108:273
   -------------------------------------------------------------
   Static eyebrow ("Proficiencies") aligned to grid col 3, then a
   slow right→left infinite marquee of the big display text. The
   track holds two identical copies of the content; the keyframe
   animates from translateX(0) to translateX(-50%) so the second
   copy lands exactly where the first started — seamless loop.
   ============================================================= */
.proficiencies {
  background-color: var(--color-black-dark);
  padding-block: 48px;
  /* Clip the marquee track that extends past viewport edges. */
  overflow: hidden;
}

.proficiencies__eyebrow-row {
  /* The .grid class on this element provides the 24-col layout
     and outer padding. The eyebrow sits at col 3 to align with
     the rest of the page's content rails. */
  margin-bottom: 6px;
}

.proficiencies__eyebrow {
  grid-column: 3 / span 20;
  margin: 0;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 18px;
  line-height: 1;
  color: var(--color-white);
}

@media (max-width: 1279px) {
  .proficiencies__eyebrow {
    grid-column: 2 / span 10;
  }
}

@media (max-width: 767px) {
  .proficiencies__eyebrow {
    grid-column: 1 / -1;
  }
}

.proficiencies__marquee {
  width: 100%;
}

.proficiencies__track {
  display: inline-flex;
  white-space: nowrap;
  /* 25s for one full loop. Paused by default — JS adds .is-in-view
     to the section when it scrolls into view, which kicks the
     animation into running state from the beginning. This way the
     marquee always starts fresh from t=0 once the user can see it,
     instead of running invisibly during page load. */
  animation: proficiencies-marquee 25s linear infinite;
  animation-play-state: paused;
  will-change: transform;
}

.proficiencies.is-in-view .proficiencies__track {
  animation-play-state: running;
}

.proficiencies__item {
  flex-shrink: 0;
  font-family: var(--font-display);
  font-weight: 900;
  /* Same scaling curve as the home hero headline (Headline 1). */
  font-size: clamp(40px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-cream);
}

@keyframes proficiencies-marquee {
  from {
    transform: translateX(0);
  }
  to {
    /* -50% = exactly one copy width, since two identical copies
       sit side-by-side in the track. The second copy ends up
       where the first started → seamless loop. */
    transform: translateX(-50%);
  }
}

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

/* =============================================================
   Contact — full-bleed cream band below the proficiencies.
   Email + 3 social icons centered on a single horizontal line.
   Reference: Figma node 111:344
   ============================================================= */
.contact {
  background-color: var(--color-cream);
  padding-block: 80px;
  padding-inline: var(--grid-margin);
  display: flex;
  align-items: center;
  justify-content: center;
  /* Wrap to a second line if the email + icon cluster don't fit
     on the viewport at small sizes. */
  flex-wrap: wrap;
  gap: 24px;
}

.contact__email {
  font-family: var(--font-display);
  font-weight: 900;
  /* Headline 2 — same scale as the timeline role titles. */
  font-size: clamp(24px, 3.28vw, 42px);
  line-height: 1;
  letter-spacing: 0.024em;
  text-transform: uppercase;
  color: var(--color-ink);
  text-decoration: none;
  /* Allow break-anywhere on very narrow viewports so the email
     doesn't blow past the edge. Most screens will keep it on one
     line because the parent flex-wrap moves the icons to a new row. */
  overflow-wrap: anywhere;
}

/* Per-letter color transition. JS sets a transition-delay on each
   letter based on its distance from the cursor entry/exit point,
   so on hover the gold color radiates outward from where the
   cursor is. Each letter has its own 250ms color transition once
   its delay elapses. */
.contact__email .letter-bounce__char {
  transition: color 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.contact__email.is-hovering .letter-bounce__char {
  color: var(--color-gold);
}

/* Keyboard focus: no cursor position to wave from, so just transition
   the whole word at once via the parent's color (letters inherit). */
.contact__email:focus-visible {
  color: var(--color-gold);
}

@media (prefers-reduced-motion: reduce) {
  .contact__email .letter-bounce__char {
    transition: none;
  }
}

.contact__icons {
  display: flex;
  align-items: center;
  gap: 24px;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* 32px icons (per Figma) — bigger than the 24px home nav variants. */
.contact__icon {
  position: relative;
  display: inline-flex;
  width: 32px;
  height: 32px;
}

/* Inner span lifts on hover, same two-layer pattern as the interior
   nav icons — keeps lift transform separate from any other transforms. */
.contact__icon-inner {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.contact__icon img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  transition: opacity 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.contact__icon-color {
  opacity: 0;
}

.contact__icon:hover .contact__icon-color,
.contact__icon:focus-visible .contact__icon-color {
  opacity: 1;
}
.contact__icon:hover .contact__icon-mono,
.contact__icon:focus-visible .contact__icon-mono {
  opacity: 0;
}

.contact__icon:hover .contact__icon-inner,
.contact__icon:focus-visible .contact__icon-inner {
  transform: translateY(-4px);
}

@media (prefers-reduced-motion: reduce) {
  .contact__icon-inner,
  .contact__icon img {
    transition: none;
  }
  .contact__icon:hover .contact__icon-inner,
  .contact__icon:focus-visible .contact__icon-inner {
    transform: none;
  }
}

.hero__headline,
.outro__headline {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  /* Locks to 72px at 1280px+ (xl), scales fluidly with 5.625vw below that.
     Floors at 36px so it stays readable on the smallest screens.
     5.625vw == 72px when viewport == 1280px, so scaling is continuous. */
  font-size: clamp(36px, 5.625vw, 72px);
  /* 74/72 ≈ 1.0278 — unitless so line-height scales with font-size. */
  line-height: 1.0278;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--color-ink);
  transition:
    opacity 800ms cubic-bezier(0.2, 0.8, 0.2, 1)
      var(--reveal-delay, 0ms),
    color 750ms cubic-bezier(0.65, 0, 0.35, 1);
}

/* Hint the browser that parallax elements will be transforming
   constantly so it can promote them to their own compositor layer. */
[data-parallax] {
  will-change: transform;
}

/* =============================================================
   Project card
   Reference: Figma node 74:153 (Pfolio)
   ============================================================= */
.project {
  /* The project always spans the full grid; the inset is created with
     animatable inline padding so the hover-to-fullwidth effect can
     transition smoothly (grid-column changes can't animate). */
  grid-column: 1 / -1;
  margin-block: 192px;
  /* Resting inset: 1 column + 1 gutter on each side.
     Formula auto-adapts per breakpoint via --grid-cols / --grid-gutter. */
  padding-inline: calc(
    (100% - (var(--grid-cols) - 1) * var(--grid-gutter))
      / var(--grid-cols) + var(--grid-gutter)
  );
  /* Two transitions: padding for the hover-to-fullwidth expansion
     (slow, smooth ease-in-out — feels deliberate),
     opacity for the reveal-on-load (with stagger). */
  transition:
    padding 750ms cubic-bezier(0.65, 0, 0.35, 1),
    opacity 800ms cubic-bezier(0.2, 0.8, 0.2, 1)
      var(--reveal-delay, 0ms);
}

/* Symmetric spacing around the hero headline: the space above the
   headline (header + hero's top padding) equals the space below
   (hero's bottom padding + this margin). Since hero padding is equal
   top/bottom, project's first margin-top just needs to match the
   header height. The 192px gap BETWEEN sibling thumbnails is still
   preserved (project N bottom + project N+1 top). */
.project:first-of-type {
  margin-top: var(--header-height);
}

/* Extra breathing room below the LAST project (Google Primer) so the
   body bg has time to finish its 750ms fade back to --color-bg after
   mouseleave fires. Without this, the outro headline can come into
   view while the bg is still mid-transition from the GP accent color.
   Doubles the bottom gap (192 + 192 = 384px) — at typical scroll
   velocities that's well over 750ms of extra scroll distance. */
.project:last-of-type {
  margin-bottom: calc(192px + 192px);
}

/* sm + xs are already full-width by design — no room to expand. */
@media (max-width: 767px) {
  .project {
    padding-inline: 0;
    /* Tighten the inter-thumbnail spacing on mobile so each pair sits
       40px apart (20 + 20). The 192px desktop margin makes the home grid
       feel cinematic; on a phone it leaves cards stranded with empty
       cream between them. */
    margin-block: 20px;
  }
  /* Google Primer home thumbnail: nudge the rotated montage 10px down
     on mobile so the empty bottom-right corner of the card fills in. */
  .project__link[data-project="google-primer"] .project__shot--desktop {
    transform: translateY(10px);
  }
  /* Override the desktop --header-height anchor on the first project so
     the hero headline → first thumbnail gap is 40px (20px hero padding-
     bottom + 20px first project margin-top), not the desktop's
     header-height ≈ 52px stacked on top of 48px hero padding. */
  .project:first-of-type {
    margin-top: 20px;
  }
  .project:last-of-type {
    /* Match the symmetric tightening — was 2× the desktop margin to give
       the body bg time to fade past the outro; on mobile the smaller gap
       is fine because the cards stack tighter and bg changes settle
       faster relative to scroll. */
    margin-bottom: 20px;
  }
  /* Tighten the hero's own vertical padding on mobile so the bottom
     padding contributes the second 20px of the 40px hero→thumbnail gap. */
  .hero {
    padding-block: 20px;
  }
  /* Drop the outro's full-viewport anticipation buildup on mobile so the
     "Over a decade of experience..." headline sits 40px below the last
     thumbnail (last-of-type margin-bottom 20px + outro top padding 20px)
     instead of being sticky-pinned at viewport-center half a viewport
     later. The dramatic floating-up effect doesn't read as well on
     phones where viewport scroll distance is shorter. */
  .outro {
    padding-top: 20px;
    padding-bottom: 0;
    min-height: 0;
  }
  .outro__sticky {
    position: static;
    top: auto;
    transform: none;
    will-change: auto;
  }
}

.project:hover,
.project:focus-within {
  padding-inline: 0;
}

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

.project__link {
  display: block;
  position: relative;
  width: 100%;
  /* aspect-ratio lives on the link now so empty placeholder cards
     (no image inside) still hold their shape. 1228/691 matches the
     Figma source container (node 77:264) exactly, so the percentage
     positions used by .project__logo and .project__shot resolve to
     the same pixel offsets the design specifies. */
  aspect-ratio: 1228 / 691;
  border-radius: 16px;
  overflow: hidden;
  /* Per-card color via --project-color on the .project element.
     Falls back to the Yahoo purple if not set. */
  background: var(--project-color, #7d2eff);
}

/* Generic fill-the-link image (used by placeholder cards if they ever
   carry a single image instead of layered composition). */
.project__image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(1.01);
  transform-origin: center;
}

/* =============================================================
   Project layered composition (logo + desktop + mobile shots)
   Reference: Figma node 74:121 (Pfolio — home thumbnails)
   -------------------------------------------------------------
   All positioning is in % of the link's box (which has the
   1228:691 aspect ratio), so the layout scales proportionally.

   Each project article sets its own --logo-*, --shot-desktop-*,
   and --shot-mobile-* custom properties inline. The defaults
   below match the Yahoo thumbnail in the Figma design.
   ============================================================= */

/* Project wordmark — SVG, top-left corner.
   On project hover, the logo slides rightward and fades out — it goes
   behind the desktop screenshot naturally because the screenshot comes
   later in DOM order (later siblings paint on top by default). */
.project__logo {
  position: absolute;
  top: var(--logo-top, 7.38%);
  left: var(--logo-left, 4.32%);
  width: var(--logo-width, 12.46%);
  height: auto;
  /* Lock aspect ratio so the logo doesn't squish if % math drifts. */
  aspect-ratio: var(--logo-aspect, 153 / 46);
  /* Behind the screenshots in z-space so the slide-down motion
     passes UNDER them, not over. */
  z-index: 0;
  /* Cursor-parallax via the `translate:` property — composes
     additively with the `transform: translateY(300%)` hover rule
     below, so we don't have to reach into that transform to add
     parallax. See scripts/card-parallax.js for the JS that writes
     --parallax-x / --parallax-y on the parent .project. The logo
     gets the strongest depth (16px) — it's the "front" layer. */
  translate: calc(var(--parallax-x, 0) * 16px)
    calc(var(--parallax-y, 0) * 16px);
  transition:
    opacity 750ms cubic-bezier(0.65, 0, 0.35, 1),
    transform 750ms cubic-bezier(0.65, 0, 0.35, 1),
    translate 450ms cubic-bezier(0.65, 0, 0.35, 1);
}

/* While the cursor is actively driving parallax, swap to a snappy
   short transition so the logo tracks the cursor in near-real-time.
   On mouseleave the class is dropped and the slower ease-back
   transition above takes over to ride the kids back to (0, 0). */
.project.is-parallaxing .project__logo {
  transition:
    opacity 750ms cubic-bezier(0.65, 0, 0.35, 1),
    transform 750ms cubic-bezier(0.65, 0, 0.35, 1),
    translate 150ms linear;
}

.project:hover .project__logo,
.project:focus-within .project__logo {
  opacity: 0;
  transform: translateY(300%);
}

@media (prefers-reduced-motion: reduce) {
  .project__logo,
  .project__shot--desktop,
  .project__shot--mobile {
    transition: none;
    /* Belt-and-suspenders: card-parallax.js already bails on
       reduced-motion, but if the variables somehow get set we
       still want zero translate when the user has asked for less
       motion. */
    translate: 0 0;
  }
  .project:hover .project__logo,
  .project:focus-within .project__logo {
    transform: none;
  }
}

/* Screenshots: viewport wrapper holds the rounded corners + shadow,
   and the <img> inside is sized/positioned to crop the source UI
   to show just the top portion — matching Figma exactly. The
   wrapper's overflow:hidden hides everything outside the rounded
   viewport, so the purple card shows through cleanly at the corners. */
.project__shot {
  position: absolute;
  overflow: hidden;
  border-radius: 20px;
  box-shadow: 8px 8px 48px 0 rgba(0, 0, 0, 0.2);
  /* Above the project logo so the logo's slide-down motion goes
     UNDER the screenshots. */
  z-index: 1;
}

.project__shot img {
  position: absolute;
  display: block;
  max-width: none;
}

/* Desktop screenshot — wrapper position + aspect set per-project via
   custom properties. Image is sized by width and keeps its natural
   aspect ratio; because the source is taller than the wrapper, the
   parent's overflow:hidden crops it down to the top-of-UI view the
   design wants. Forcing an explicit height here would stretch the
   artwork. Defaults match the Yahoo thumbnail.
   --shot-desktop-rotate lets a project tilt its desktop shot in place
   (e.g. Google Primer's rotated phone montage). Defaults to 0deg so
   non-rotated projects are unaffected. */
.project__shot--desktop {
  top: var(--shot-desktop-top, 12.45%);
  left: var(--shot-desktop-left, 18.57%);
  right: var(--shot-desktop-right, 4.56%);
  aspect-ratio: var(--shot-desktop-aspect, 944 / 605);
  transform: rotate(var(--shot-desktop-rotate, 0deg));
  transform-origin: 50% 50%;
  /* Cursor-parallax — desktop is the back/biggest layer, so it gets
     the smallest depth (5px). Keeping it tiny is also what lets the
     Google Primer rotated montage stay calm: the `translate:` lives
     in a separate pipeline slot from `transform: rotate(...)`, so
     they compose without fighting. */
  translate: calc(var(--parallax-x, 0) * 5px)
    calc(var(--parallax-y, 0) * 5px);
  transition: translate 450ms cubic-bezier(0.65, 0, 0.35, 1);
}
.project.is-parallaxing .project__shot--desktop {
  transition: translate 150ms linear;
}
.project__shot--desktop img {
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
}

/* Mobile screenshot — same width-driven, overflow-clipped pattern as
   desktop. Defaults match the Yahoo thumbnail. */
.project__shot--mobile {
  top: var(--shot-mobile-top, 25.04%);
  left: var(--shot-mobile-left, 4.56%);
  width: var(--shot-mobile-width, 21.58%);
  aspect-ratio: var(--shot-mobile-aspect, 265 / 519);
  /* Cursor-parallax — mobile sits between logo (front) and desktop
     (back), so it gets a mid-range depth (10px). */
  translate: calc(var(--parallax-x, 0) * 10px)
    calc(var(--parallax-y, 0) * 10px);
  transition: translate 450ms cubic-bezier(0.65, 0, 0.35, 1);
}
.project.is-parallaxing .project__shot--mobile {
  transition: translate 150ms linear;
}
.project__shot--mobile img {
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
}

/* =============================================================
   Google Primer — bespoke thumbnail behavior
   Reference: Figma node 111:536 (Pfolio)
   -------------------------------------------------------------
   The new GP design swaps the single landscape screenshot for a
   rotated 6-phone montage (asset already includes per-phone
   shadows, so the wrapper's own shadow is suppressed). The
   montage is positioned to extend WAY beyond the card's bounds
   on top + sides, with the card's overflow:hidden clipping it.
   On hover the card un-clips so the full montage spills out —
   the image's anchor (top/left/transform-origin) doesn't move,
   only the clipping changes.
   ============================================================= */

/* The montage PNG is transparent between phones — each phone is its
   own opaque region with rounded corners baked into the source. The
   wrapper's shared box-shadow + 20px border-radius would draw ONE
   shadow around the rectangular bounding box, which is wrong here.
   Strip them for GP only, then apply per-phone shadows via the
   drop-shadow filter rule below. Wrapper overflow also has to be
   visible so the drop-shadow can spill outside the image's own box. */
.project__link[data-project="google-primer"] .project__shot--desktop,
.project-view[data-project-view="google-primer"] .project-view__shot--desktop {
  box-shadow: none;
  border-radius: 0;
  overflow: visible;
}

/* Per-phone drop shadows applied via filter:drop-shadow(), which
   traces the PNG's alpha channel and draws a separate soft shadow
   wrapping each phone's rounded corners — matching the Figma spec
   exactly (8px 8px 48px rgba(0,0,0,0.2) per phone). The filter
   resolves in the rotated coordinate space of the parent wrapper,
   so the shadows tilt with the montage like they do in Figma. */
.project__link[data-project="google-primer"] .project__shot--desktop img,
.project-view[data-project-view="google-primer"] .project-view__shot--desktop img {
  filter: drop-shadow(8px 8px 48px rgba(0, 0, 0, 0.2));
}

/* The new montage embeds its own array of phones, so the
   bottom-left mobile screenshot is no longer part of the
   composition. */
.project__link[data-project="google-primer"] .project__shot--mobile,
.project-view[data-project-view="google-primer"] .project-view__shot--mobile {
  display: none;
}

/* Asana interior masthead: nudge the screenshots up within the bar so
   they sit higher than their home-thumbnail rest position. Scoped to the
   project-view (not the home card) so the home grid thumbnail is
   untouched. Desktop keeps the rotate-var slot it composes with. */
.project-view[data-project-view="asana"] .project-view__shot--desktop {
  transform: translateY(-80px) rotate(var(--shot-desktop-rotate, 0deg));
}
.project-view[data-project-view="asana"] .project-view__shot--mobile {
  transform: translateY(-40px);
}

/* GP interior masthead matches Figma node 115:1601 directly. The
   rotated montage uses the SAME wrapper insets as the home thumbnail
   (--shot-desktop-top: -58.5%, --shot-desktop-left: -13.19%,
   --shot-desktop-right: -44.32%) so the FLIP morph from thumbnail to
   interior is a pure resize — no shot-position re-computation, no
   is-morphing override needed. The masthead-inner uses the standard
   grid content area (no per-project max-width override) so the
   montage scales smoothly with the masthead's full-bleed width. */

/* Hover-unclip: when the GP card is hovered (or focus-within for
   keyboard nav), un-mask the rotated montage so the phones that
   sit OUTSIDE the 1228×691 card box become visible. We can't
   animate `overflow`, so instead the link uses an animatable
   `clip-path: inset()` to hold the same rounded-rectangle clip at
   rest, then expands it outward on hover.

   At rest: inset(0 round 16px) — visually identical to
   overflow:hidden + border-radius:16px.

   On hover: generous negative insets matching where the montage
   spills past the card. The inline style sets the shot wrapper to
   top:-58.5%, left:-13.19%, right:-44.32% with aspect 1934/1943.

   Top/left/right are straightforward — they match the wrapper
   offsets directly (-58.5% / -13.19% / -44.32%) plus a small
   buffer for the drop-shadow falloff (8px offset + 48px blur ≈
   56px past each phone edge), giving us -65% / -25% / -55%.

   Bottom is the tricky one. The wrapper is sized by aspect-ratio,
   not an explicit `bottom`, so its bottom edge is computed:
     wrapper_w = card_w × (1 + 0.1319 + 0.4432) = card_w × 1.5751
     wrapper_h = wrapper_w × 1943/1934    = card_w × 1.5824
     wrapper_bottom (px from card top) = card_h × -0.585 + wrapper_h
   At the card's reference 1228×691, that's -404.2 + 1942.8 =
   1538.6px from the card top, which is 847.6px (≈122.66% of
   card height) past the card bottom. Add shadow-falloff buffer
   and we land at -130%.

   Border-radius (16px) is kept on both ends — at full unclip the
   rounded corners of the inset rectangle land far outside any
   visible content, so they don't show, but holding the round value
   constant keeps the transition cheap and avoids interpolating two
   separate animation tracks.

   The link's own `background: var(--project-color)` is bound to
   its border-box and is not expanded by clip-path, so the visible
   cyan card stays its same rounded shape throughout. Only the
   spilled-phone pixels gain visibility. */
.project__link[data-project="google-primer"] {
  overflow: visible;
  clip-path: inset(0 round 16px);
  transition: clip-path 600ms cubic-bezier(0.65, 0, 0.35, 1);
}
.project:hover .project__link[data-project="google-primer"],
.project:focus-within .project__link[data-project="google-primer"] {
  clip-path: inset(-65% -55% -130% -25% round 16px);
}
@media (prefers-reduced-motion: reduce) {
  .project__link[data-project="google-primer"] {
    transition: none;
  }
}

/* =============================================================
   Yahoo / Asana — shadow unclip on hover
   -------------------------------------------------------------
   The base .project__link has `overflow: hidden` so the
   .project__shot wrapper's box-shadow (8px 8px 48px) gets cropped
   at the rounded card edge — most visible on hover when the card
   expands and the shadow is supposed to spill past the screenshot.
   Mirror the Google Primer pattern (above): swap overflow:hidden
   for an animatable clip-path so we can expand the clip outward on
   hover (overflow itself cannot transition). At rest,
   `inset(0 round 16px)` is visually identical to the original
   overflow:hidden + border-radius:16px clip. On hover the inset
   goes negative on right + bottom (where the shadow's 8px offset
   pushes it most), with smaller buffers on top + left for the
   shadow's blur halo. GP has its own bigger expansion above —
   excluded here via :not(). The 600ms / 0.65,0,0.35,1 timing
   matches GP for visual consistency.
   ============================================================= */
.project__link:not([data-project="google-primer"]) {
  overflow: visible;
  clip-path: inset(0 round 16px);
  transition: clip-path 600ms cubic-bezier(0.65, 0, 0.35, 1);
}
.project:hover .project__link:not([data-project="google-primer"]),
.project:focus-within .project__link:not([data-project="google-primer"]) {
  clip-path: inset(-3% -8% -12% -3% round 16px);
}
@media (prefers-reduced-motion: reduce) {
  .project__link:not([data-project="google-primer"]) {
    transition: none;
  }
}

/* =============================================================
   Project view (internal page) — opened by clicking a project card
   Reference: Figma node 59:481 (Pfolio)
   ============================================================= */

/* When project view is open, hide the home main + the proficiencies
   + contact bands so the project view becomes the only visible content. */
body.project-view-open [data-page-grid],
body.project-view-open .proficiencies,
body.project-view-open .contact {
  display: none;
}
body.project-view-open {
  overflow-x: clip;
}

/* Project view: lift the header out of flow so the ALEX MILLER wordmark
   overlaps the masthead in its normal top-left position. Hide the social
   icons (the home nav) — they're replaced by the interior nav, which
   reveals on hover. Override grid → flex so brand + interior-nav flow
   horizontally with a clean 32px gap (matches the Figma layout). */
body.project-view-open .site-header {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  /* Above masthead (z:1) and the morphing masthead (z:10) so the
     wordmark stays visible throughout the open animation. */
  z-index: 11;
  display: flex;
  align-items: center;
  gap: 32px;
}
body.project-view-open .site-header__nav {
  display: none;
}

/* Pin the interior-page header to the top of the viewport so the
   wordmark + interior nav stay accessible while the user scrolls
   through the project copy. Uses position:fixed (rather than CSS
   sticky) because the rule above already establishes the header as
   position:absolute; top:0 — switching to fixed gives the IDENTICAL
   visual position at scroll=0, then keeps the header pinned during
   scroll without altering the document flow that the masthead's
   FLIP morph animation depends on.

   Backgrounds match each project's masthead color so the header
   reads as a continuous extension of the masthead band (no visual
   seam during the first 600px of scroll, where the masthead sits
   directly behind the header), and stays legible once the user
   scrolls past the masthead onto the cream body content. The
   per-project rules use :has() against the visible (non-[hidden])
   .project-view article — exactly one is unhidden at a time, so the
   right color always wins. */
body.project-view-open .site-header {
  position: fixed;
}
/* Site-header on interior pages is TRANSPARENT — the body itself paints
   the project color (see rules below) so the masthead's image shadows
   can extend past the masthead's bottom edge into the body without a
   visible cream-bg seam at y=600. The wordmark + interior nav read
   against the body's project color directly. */
body.project-view-open .site-header {
  background: transparent;
}

/* Body background tracks the active project's masthead color so the
   masthead's `overflow: hidden` clip line at y=600 becomes visually
   seamless with the body below it — shadows from the masthead
   screenshots fade into the same project color on both sides of the
   masthead's bottom edge.

   The per-project hex values match the masthead bg color set in the
   home thumbnails (.project { --project-color: #... }) and the
   project-view masthead's `background: var(--project-color)` default.

   The 750ms cubic-bezier transition on `body { transition: ... }`
   above runs on this color change too, so the home → interior bg
   shift composes with the masthead morph in one continuous wash. */
body.project-view-open:has(.project-view[data-project-view="yahoo"]:not([hidden])) {
  background-color: #7d2eff;
}
body.project-view-open:has(.project-view[data-project-view="asana"]:not([hidden])) {
  background-color: #d84c4c;
}
body.project-view-open:has(.project-view[data-project-view="google-primer"]:not([hidden])) {
  background-color: #5cbedc;
}

/* =============================================================
   Page wipe — return-to-home transition
   -------------------------------------------------------------
   A solid #151314 (color-ink) panel sweeps right→left across the
   viewport when the user clicks the wordmark on a project page.
   - Phase 1: panel slides in from the right edge to fully cover
   - Phase 2 (under cover): JS swaps the project view back to home
   - Phase 3: panel exits off the left edge, revealing home
   - Phase 4: JS resets the panel back off-screen-right (no anim)
   The motion is one continuous direction the whole time.
   ============================================================= */
.page-wipe {
  position: fixed;
  inset: 0;
  background: var(--color-ink);
  z-index: 99999;
  pointer-events: none;
  transform: translateX(100%);
  transition: transform 450ms cubic-bezier(0.65, 0, 0.35, 1);
  will-change: transform;
}

.page-wipe.is-covering {
  transform: translateX(0);
}

.page-wipe.is-exiting {
  transform: translateX(-100%);
}

/* Used between exiting → ready-for-next-use. Snaps back off-screen
   right with no transition, so the panel is invisibly ready again. */
.page-wipe.is-resetting {
  transform: translateX(100%);
  transition: none;
}

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

/* =============================================================
   Blood-drop trail
   -------------------------------------------------------------
   Spawned by scripts/blood-trail.js when the cursor moves fast
   for >1s continuously. Drops fall from the wrist of the cursor
   with gravity-like easing, fade, then remove themselves.
   z-index sits above page content but below the page-wipe
   (99999) so a return-to-home wipe cleanly covers any in-flight
   drops.
   ============================================================= */
.blood-drop {
  position: fixed;
  pointer-events: none;
  z-index: 9998;
  transform: translate(-50%, 0) rotate(var(--start-rotate, 0deg));
  /* 1600ms total — drops settle quickly, hold opaque while the trail
     persists behind the cursor, then fade out cleanly at the end. */
  animation: blood-drop-trail 1600ms cubic-bezier(0.2, 0.8, 0.2, 1)
    forwards;
  will-change: transform, opacity;
}

/* Trail-style keyframe: drops do their tiny "settle" move in the
   first ~15% of the timeline, then hold opaque (so the trail of
   drops behind the cursor reads as a continuous mark), then fade
   to 0 in the last ~30%. */
@keyframes blood-drop-trail {
  0% {
    opacity: 1;
    transform: translate(-50%, 0) rotate(var(--start-rotate, 0deg));
  }
  15% {
    opacity: 1;
    transform:
      translate(calc(-50% + var(--fall-x, 0px)), var(--fall-y, 8px))
      rotate(var(--start-rotate, 0deg));
  }
  70% {
    opacity: 1;
    transform:
      translate(calc(-50% + var(--fall-x, 0px)), var(--fall-y, 8px))
      rotate(var(--start-rotate, 0deg));
  }
  100% {
    opacity: 0;
    transform:
      translate(calc(-50% + var(--fall-x, 0px)), var(--fall-y, 8px))
      rotate(var(--start-rotate, 0deg));
  }
}

@media (prefers-reduced-motion: reduce) {
  .blood-drop {
    display: none;
  }
}

/* Project-view article paints a white (--color-bg) background that
   becomes the DEFAULT surface for every section below the masthead,
   plus the 96px spacers that sit between them. Individual sections
   can override with their own `background-color` (e.g. the split
   section's ink + grey columns) to break the white run, but if a
   section ships with no bg it gets the white default automatically.

   The masthead has its own opaque bg (`var(--project-color)`) so
   it paints over this white at the top 600px of the article, and
   the project-color body bg still bleeds above and behind via the
   `body.project-view-open:has(...)` rules — important during the
   FLIP morph where the masthead's box is animating. */
.project-view {
  position: relative;
  width: 100%;
  background-color: var(--color-bg);
}

/* The project view reserves 600px of space at the top for the masthead.
   The masthead itself is absolutely positioned inside that space, so it
   can be swapped to position:fixed during the morph animation without
   disrupting the body content's layout. Masthead height matches Figma
   node 115:1248 (clips bottoms of the screenshots that would otherwise
   peek past the masthead's bottom edge). */
.project-view {
  /* Masthead height — also the top padding that reserves space for the
     absolutely-positioned masthead. A variable so the mobile pass can
     shrink it (and the morph target reads the same value). */
  --masthead-h: 600px;
  padding-top: var(--masthead-h);
}

/* Masthead — full-width colored banner that holds the same two
   screenshots from the thumbnail (continuity of imagery). 600px tall,
   matching Figma 115:1248. */
.project-view__masthead {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: var(--masthead-h);
  background: var(--project-color, #7d2eff);
  overflow: hidden;
  z-index: 1;
}

/* Inner wrapper that caps the screenshots' positioning context at the
   grid's max-width (1920px). The masthead's purple background still
   bleeds to the viewport edges, but the screenshots inside use
   percentage positioning relative to this wrapper, so they stop
   scaling once the wrapper hits its 1920px cap. Past that, the wrapper
   is centered via margin-inline: auto and the masthead bg fills the
   empty space on either side. */
.project-view__masthead-inner {
  position: relative;
  height: 100%;
  /* Match the grid's CONTENT AREA exactly (not the grid's outer box).
     The .grid container is `max-width: 1920` + `padding-inline:
     var(--grid-margin)`, so its inner content area is
     min(viewport, 1920) − 2*grid-margin. Mirror that here so the
     screenshots — positioned via percentages of this wrapper — stay
     flush with the same column rails as everything else on the page. */
  width: calc(100% - 2 * var(--grid-margin));
  max-width: calc(1920px - 2 * var(--grid-margin));
  margin-inline: auto;
}

/* During the FLIP morph, the masthead temporarily becomes position:fixed
   so JS can animate its viewport-relative top/left/width/height. After
   the animation finishes, the .is-morphing class is removed and it
   returns to its default absolute position — same visual spot, no jump. */
.project-view.is-morphing .project-view__masthead {
  position: fixed;
  z-index: 10;
}

/* Identical positioning system to the home thumbnail (.project__shot)
   so the screenshots sit at the exact same percentages of their parent
   masthead in both views. That means the masthead's FLIP morph carries
   them smoothly without any internal repositioning. */
.project-view__shot {
  position: absolute;
  overflow: hidden;
  border-radius: 12px;
  box-shadow: 8px 8px 48px 0 rgba(0, 0, 0, 0.2);
}
.project-view__shot img {
  position: absolute;
  display: block;
  max-width: none;
}

/* Desktop screenshot — wrapper position + aspect set per-project via
   the SAME custom properties the home thumbnail (.project__shot) uses,
   so the FLIP morph from home → project view doesn't visibly reposition.
   Each project-view article sets these via its inline style; defaults
   here match Yahoo's home thumbnail so any project-view without
   overrides falls back gracefully. */
.project-view__shot--desktop {
  top: var(--shot-desktop-top, 12.45%);
  left: var(--shot-desktop-left, 18.57%);
  right: var(--shot-desktop-right, 4.56%);
  aspect-ratio: var(--shot-desktop-aspect, 944 / 605);
  transform: rotate(var(--shot-desktop-rotate, 0deg));
  transform-origin: 50% 50%;
}
.project-view__shot--desktop img {
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
}

/* Mobile screenshot — same custom-property pattern. Defaults match the
   Yahoo home thumbnail. */
.project-view__shot--mobile {
  top: var(--shot-mobile-top, 25.04%);
  left: var(--shot-mobile-left, 4.56%);
  width: var(--shot-mobile-width, 21.58%);
  aspect-ratio: var(--shot-mobile-aspect, 265 / 519);
}
.project-view__shot--mobile img {
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
}

/* Body section — fades + slides up after the masthead settles.
   Sits in the project-view's content area (below the 600px top padding
   that's reserved for the masthead). */
/* Grid wrapper: provides the 24-column layout via the .grid class +
   80px breathing room above and below the content block, matching
   Figma 115:1248 (py-[80px]).

   This is the FIRST CONTENT SECTION — it paints the cream/off-white
   background of the body content, layered atop the project-color body
   bg. The ::before pseudo extends the cream fill edge-to-edge across
   the viewport (rather than being capped by the .grid class's 1920px
   max-width) so the section reads as a full-width content band. */
/* Scroll-parallax for body content. The JS (scripts/scroll.js) writes a
   px value into --py for any [data-parallax-y] element; we apply it via the
   independent `translate` property so it COMPOSES with any existing
   `transform` (e.g. the reveal translateY) rather than clobbering it.
   Layered speeds (text ~0.03, media ~0.06) live on the data attribute. */
[data-parallax-y] {
  translate: 0 var(--py, 0px);
}

.project-view__body-grid {
  position: relative;
  padding-block: 100px;
}
.project-view__body-grid::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 100vw;
  background-color: var(--color-bg);
  z-index: -1;
}

.project-view__body {
  /* Anchor to col 3 → col 22 at xl (matches the home hero / timeline /
     outro left rail at col 3). Falls back to col 2 → col 11 at md,
     full-width at sm/xs.

     Uses CSS subgrid so children can place themselves on the outer
     24-col grid's column tracks. Internal col mapping at xl:
       body col 1  = outer col 3
       body col 6  = outer col 8   ← info ends here
       body col 7  = outer col 9   ← gap column
       body col 8  = outer col 10  ← copy starts here
       body col 20 = outer col 22                                    */
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 3 / span 20;
  /* Vertical rhythm — 40px between the headline row and the info/copy row. */
  row-gap: 40px;
  opacity: 0;
  transform: translateY(40px);
  transition:
    opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1),
    transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

@media (max-width: 1279px) {
  .project-view__body {
    grid-column: 2 / span 10;
    row-gap: 32px;
  }
  /* Subgrid alignment doesn't make sense at narrower viewports —
     collapse info + copy to a single stacked column. */
  .project-view__info,
  .project-view__copy {
    grid-column: 1 / -1;
  }
}

@media (max-width: 767px) {
  .project-view__body {
    grid-column: 1 / -1;
  }
}

.project-view.is-content-revealed .project-view__body {
  opacity: 1;
  transform: translateY(0);
}

/* "Project intro" — a duplicate of the project-view content section
   (the block below the masthead), appended as the last section of a
   project view inside the (decrypted) protected content. Force its body
   visible so it never gets stuck at the base .project-view__body
   opacity:0 if it's injected before .is-content-revealed is asserted. */
.project-intro .project-view__body {
  opacity: 1;
  transform: none;
}

.project-view__headline {
  /* Spacing between headline and the info/copy row is owned by the
     parent grid's row-gap (40px), not a margin here. */
  grid-column: 1 / -1;
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  /* Matches the home hero headline scaling but with slightly tighter
     letter-spacing (Figma's 2.88px on 72px = ~4%). */
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-ink);
}

.project-view__info {
  /* Body internal cols 1–6 = outer cols 4–9. */
  grid-column: 1 / span 6;
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.project-view__info-block {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.project-view__info-label {
  margin: 0;
  font-family: var(--font-body);
  /* Body Large Emphasized per the design system — 18/26 with
     font-weight 500. Color: dark gold (--color-gold-dark) so the
     eyebrow labels read as a tertiary accent on top of the cream
     content area. */
  font-weight: 500;
  font-size: 18px;
  line-height: 1.444;
  color: var(--color-gold-dark);
}
.project-view__info-value {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 18px;
  line-height: 1;
}

/* Right-column copy block — section subheads + body paragraphs.
   Body internal col 8 → end (-1) = outer cols 11–22. Body col 7
   (= outer col 10) stays empty and acts as the gap between info
   and copy — at xl this resolves to ~85px, close to Figma's 79px. */
.project-view__copy {
  grid-column: 8 / -1;
}

/* Subhead — matches the .project-view__info-label styling exactly:
   Body Large Emphasized (Gibson Medium 18/26) in dark gold. Acts as
   a small eyebrow label above each body paragraph (label/content
   pattern, like Role/value in the info column). The dark-gold +
   font-weight 500 treatment differentiates the eyebrow from the
   body copy below it without needing extra spacing. */
.project-view__subhead {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 18px;
  line-height: 1.444;
  color: var(--color-gold-dark);
}

/* Body paragraph — Gibson Book at 18/26 (Body Large per Figma
   115:1248) in Black/Light Black. 6px gap below the subhead label. */
.project-view__paragraph {
  margin: 6px 0 0;
  font-family: var(--font-body);
  font-weight: 400;
  /* Body Large — 20/28. */
  font-size: 20px;
  line-height: 1.4;
  color: var(--color-black-light);
}

/* When a subhead follows a paragraph, treat it as a new section break
   with breathing room above (matches Figma's 24px section gap). */
.project-view__paragraph + .project-view__subhead {
  margin-top: 24px;
}

/* =============================================================
   .project-view__feature — full-bleed full-screen image template
   -------------------------------------------------------------
   Reusable template for breaking up case-study copy with a
   full-width, full-viewport-height visual block (image, video,
   placeholder color block, etc.) that parallaxes as the user
   scrolls past it. Drop in anywhere inside a `.project-view`
   article — uses negative margins to escape the article's
   container width and bleed edge-to-edge regardless of where it
   sits in the layout.

   Markup:
     <section class="project-view__feature" aria-hidden="true">
       <div class="project-view__feature-inner" data-parallax="0.08">
         <!-- optional: <img class="project-view__feature-img"
              src="..." alt="..."> -->
       </div>
     </section>

   How it works:
   - .project-view__feature is the VISIBLE viewport — a 100vh tall,
     100vw wide box clipped by overflow: hidden. The negative
     `margin-inline: calc(50% - 50vw)` cancels any parent grid /
     padding, so the section reads as full-bleed even when the
     enclosing .project-view__body or any other constrained
     container holds it.
   - .project-view__feature-inner is overscanned by 12% top + 12%
     bottom (inset: -12% 0 → 124% of parent height). The parallax
     RAF loop in scripts/scroll.js writes a transform on this inner
     div via the [data-parallax] attribute; the overscan guarantees
     the translate never exposes the page background above or
     below the visible viewport.
   - Placeholder: a neutral grey #d9d9d9. Swap with a child <img>
     (use .project-view__feature-img — full-cover styling provided
     below), a `background-image` on the inner, or any visual
     content. */
.project-view__feature {
  position: relative;
  width: 100vw;
  height: 100vh;
  /* Escape the parent's width + horizontal padding so the feature
     bleeds edge-to-edge no matter what container it lives in
     (project-view__body, project-view__body-grid, etc.).
     `calc(50% - 50vw)` shifts the section's left and right edges
     half a viewport-width past its parent's edges — a classic
     full-bleed-from-constrained-grid trick. */
  margin-inline: calc(50% - 50vw);
  margin-block: 96px;
  overflow: hidden;
}

.project-view__feature-inner {
  position: absolute;
  inset: -12% 0;
  /* Neutral grey placeholder — swap to a real <img>, video, or
     background-image when ready. */
  background-color: #d9d9d9;
  will-change: transform;
}

/* Optional helper for dropping a real image into the feature. The
   img sits absolute-fill within the inner (which is already
   overscanned), so the parallax transform on the inner carries the
   image with it. `object-fit: cover` keeps it edge-to-edge at any
   aspect ratio. */
.project-view__feature-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Reduced-motion: kill the inner overscan so parallax has no room
   to translate — scroll.js still reads the [data-parallax] attr
   but the visible result becomes an effectively static image. */
@media (prefers-reduced-motion: reduce) {
  .project-view__feature-inner {
    inset: 0;
  }
}

/* =============================================================
   .project-view__journeys — full-screen sticky dark panel
   -------------------------------------------------------------
   A 100vh ink panel that pins to the viewport while the user
   scrolls through the section's runway — same "hold in place,
   then release" feel as the homepage outro headline, but as a
   full-screen dark stage. Gold eyebrow + large cream headline,
   vertically centered on the grid's content rails.
   Reference: Figma 155:661.
   ============================================================= */
.project-view__journeys {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  background-color: var(--color-ink);
  /* Unfixed: a normal flowing band (no pinned runway). */
  min-height: 0;
}

.project-view__journeys-pin {
  position: static;
  height: auto;
  display: flex;
  align-items: center;
  /* Normal section padding now that it's not a full-screen stage. */
  padding-block: 120px;
  will-change: auto;
}

.project-view__journeys-grid {
  width: 100%;
}

.project-view__journeys-body {
  /* Same column rails as the rest of the project-view content. */
  grid-column: 4 / span 19;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.project-view__journeys-eyebrow {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 18px;
  line-height: 1.444;
  color: var(--color-gold);
}

.project-view__journeys-headline {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-white);
}

@media (max-width: 1279px) {
  .project-view__journeys-body {
    grid-column: 2 / span 10;
  }
}

@media (max-width: 767px) {
  .project-view__journeys-body {
    grid-column: 1 / -1;
  }
}

@media (max-width: 1180px) {
  /* On mobile the journeys headline is a normal flowing band, not a
     pinned full-screen stage — 60px of breathing room top and bottom. */
  .project-view__journeys {
    min-height: 0;
    /* The journeys/strategy boundary lands on a fractional pixel, so pixel
       rounding can leak a 1px line of the purple body bg between the two ink
       bands. Overlap the Strategy section by 1px — both are ink, so the
       overlap is invisible but the seam is gone. */
    margin-bottom: -1px;
  }
  .project-view__journeys-pin {
    position: static;
    height: auto;
    padding-block: 60px;
    /* Drop the GPU layer promotion — on mobile the pin is static and
       never transforms, and a composited layer ending on a fractional
       pixel leaks a 1px strip of the purple body bg between this section
       and the Strategy section below. */
    will-change: auto;
  }
}

/* =============================================================
   .project-view__vision — full-bleed dark closing band
   -------------------------------------------------------------
   Ink (#151314) full-width section with a cream display headline
   and a row of three "Overview" columns (gold eyebrow label +
   cream body copy). Reference: Figma 150:591.

   Full-bleed via the same `margin-inline: calc(50% - 50vw)` trick
   the feature / split templates use, so it escapes the project
   view's constrained container. The inner content re-aligns to the
   24-col grid (cols 4–22 at xl) so the headline + columns line up
   with the rest of the project-view content above. py-100 / gap-40
   / 3 × 315px columns with an 85px gap mirror the Figma frame.
   ============================================================= */
.project-view__vision {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  background-color: var(--color-ink);
  padding-block: 100px;
}

/* When the feature image sits directly above a dark band (journeys or
   vision), drop the feature's bottom margin so the grey image meets the
   dark section flush — no project-color gap between them. */
.project-view__feature:has(+ .project-view__journeys),
.project-view__feature:has(+ .project-view__vision),
.project-view__feature:has(+ .project-view__split--full),
.project-view__feature:has(+ .project-view__primer-stats) {
  margin-bottom: 0;
}

/* The split's 96px bottom margin would otherwise stack on top of the
   Project intro's 100px top padding, making the space ABOVE the intro
   content far larger than the (margin-less) space below it. Drop it so
   the cream band's 100px padding is the only spacing on both sides. */
.project-view__split:has(+ .project-intro) {
  margin-bottom: 0;
}

.project-view__vision-body {
  /* Cols 3–22 at xl — starts at col 3 with a 2-col pad on each side,
     matching the intro sections' left rail. */
  grid-column: 3 / span 20;
  display: flex;
  flex-direction: column;
  gap: 40px;
}

.project-view__vision-title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-white);
}

.project-view__vision-columns {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* Figma 85px gap at xl; eases down on narrower viewports so the
     three columns keep comfortable measure. */
  gap: clamp(40px, 5.5vw, 85px);
  align-items: start;
}

.project-view__vision-col {
  display: flex;
  flex-direction: column;
  /* 6px between the eyebrow label and the body copy (Figma gap-6). */
  gap: 6px;
}

.project-view__vision-label {
  margin: 0;
  font-family: var(--font-body);
  /* Body Large Emphasized — Gibson Medium 18 in Gold. */
  font-weight: 500;
  font-size: 18px;
  line-height: 1.444;
  color: var(--color-gold);
}

.project-view__vision-text {
  margin: 0;
  font-family: var(--font-body);
  /* Body — Gibson Regular 16/24 in cream. */
  font-weight: 400;
  font-size: 16px;
  line-height: 1.5;
  color: var(--color-cream);
}

/* md — narrower content inset (cols 2–11), columns keep 3-up but
   the clamp() gap above already tightens them. */
@media (max-width: 1279px) {
  .project-view__vision-body {
    grid-column: 2 / span 10;
  }
}

/* sm / xs — stack the three columns into one. */
@media (max-width: 767px) {
  .project-view__vision-body {
    grid-column: 1 / -1;
  }
  .project-view__vision-columns {
    grid-template-columns: 1fr;
    gap: 40px;
  }
}

/* =============================================================
   .project-view__strategy — sticky-title strategy scroll
   -------------------------------------------------------------
   Ink panel. The "Strategy" title pins (position: sticky) in the left
   column while the three principles scroll past it in the right column.
   Each principle is a gold "Body Large Emphasized" label over a cream
   "Body Large" (20/28) paragraph, stacked vertically. The principles sit
   in tall bands so they scroll one-at-a-time past the fixed title.
   Reference: Figma 165:379.
   ============================================================= */
.project-view__strategy {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  background-color: var(--color-ink);
  padding-block: 120px;
}

.project-view__strategy-aside {
  /* Title column — starts at col 3, matching the interior content rail.
     Top-aligned so the headline sits level with the first principle in the
     list to its right (rather than centered against the whole list). */
  grid-column: 3 / span 6;
  align-self: start;
}

.project-view__strategy-title {
  /* Unfixed: the title sits statically at the top of its column. */
  position: static;
  top: auto;
  transform: none;
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-white);
}

.project-view__strategy-list {
  grid-column: 11 / span 12;
  display: flex;
  flex-direction: column;
  /* Unfixed: a compact 24px stack at natural height (no tall runway). */
  gap: 24px;
  justify-content: flex-start;
  min-height: 0;
}

.project-view__strategy-item {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.project-view__strategy-label {
  margin: 0;
  font-family: var(--font-body);
  /* Body Large Emphasized — Medium 18, Gold. */
  font-weight: 500;
  font-size: 18px;
  line-height: 1.444;
  color: var(--color-gold);
}

.project-view__strategy-text {
  margin: 0;
  font-family: var(--font-body);
  /* Body Large — Regular 20/28, Cream. */
  font-weight: 400;
  font-size: 20px;
  line-height: 1.4;
  color: var(--color-cream);
  max-width: 540px;
}

/* md — pull both columns to the narrower content inset and stack the
   title above the list (no sticky; not enough horizontal room). */
@media (max-width: 1279px) {
  .project-view__strategy-aside,
  .project-view__strategy-list {
    grid-column: 2 / span 10;
  }
  .project-view__strategy-title {
    position: static;
    top: auto;
    transform: none;
    margin-bottom: 40px;
  }
  .project-view__strategy-list {
    min-height: 0;
    justify-content: flex-start;
  }
}

@media (max-width: 767px) {
  .project-view__strategy-aside,
  .project-view__strategy-list {
    grid-column: 1 / -1;
  }
}

/* =============================================================
   .project-view__showcase — light section: headline + a desktop
   screenshot with a mobile phone overlapping its right edge.
   Reference: Figma 156:763 ("Making action effortless").
   -------------------------------------------------------------
   Full-bleed light band. The headline sits on the standard content
   rails, stacked above the image cluster (a desktop screenshot with
   the mobile phone overlapping its right edge). The cluster spans the
   full grid width with the two shots absolutely positioned (the
   desktop inset from the left so it reads center-right).
   ============================================================= */
.project-view__showcase {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  background-color: var(--color-bg);
  padding-block: 100px;
}

.project-view__showcase-title {
  grid-column: 3 / span 20;
  margin: 0 0 100px;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-ink);
}

/* Each [paragraph + image cluster] repeats as its own grid block under
   the section title; stack them with breathing room between. */
.project-view__showcase-block + .project-view__showcase-block {
  margin-top: 96px;
}

/* Paragraph block in the cluster's empty left area (the desktop shot
   starts at col 7). Shares row 1 with the cluster and centers against
   it. */
.project-view__showcase-aside {
  grid-column: 1 / span 6;
  grid-row: 1;
  align-self: center;
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* Pull the text in by half a grid column on the right (one column
     track ÷ 2). Derived from the grid vars so it tracks the breakpoint:
     trackW = (gridContent − (cols−1)·gutter) / cols, gridContent =
     100vw − 2·margin. */
  padding-right: calc(
    (100vw - 2 * var(--grid-margin) - (var(--grid-cols) - 1) * var(--grid-gutter)) /
      var(--grid-cols) / 2
  );
}

.project-view__showcase-aside-label {
  margin: 0;
  font-family: var(--font-body);
  /* Body Large Emphasized — Gibson Medium 18 in Dark Gold. */
  font-weight: 500;
  font-size: 18px;
  line-height: 1.444;
  color: var(--color-gold-dark);
}

.project-view__showcase-aside-text {
  margin: 0;
  font-family: var(--font-body);
  /* Body — Gibson Regular 16/24 in Black/Light Black. */
  font-weight: 400;
  font-size: 16px;
  line-height: 1.5;
  color: var(--color-black-light);
}

.project-view__showcase-cluster {
  /* Spans the full grid width so the desktop's column-aligned margins
     and the mobile's % position resolve against the same rails the
     headline uses. The in-flow desktop shot defines the height. */
  grid-column: 1 / -1;
  grid-row: 1;
  position: relative;
}

.project-view__showcase-shot {
  position: absolute;
  overflow: hidden;
  border-radius: 20px;
  /* Match the horizontal-carousel shot shadow (half-strength 0.1). */
  box-shadow: 8px 8px 48px 0 rgba(0, 0, 0, 0.1);
}

.project-view__showcase-shot img,
.project-view__showcase-shot video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Top-anchor so each screenshot crops to its header / top content
     when the source is taller than the shot frame (matches Figma). */
  object-position: top center;
  display: block;
}

.project-view__showcase-shot--desktop {
  /* In-flow (relative) so it defines the cluster height. Margins place
     it from the start of grid column 7 (≈25.4%) to the right edge of
     column 23 (≈2.7% inset), leaving col 24 for the mobile overlap. */
  position: relative;
  margin-left: 25.4%;
  margin-right: 2.7%;
  /* Frame aspect defaults to the original recording's, but each shot can
     override via --shot-aspect so the source fills cover with no crop. */
  aspect-ratio: var(--shot-aspect, 888 / 525);
}

/* In blocks with no phone overlay (the Multilisting / Places / Hotels
   supertop sections), let the desktop video run to the right grid edge
   instead of stopping at column 23 — there's no phone to leave room for. */
.project-view__showcase-cluster:not(:has(.project-view__showcase-shot--mobile))
  .project-view__showcase-shot--desktop {
  margin-right: 0;
}

/* Per-shot frame aspects so each source fills cover with no cropping.
   Hotels is 16:9 (wider than the default 888/525); the Multilisting
   still is 1024×638 (slightly taller). */
.project-view__showcase-shot--16x9 {
  --shot-aspect: 1286 / 720;
}
.project-view__showcase-shot--multilisting {
  --shot-aspect: 1744 / 1080;
}

.project-view__showcase-shot--mobile {
  /* 4 grid columns wide, flush to the grid's right edge, and
     vertically centered against the desktop shot. The width calc
     reconstructs 4 column tracks + the 3 gutters between them from
     the cluster's full width (24 cols): colW = (100% − 23·gutter)/24,
     so 4·colW + 3·gutter = (100% − 23·gutter)/6 + 3·gutter. */
  right: 0;
  width: calc(
    (100% - 23 * var(--grid-gutter)) / 6 + 3 * var(--grid-gutter)
  );
  top: 50%;
  transform: translateY(-50%);
  aspect-ratio: 262 / 620;
  z-index: 1;
}

/* ≤1279 (grid collapses to 12 cols) — within each block, stack the
   paragraph above the cluster, the gif goes full-width, and the phone
   centers beneath it. Triggers at 1279 (not 1180) because the desktop
   overlap layout is built for the 24-col grid. */
@media (max-width: 1279px) {
  .project-view__showcase-title {
    grid-column: 2 / span 10;
  }
  .project-view__showcase-block.grid {
    /* Image first, paragraph below it, 24px apart. Needs .grid in the
       selector to outrank grid.css's `.grid { row-gap: var(--grid-gutter) }`. */
    row-gap: 24px;
  }
  .project-view__showcase-aside {
    grid-column: 2 / span 10;
    grid-row: 2;
    align-self: start;
    margin-top: 0;
    margin-bottom: 0;
    /* Full content width when stacked — no half-column trim. */
    padding-right: 0;
  }
  .project-view__showcase-cluster {
    grid-column: 2 / span 10;
    grid-row: 1;
  }
  .project-view__showcase-shot--desktop {
    margin-left: 0;
    margin-right: 0;
  }
  .project-view__showcase-shot--mobile {
    /* The phone mockup is desktop-only; on mobile the desktop shot
       carries the section and the overlay phone is omitted. */
    display: none;
  }
  .project-view__showcase-block + .project-view__showcase-block {
    margin-top: 64px;
  }
}

@media (max-width: 767px) {
  .project-view__showcase-title,
  .project-view__showcase-aside,
  .project-view__showcase-cluster {
    grid-column: 1 / -1;
  }
}

/* =============================================================
   .project-view__hscroll — horizontal scrolling section
   -------------------------------------------------------------
   Reusable section template for laying out a series of "feature
   card" pairs (desktop screenshot + mobile screenshot + label)
   that the user scrolls horizontally through. Reference: Figma
   139:516 (the "Hometown redesign" row in the Yahoo case study).

   Markup:
     <section class="project-view__hscroll">
       <header class="project-view__hscroll-header grid">
         <h2 class="project-view__hscroll-title">…</h2>
       </header>
       <div class="project-view__hscroll-track">
         <div class="project-view__hscroll-track-inner">
           <article class="project-view__hscroll-card">
             <div class="project-view__hscroll-shot
                         project-view__hscroll-shot--desktop">
               <img src="…" alt="…">
             </div>
             <div class="project-view__hscroll-shot
                         project-view__hscroll-shot--mobile">
               <img src="…" alt="…">
             </div>
             <p class="project-view__hscroll-label">Weather</p>
           </article>
           <!-- repeat for each card -->
         </div>
       </div>
     </section>

   Behavior:
   - Section uses the same negative-margin-bleed trick as the
     feature template so it can sit inside any constrained parent
     (body-grid, body, copy column) and still bleed edge-to-edge.
   - Track is a native `overflow-x: auto` container with snap, so
     the user can scroll horizontally with trackpad / shift+wheel /
     touch swipe / arrow keys. Scrollbar is hidden but interaction
     stays.
   - Cards size responsively (clamp(600px, 70vw, 1116px)) preserving
     the Figma aspect-ratio (1116:672 ≈ 1.66) so each card renders
     consistently across viewport widths.
   - Desktop + mobile shot positioning matches Figma 139:516's
     percent insets exactly so a real image dropped in will land
     where the design wants it.
   - Cards without images get a neutral grey placeholder bg so the
     template reads visually even before assets are added. */
.project-view__hscroll {
  position: relative;
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  margin-block: 0;
  /* Section is a TALL outer scroll runway. Inside, .project-view__
     hscroll-pin uses position: sticky to lock to the viewport top
     while the user scrolls through this runway. JS sets the runway
     height via `--hscroll-runway` based on the card count + the
     horizontal travel distance, so the user gets one viewport of
     scroll runway per card transition before the pin releases.

     Default 600vh covers a 6-card carousel at full-width cards
     (1116px) on a typical 1280px viewport — JS overrides at runtime
     with the actual computed runway. */
  height: var(--hscroll-runway, 600vh);
  /* Clip horizontally so any overflow from oversized cards stays
     contained; vertical can spill freely (e.g. shadows). */
  overflow-x: clip;
}

/* Sticky pin — locks to viewport top for the entire section's
   height, displaying the title + track + skip button while the
   track translates horizontally. Once the section's bottom catches
   up to the viewport bottom, sticky releases and vertical scroll
   resumes naturally — at which point the last card has just been
   centered (per the JS `totalTravel` math). */
.project-view__hscroll-pin {
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: var(--color-bg);
  /* `overflow-x: clip` clips horizontally inside the pin — cards
     translating in/out of viewport edges get cleanly trimmed at
     the section's left/right boundaries. `overflow-y: visible`
     leaves the y-axis open so card box-shadows can extend up
     into the header's whitespace and down into the skip-row's
     whitespace without being chopped at any internal element
     boundary. The pin is 100vh tall and sticky-pinned to the
     viewport top, so any vertical shadow spill is visually bound
     by the viewport itself, not by an inner clip box. */
  overflow-x: clip;
  overflow-y: visible;
}

.project-view__hscroll-header {
  flex: 0 0 auto;
  /* Header uses the standard 24-col .grid; title spans cols 3 → 22
     to match the body content's inset on the page. */
  padding-block: 80px 40px;
}
.project-view__hscroll-title {
  margin: 0;
  grid-column: 3 / span 20;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-ink);
}

@media (max-width: 1279px) {
  .project-view__hscroll-title {
    grid-column: 2 / span 10;
  }
}
@media (max-width: 767px) {
  .project-view__hscroll-title {
    grid-column: 1 / -1;
  }
}

.project-view__hscroll-track {
  flex: 1;
  display: flex;
  align-items: center;
  /* No native scroll on the desktop scroll-jacked variant — JS
     drives the track-inner's transform: translateX based on the
     section's vertical scroll progress.

     `overflow-x: clip` clips horizontally (essential — the wide
     track-inner translates left/right past viewport edges) but
     leaves `overflow-y: visible` so card box-shadows can extend
     UP into the header's whitespace and DOWN into the skip-row's
     whitespace without being chopped at the track's edge. */
  overflow-x: clip;
  overflow-y: visible;
  /* Explicit vertical breathing room around the cards — gives
     box-shadows (8px / 48px geometry, blur extending up to 56px
     beyond a card's edge) clear vertical space inside the track
     before they have to spill into adjacent layout zones. */
  padding-block: 64px;
}
.project-view__hscroll-track::-webkit-scrollbar {
  display: none;
}

.project-view__hscroll-track-inner {
  display: flex;
  align-items: center;
  gap: 80px;
  /* Padding-inline-start + end center the boundary cards in the
     viewport at the start (translateX(0)) and end (max translateX)
     of the carousel's horizontal travel respectively. Card width
     is `clamp(600px, 70vw, 1116px)` so we mirror the same clamp
     in the padding calc to keep first/last card centering exact
     across viewport sizes. */
  padding-inline: calc(50vw - clamp(600px, 70vw, 1116px) / 2);
  width: max-content;
  /* JS sets `transform: translateX(...)` here every scroll frame.
     `will-change` pre-promotes the track to its own compositor
     layer so 6 cards' worth of layout doesn't repaint each frame. */
  will-change: transform;
}

.project-view__hscroll-card {
  position: relative;
  flex: 0 0 auto;
  /* Card width clamps so smaller viewports still see ~1 card at a
     time without horizontal overscan getting absurd. Aspect-ratio
     locked to Figma 1116:672. */
  width: clamp(600px, 70vw, 1116px);
  aspect-ratio: 1116 / 672;
  /* Don't clip the shadows — they spill outside the card box. */
  overflow: visible;
  /* Lift the whole cluster (shots + its bottom-left label) up 40px so
     it sits higher in the pinned viewport on desktop. The track-inner's
     JS translateX is unaffected — this transform lives on the card. */
  transform: translateY(-40px);
}

/* Each shot is absolute-positioned within the card per Figma
   139:516. The percentage insets match the Figma component so
   real images dropped in will land exactly where designed. */
.project-view__hscroll-shot {
  position: absolute;
  background-color: #d9d9d9;
  border-radius: 20px;
  /* Half-strength shadow (0.1 alpha vs 0.2) keeps the cards
     anchored on the white surface without overpowering the
     screenshots. The 8px / 48px geometry stays the same so the
     light direction reads consistently with home-thumbnail
     cards. */
  box-shadow: 8px 8px 48px 0 rgba(0, 0, 0, 0.1);
  overflow: hidden;
}

.project-view__hscroll-shot--desktop {
  /* aspect-[884/557], left:0, right:10.47%, top:0 */
  left: 0;
  right: 10.47%;
  top: 0;
  aspect-ratio: 884 / 557;
  /* Cursor-parallax: desktop is the BACK layer in the depth stack —
     gets a small 5px depth so it drifts gently behind the mobile.
     Composes additively with any other transforms. See
     scripts/card-parallax.js. */
  translate: calc(var(--parallax-x, 0) * 5px)
    calc(var(--parallax-y, 0) * 5px);
  transition: translate 450ms cubic-bezier(0.65, 0, 0.35, 1);
}
.project-view__hscroll-card.is-parallaxing
  .project-view__hscroll-shot--desktop {
  transition: translate 150ms linear;
}

.project-view__hscroll-shot--mobile {
  /* aspect-[228/417], left:76.54%, right:0, top:118px (Figma)
     Convert top:118px to a card-height percentage so it scales:
     118 / 672 ≈ 17.56%. */
  left: 76.54%;
  right: 0;
  top: 17.56%;
  aspect-ratio: 228 / 417;
  /* Cursor-parallax: mobile is the FRONT layer in the stack — gets
     a slightly stronger 9px depth so it visibly leads the desktop
     during cursor movement, creating a subtle 3D feel. */
  translate: calc(var(--parallax-x, 0) * 9px)
    calc(var(--parallax-y, 0) * 9px);
  transition: translate 450ms cubic-bezier(0.65, 0, 0.35, 1);
}
.project-view__hscroll-card.is-parallaxing
  .project-view__hscroll-shot--mobile {
  transition: translate 150ms linear;
}

.project-view__hscroll-shot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Top-anchor the image so the screenshot's header / top
     content is always visible when the image is taller than
     the shot wrapper's aspect ratio (which is the case for
     every asset — desktop screenshots are 884×~1100, mobile
     screenshots are 228×~600+). Matches Figma 139:515 where
     each shot crops to its top edge. */
  object-position: top center;
}

.project-view__hscroll-label {
  position: absolute;
  /* Sit below the desktop shot at the bottom-left of the card. */
  left: 0;
  bottom: 0;
  margin: 0;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 18px;
  line-height: 1.444;
  color: var(--color-ink);
}

/* Skip-carousel button — Figma 136:387.
   ----------------------------------------
   Floats absolute-positioned inside the pin, locked 40px from
   the viewport bottom while the carousel is engaged. JS shows
   it after the user's first scroll into the carousel and hides
   it once the last card has been centered (or the user clicks).

   The button slides UP from below the viewport on entry and
   slides BACK DOWN out of view on exit, so the affordance
   matches the user's downward scroll — they scrolled in,
   the button rises in from the same direction. */

/* Mobile fallback — pinning + scroll-jacking is too disruptive on
   small touch screens. Revert to native horizontal scroll with
   snap, matching the previous behavior at < 768px. */
@media (max-width: 767px) {
  .project-view__hscroll {
    height: auto;
    /* No outer margin on mobile — the header's 60px top padding and the
       track's bottom padding (60px clear below the label) are the section's
       only vertical spacing, so it reads as a clean 60px top and bottom. */
    margin-block: 0;
  }
  /* Let the carousel's own 60px padding be the ONLY spacing around it: drop
     the feature montage's bottom margin (above) and the split section's top
     margin (below) so neighbors abut it instead of adding 96px gaps. */
  .project-view__feature:has(.project-view__feature-img) {
    margin-bottom: 0;
  }
  .project-view__hscroll + .project-view__split {
    margin-top: 0;
  }
  .project-view__hscroll-pin {
    position: static;
    height: auto;
  }
  /* 60px above the title, 24px below it. */
  .project-view__hscroll-header {
    padding-block: 60px 24px;
  }
  .project-view__hscroll-track {
    overflow-x: auto;
    overflow-y: visible;
    flex: 0 0 auto;
    /* No top padding (the header's 24px is the gap to the cards). Bottom
       is 76px: the label is absolutely positioned 6px below the shot and
       spills ~16px past the card box, so 76px leaves ~60px of clear space
       below the label. */
    padding-block: 0 76px;
    scroll-snap-type: x proximity;
    scroll-padding-inline: 50%;
    scrollbar-width: none;
    -ms-overflow-style: none;
    -webkit-overflow-scrolling: touch;
  }
  .project-view__hscroll-track::-webkit-scrollbar {
    display: none;
  }
  .project-view__hscroll-track-inner {
    /* Reset the JS-driven transform — native scroll handles travel
       on mobile. */
    transform: none !important;
    padding-inline: 50vw;
  }
  .project-view__hscroll-card {
    scroll-snap-align: center;
    /* No desktop lift on the native-scroll mobile layout. */
    transform: none;
  }
  /* The desktop shot's bottom sits at a fixed 93.69% of the card height
     (ratio of the shot's aspect to the card's), so anchor the label 6px
     below that — exact 6px gap at any card width, instead of running into
     the screenshot on the narrow mobile cards. */
  .project-view__hscroll-label {
    top: calc(93.69% + 6px);
    bottom: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Reduced-motion users get the same native-scroll fallback as
     mobile — no scroll-jacking, no JS-driven transforms. */
  .project-view__hscroll {
    height: auto;
  }
  .project-view__hscroll-pin {
    position: static;
    height: auto;
  }
  .project-view__hscroll-track {
    overflow-x: auto;
    overflow-y: visible;
    flex: 0 0 auto;
    padding-block: 60px;
    scroll-snap-type: x proximity;
  }
  .project-view__hscroll-track-inner {
    transform: none !important;
    padding-inline: 50vw;
  }
}

.project-view__hscroll-skip {
  position: absolute;
  bottom: 40px;
  /* Center horizontally inside the pin (= viewport while sticky-
     engaged). The X-axis translate stays at -50% so the button
     stays centered while the Y-axis translate handles the slide
     entrance / exit. */
  left: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 8px 16px;
  background-color: var(--color-ink);
  color: var(--color-cream);
  border: 0;
  border-radius: 1000px;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 16px;
  line-height: 24px;
  letter-spacing: 0;
  text-transform: none;
  cursor: inherit;
  /* Hidden state: parked below the viewport. translateY of
     `100% + 40px` pushes the button down by its own height
     plus the bottom-edge gap, so it sits fully out of frame
     before sliding in. */
  opacity: 0;
  transform: translate3d(-50%, calc(100% + 40px), 0);
  pointer-events: none;
  transition:
    opacity 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
    transform 480ms cubic-bezier(0.16, 1, 0.3, 1),
    background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1),
    color 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
  /* Float above the carousel cards so the button isn't obscured
     by a card scrolling past underneath. */
  z-index: 5;
}
.project-view__hscroll-skip.is-visible {
  opacity: 1;
  transform: translate3d(-50%, 0, 0);
  pointer-events: auto;
}
/* Hover / focus / active state — dark gold fill with dark-black
   text + icon (matches the password-gate submit's enabled hover
   so all interactive buttons share the same hover treatment).
   Keeps the X-axis -50% centering translate while adding a 2px
   lift on the Y-axis. */
.project-view__hscroll-skip.is-visible:hover,
.project-view__hscroll-skip.is-visible:focus-visible,
.project-view__hscroll-skip.is-visible:active {
  background-color: var(--color-gold-dark);
  color: var(--color-ink);
  outline: none;
  transform: translate3d(-50%, -2px, 0);
}

.project-view__hscroll-skip-icon {
  display: inline-flex;
  width: 12px;
  height: 12px;
  color: currentColor;
  transform-origin: 50% 50%;
  transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.project-view__hscroll-skip-icon svg {
  width: 100%;
  height: 100%;
  display: block;
}

/* When the user is scrolling UP, JS adds .is-up to the button —
   the icon rotates 180° to read as an up-arrow, and the click
   handler reverses direction (jumps ABOVE the carousel instead
   of below it). The same down-arrow SVG is used; CSS just flips
   it so we don't need a second asset. */
.project-view__hscroll-skip.is-up .project-view__hscroll-skip-icon {
  transform: rotate(180deg);
}

@media (prefers-reduced-motion: reduce) {
  .project-view__hscroll-skip-icon {
    transition: none;
  }
}

/* =============================================================
   .project-view__split — full-bleed scrolly crossfade template
   -------------------------------------------------------------
   Dark-text-on-ink column on the left, image on the right. The
   text column hosts ONE station (single static state) or
   multiple stations that crossfade as the user scrolls — image
   stays pinned the whole time. Reference: Figma 139:517 + 136:410
   (the "Redefining the System" → "Results" scrolly in the Yahoo
   case study).

   Architecture:
   - .project-view__split          — outer scroll runway. Height
                                     = N × 600px (set via
                                     `--split-station-count` by
                                     scripts/split-scrolly.js).
   - .project-view__split-pin      — sticky inner that locks to
                                     viewport top for the section's
                                     full height. Holds the two
                                     grid columns at exactly 600px
                                     tall — what the user actually
                                     sees at any moment.
   - .project-view__split-stack    — left grid item. Positioning
                                     context for the absolute-
                                     positioned stations stacked
                                     ON TOP of each other (not
                                     vertically). JS sets opacity
                                     on each station based on
                                     scroll progress so only one
                                     is visible at a time — clean
                                     crossfade, no overlap.
   - .project-view__split-text     — single station. Absolute
                                     within the stack; controlled
                                     opacity.
   - .project-view__split-image    — right grid item. The pin's
                                     sticky positioning keeps it
                                     visible the whole time.

   Markup — single station (no scrolly):
     <section class="project-view__split">
       <div class="project-view__split-pin">
         <div class="project-view__split-stack">
           <div class="project-view__split-text">…</div>
         </div>
         <div class="project-view__split-image"></div>
       </div>
     </section>

   Markup — multiple stations (crossfade as user scrolls):
     <section class="project-view__split">
       <div class="project-view__split-pin">
         <div class="project-view__split-stack">
           <div class="project-view__split-text">…station 1…</div>
           <div class="project-view__split-text">…station 2…</div>
         </div>
         <div class="project-view__split-image"></div>
       </div>
     </section>

   Responsive:
   - ≥ 768px: scrolly crossfade with pinned image.
   - < 768px: stacks to single column. Each station gets natural
     flow height, all visible normally — no crossfade.            */
.project-view__split {
  position: relative;
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  margin-block: 96px;
  /* Unfixed: the section flows at its natural height (no sticky runway).
     Both copy blocks stack and the image sits below them; scroll motion
     comes from parallax, not pinning. */
  height: auto;
  /* Clip overflow horizontally — defensive in case a parent's
     padding/grid causes a sub-pixel overshoot at the bleed edge. */
  overflow-x: clip;
  background-color: var(--color-ink);
}

.project-view__split-pin {
  /* No longer pinned — a plain stacking container. */
  position: static;
  height: auto;
  display: block;
}

.project-view__split-stack {
  position: relative;
  background-color: var(--color-ink);
  /* Both copy blocks stack vertically. */
  display: flex;
  flex-direction: column;
}

.project-view__split-text {
  position: static;
  inset: auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* 80px vertical, fluid horizontal (24 → 163px) so the text
     column matches the Figma's 163px inset at xl while still
     having reasonable padding at narrower breakpoints. */
  padding: 80px clamp(24px, 12vw, 163px);
  gap: 6px;
  /* Unfixed: every copy block is visible (no crossfade). */
  opacity: 1;
  pointer-events: auto;
}
.project-view__split-stack > .project-view__split-text:first-child {
  opacity: 1;
  pointer-events: auto;
  padding-bottom: 0;
}

.project-view__split-eyebrow {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 18px;
  line-height: 1;
  color: var(--color-gold);
}

.project-view__split-body {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 400;
  /* Body Large — 20/28. */
  font-size: 20px;
  line-height: 1.4;
  color: var(--color-white);
  /* Cap measure to ~488px to match the Figma's 489px text column
     width — long lines on ultra-wide displays would otherwise
     stretch the paragraph past readability. */
  max-width: 488px;
}

.project-view__split-image {
  position: relative;
  /* Yahoo purple backdrop behind the artwork. Unfixed: a contained,
     centered block below the copy (sized to the artwork's own aspect so
     nothing crops), rather than a full-bleed pinned column. */
  background-color: #7d2eff;
  overflow: hidden;
  aspect-ratio: 1555 / 1600;
  width: min(720px, 88%);
  margin: 32px auto 96px;
  border-radius: 20px;
}

/* =============================================================
   .project-view__split--full — full-width (single column) variant
   -------------------------------------------------------------
   Same scrolly crossfade engine, but instead of a text column + image,
   both stations are full-bleed centered content on an ink panel. Used
   to crossfade the User Journeys headline into the Strategy content.
   ============================================================= */
.project-view__split--full {
  background-color: var(--color-ink);
  margin-block: 0;
  /* Inherits the base split's 90vh-per-station runway, which holds each
     station long enough that it isn't accidentally scrolled past and
     gives the dissolve room to read as a gentle transition. The wider
     fade window for this variant lives in scripts/split-scrolly.js
     (which keys off the `--full` class). */
}
.project-view__split--full .project-view__split-pin {
  /* One column — the stack fills the whole pin. */
  grid-template-columns: 1fr;
  background-color: var(--color-ink);
}
.project-view__split--full .project-view__split-stack {
  background-color: transparent;
}
.project-view__split--full .project-view__split-text {
  /* Drop the text-column padding; the inner .grid provides the column
     rails and the station just vertically centers its content. */
  padding: 100px 0;
  gap: 0;
}
/* The inner grid fills the station so the body lands on the same
   column rails as every other section. */
.project-view__split--full .project-view__split-text > .grid {
  width: 100%;
}

.project-view__split-image-asset {
  position: absolute;
  /* The cluster sits inside the full-bleed purple container: dropped 80px
     from the top, its right edge pulled in to the grid's last-column rail
     (grid-margin at ≤1920px, plus the centered gutter past that), flush to
     the bottom, starting at the column boundary on the left. <img> is a
     replaced element, so its box must be sized with explicit width/height —
     left+right insets don't size it. The purple container behind covers any
     parallax-exposed edge seamlessly, so no overscan is needed. */
  /* Fill the entire right column edge-to-edge, top-to-bottom. */
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
}

/* =============================================================
   .project-view__pinned — pinned scroll-crossfade stations
   -------------------------------------------------------------
   Full-screen sticky ink panel: a header pinned 100px from the
   section top, plus N stations (gold eyebrow + body copy on the
   left, a two-phone image cluster on the right) that crossfade as
   the user scrolls the section's runway. Driven by split-scrolly.js
   (writes per-station opacity from scroll progress).
   Reference: Figma 184:10253.
   ============================================================= */
.project-view__pinned {
  position: relative;
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  background-color: var(--color-ink);
  /* Unfixed: the section flows at its natural height — all stations stack
     and scroll past normally (with parallax), no sticky crossfade. */
  height: auto;
  padding-bottom: 60px;
}

.project-view__pinned-pin {
  position: static;
  height: auto;
  display: block;
  overflow: visible;
}

.project-view__pinned-header {
  flex: 0 0 auto;
  /* Header sits 100px below the section's top border. */
  padding-top: 100px;
}
.project-view__pinned-title {
  grid-column: 3 / span 20;
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-white);
}

.project-view__pinned-stack {
  position: relative;
  /* Stations stack vertically, each its own scroll row. */
  display: flex;
  flex-direction: column;
  gap: 120px;
  padding-top: 80px;
}

.project-view__pinned-station {
  /* Unfixed: each station flows in place, all visible. Keeps its 24-col
     grid (text left, cluster right) — just stacked down the page. */
  position: static;
  inset: auto;
  align-content: center;
  opacity: 1;
  pointer-events: auto;
}
.project-view__pinned-stack > .project-view__pinned-station:first-child {
  opacity: 1;
  pointer-events: auto;
}

.project-view__pinned-text {
  grid-column: 3 / span 7;
  align-self: center;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.project-view__pinned-eyebrow {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 18px;
  line-height: 1;
  color: var(--color-gold);
}
.project-view__pinned-body {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 20px;
  line-height: 1.4;
  color: var(--color-white);
  max-width: 431px;
}

.project-view__pinned-cluster {
  grid-column: 12 / span 12;
  align-self: center;
  display: flex;
  align-items: center;
  justify-content: center;
  height: min(70vh, 620px);
}
.project-view__pinned-phone {
  display: block;
  width: auto;
  /* Match the horizontal carousel shadow (8px 8px 48px / 0.1). The phones
     are transparent device mockups, so drop-shadow traces each phone's
     rounded silhouette instead of a rectangular box-shadow. */
  filter: drop-shadow(8px 8px 48px rgba(0, 0, 0, 0.1));
  /* Cursor-parallax (card-parallax.js binds the cluster and writes
     --parallax-x/y); ease back to rest on mouseleave. */
  transition: translate 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Larger phone in front — front layer drifts the most for depth. */
.project-view__pinned-phone--a {
  height: 100%;
  position: relative;
  z-index: 2;
  translate: calc(var(--parallax-x, 0) * 14px)
    calc(var(--parallax-y, 0) * 14px);
}
/* Smaller phone tucked 20px behind the larger one — shallower drift. */
.project-view__pinned-phone--b {
  height: 90%;
  position: relative;
  z-index: 1;
  margin-left: -20px;
  translate: calc(var(--parallax-x, 0) * 7px)
    calc(var(--parallax-y, 0) * 7px);
}
/* While hovered, track the cursor snappily; the base transition above
   handles the slower ease-back once the cursor leaves. */
.project-view__pinned-cluster.is-parallaxing .project-view__pinned-phone {
  transition: translate 120ms ease-out;
}

/* Results station: a 2×2 metrics grid stands in for the phone cluster —
   a big gold Obviously number over a cream label, on the same column rails
   as the image clusters. */
.project-view__pinned-stats {
  /* Shifted 2 outer-grid columns to the right (was 12 / span 12). */
  grid-column: 14 / span 11;
  align-self: center;
  display: grid;
  /* Content-sized columns so the left stack hugs the right stack with a
     fixed gap, rather than each filling half the row and leaving a wide
     empty band between them. */
  grid-template-columns: auto auto;
  justify-content: start;
  column-gap: clamp(80px, 12vw, 200px);
  row-gap: 56px;
}
/* Left stack: nudged one extra outer-grid column to the right (so its
   contents start 1 column further in than the right stack), derived from
   the outer grid's column-width formula so it tracks the breakpoints. */
.project-view__pinned-stats > .project-view__pinned-stat:nth-child(odd) {
  padding-left: calc(
    (100vw - 2 * var(--grid-margin) - (var(--grid-cols) - 1) * var(--grid-gutter))
      / var(--grid-cols)
      + var(--grid-gutter)
  );
}
.project-view__pinned-stat-num {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(40px, 5vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-gold);
}
.project-view__pinned-stat-label {
  margin: 8px 0 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 18px;
  line-height: 1;
  color: var(--color-white);
}

/* === Mobile fallback (≤1180px): unpin, stack each station's text above
   its cluster, show all stations in normal flow. === */
@media (max-width: 1180px) {
  .project-view__pinned {
    height: auto;
  }
  .project-view__pinned-pin {
    position: static;
    height: auto;
    display: block;
  }
  .project-view__pinned-header {
    padding-top: 60px;
  }
  .project-view__pinned-title {
    grid-column: 2 / span 10;
  }
  .project-view__pinned-stack {
    display: flex;
    flex-direction: column;
    gap: 80px;
    padding-block: 40px 60px;
  }
  .project-view__pinned-station {
    position: static;
    inset: auto;
    opacity: 1 !important;
    pointer-events: auto;
    transition: none;
    row-gap: 24px;
  }
  .project-view__pinned-text {
    grid-column: 2 / span 10;
    grid-row: 1;
  }
  .project-view__pinned-cluster,
  .project-view__pinned-stats {
    grid-column: 2 / span 10;
    grid-row: 2;
    height: auto;
  }
  /* Lower each phone image cluster 40px on mobile. */
  .project-view__pinned-cluster {
    transform: translateY(40px);
  }
  /* On mobile the column is narrow, so size the phones by width (not
     height) to keep both in frame; smaller still tucked 20px behind. */
  .project-view__pinned-phone--a {
    height: auto;
    width: 52%;
  }
  .project-view__pinned-phone--b {
    height: auto;
    width: 47%;
  }
}
@media (max-width: 767px) {
  .project-view__pinned-title,
  .project-view__pinned-text,
  .project-view__pinned-cluster,
  .project-view__pinned-stats {
    grid-column: 1 / -1;
  }
}

/* =============================================================
   .project-view__integrations — intro-style block + centered GIF
   -------------------------------------------------------------
   Reuses the .project-view__body-grid / __body / __info / __copy intro
   formatting, then drops a full-content-width animated GIF below it:
   rounded 20px, soft carousel-style shadow, centered on the content rails.
   (The source video's baked-in black border + rounded corners were cropped
   off the asset so the CSS radius rounds clean edges.) Reference: Figma 185:409.
   ============================================================= */
.project-view__integrations-media {
  grid-column: 3 / span 20;
  margin-top: 56px;
  aspect-ratio: 1115 / 622;
  border-radius: 20px;
  overflow: hidden;
  /* Match the horizontal carousel shadow (8px 8px 48px / 0.1). */
  box-shadow: 8px 8px 48px 0 rgba(0, 0, 0, 0.1);
}
.project-view__integrations-gif {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
@media (max-width: 1279px) {
  .project-view__integrations-media {
    grid-column: 2 / span 10;
  }
}
@media (max-width: 767px) {
  .project-view__integrations-media {
    grid-column: 1 / -1;
  }
}

/* =============================================================
   .project-view__granular — Granular Scopes
   -------------------------------------------------------------
   Reuses the intro-block formatting (headline + Users/Goals + Overview),
   then a wide first image cluster (big screenshot left, smaller card
   overlapping on the right), then a two-note column (Design Details /
   Designing for Scale, 48px apart) beside a second image cluster.
   Reference: Figma 185:394 + loose nodes 185:414/411 and 185:727/728.
   ============================================================= */
.project-view__granular {
  background-color: var(--color-bg);
}
/* Intro overview sits ~64px above the first cluster. */
.project-view__granular .project-view__body-grid {
  padding-bottom: 64px;
}

.project-view__granular-cluster {
  position: relative;
}
.project-view__granular-img {
  display: block;
  border-radius: 16px;
  box-shadow: 8px 8px 48px 0 rgba(0, 0, 0, 0.1);
  /* Cursor-parallax ease-back (see scripts/card-parallax.js). */
  transition: translate 450ms cubic-bezier(0.65, 0, 0.35, 1);
}
/* The big screenshot is the BACK layer — small 5px depth. */
.project-view__granular-img--lg {
  translate: calc(var(--parallax-x, 0) * 5px)
    calc(var(--parallax-y, 0) * 5px);
}
/* The smaller card overlaps the big screenshot's right edge, dropped down.
   It's the FRONT layer — stronger 9px depth so it leads on cursor move. */
.project-view__granular-img--sm {
  position: absolute;
  right: 0;
  translate: calc(var(--parallax-x, 0) * 9px)
    calc(var(--parallax-y, 0) * 9px);
}
/* While hovered, track the cursor snappily; base transition eases back. */
.project-view__granular-cluster.is-parallaxing .project-view__granular-img {
  transition: translate 150ms linear;
}

/* First cluster spans the full content width (wider than the intro text). */
.project-view__granular-cluster--1 {
  grid-column: 1 / -1;
}
.project-view__granular-cluster--1 .project-view__granular-img--lg {
  width: 74.6%;
}
.project-view__granular-cluster--1 .project-view__granular-img--sm {
  width: 36.5%;
  top: 12%;
}

/* Second row: notes left, cluster right, vertically centered to each other. */
.project-view__granular-row {
  /* 120px base + 100px extra breathing room below the first image cluster. */
  margin-top: 220px;
  padding-bottom: 100px;
  align-items: center;
}
.project-view__granular-notes {
  grid-column: 1 / span 6;
  align-self: center;
  display: flex;
  flex-direction: column;
  gap: 48px;
  /* Match the "Making action effortless" aside: col 1 / span 6 with a
     half-grid-column right trim, derived from the grid vars so it tracks
     the breakpoint. */
  padding-right: calc(
    (100vw - 2 * var(--grid-margin) - (var(--grid-cols) - 1) * var(--grid-gutter)) /
      var(--grid-cols) / 2
  );
}
.project-view__granular-note .project-view__subhead {
  margin-bottom: 8px;
}
.project-view__granular-cluster--2 {
  grid-column: 9 / -1;
  align-self: center;
}
.project-view__granular-cluster--2 .project-view__granular-img--lg {
  width: 53.5%;
  /* Cluster-2: the big "Slack" card is the FRONT layer; bump above the
     small "Zapier" card so they overlap with Slack on top. */
  position: relative;
  z-index: 2;
}
.project-view__granular-cluster--2 .project-view__granular-img--sm {
  /* Pull the smaller "Zapier" card under the right edge of "Slack" so it
     reads as sitting BEHIND it (z-index:1 vs Slack's z-index:2). */
  width: 53%;
  right: 0;
  top: 11.7%;
  z-index: 1;
}

@media (max-width: 1180px) {
  .project-view__granular-row {
    margin-top: 60px;
    /* 60px visual gap + 40px to compensate for the cluster's translateY(40px). */
    padding-bottom: 100px;
    row-gap: 60px;
  }
  .project-view__granular-notes {
    grid-column: 2 / span 10;
    grid-row: 1;
    /* Full content width when stacked — drop the half-column trim. */
    padding-right: 0;
  }
  .project-view__granular-cluster--1,
  .project-view__granular-cluster--2 {
    grid-column: 2 / span 10;
  }
  .project-view__granular-cluster--2 {
    grid-row: 2;
    /* Lower the cluster 40px below the "Designing for Scale" copy on mobile. */
    transform: translateY(40px);
  }
}
@media (max-width: 767px) {
  .project-view__granular-notes,
  .project-view__granular-cluster--1,
  .project-view__granular-cluster--2 {
    grid-column: 1 / -1;
  }
}

/* =============================================================
   Full-screen views (desktop ≥1181px)
   -------------------------------------------------------------
   Each content section is at least a full viewport tall with its content
   vertically centered, so the interior pages read as a sequence of
   full-screen panels. Sections naturally taller than the viewport
   (Granular Scopes' multi-row block, the showcase stack, etc.) keep their
   height and flow from the top — centering only adds space when there's
   room. Left as-is: the masthead (hero + morph target), the horizontal
   carousel (already a pinned full-screen scroller), and the full-bleed
   feature image (already height:100vh).
   ============================================================= */
@media (min-width: 1181px) {
  /* Full-screen content sections vertically center their content. The
     Next Project section is intentionally NOT in this list — it should
     end the page at its natural height (label + card + padding), without
     an extra 100vh stretch leaving dead space below the card. */
  .project-view__journeys,
  .project-view__strategy,
  .project-view__integrations {
    min-height: 100vh;
  }
  .project-view__journeys,
  .project-view__strategy,
  .project-view__integrations {
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  /* Mobile Tasks: each station is its own full-screen, centered view
     (the station grid already centers its row via align-content:center). */
  .project-view__pinned-station {
    min-height: 100vh;
  }
  .project-view__pinned-stack {
    gap: 0;
    padding-top: 0;
  }

  /* Redefining the System / Results — the image pins to the right
     (column 12 → viewport edge), vertically centered in the viewport,
     while the two copy blocks scroll past it on the left (each block
     occupies a viewport-tall band). */
  .project-view__split-pin {
    position: relative;
    display: grid;
    grid-template-columns: repeat(var(--grid-cols), 1fr);
    column-gap: var(--grid-gutter);
    padding-inline: var(--grid-margin);
    max-width: var(--grid-max-width);
    margin-inline: auto;
    width: 100%;
  }
  .project-view__split-stack {
    grid-column: 3 / 12;
    display: flex;
    flex-direction: column;
    /* Release the sticky image right as the Results paragraph approaches
       ~40px from the top of the viewport. Results sits centered in its
       100vh band, so its visible top is roughly (band-top + 50vh −
       half its rendered height). For pin-release at scrollY where that
       top is 40px below viewport-top, the runway past the text needs to
       be (50vh − paragraph-half-height − 40px). Using ~140px as the
       half-height + 40px target gives a clean release moment. */
    padding-bottom: calc(50vh - 150px);
  }
  .project-view__split-text,
  .project-view__split-stack > .project-view__split-text {
    min-height: 100vh;
    justify-content: center;
    /* The grid column owns the left rail now — drop the inset padding. */
    padding: 0;
  }
  .project-view__split-image {
    grid-column: 12 / -1;
    align-self: start;
    position: sticky;
    top: 0;
    height: 100vh;
    /* Bleed the right edge past the grid margin to the viewport edge. */
    margin: 0 calc(-1 * var(--grid-margin)) 0 0;
    width: auto;
    aspect-ratio: auto;
    border-radius: 0;
    background-color: transparent;
    overflow: hidden;
    display: block;
  }
  /* Fill the full sticky box (column 12 → viewport edge, top → bottom);
     cover crops the square artwork rather than leaving ink gaps. */
  .project-view__split-image-asset {
    position: static;
    inset: auto;
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }
}

/* =============================================================
   .project-view__phones — 4×4 grid of Google Primer app screens
   -------------------------------------------------------------
   Sits between the "PRIMER 5.0" chapter heading and the reviews-intro
   sticky headline. 16 phone PNGs (744×1340 each, sliced from Figma
   213:237) arranged in a 4-column grid on desktop, scaling down to 2
   columns on tablet and narrow screens. Each phone has a staggered
   data-parallax-y so the grid drifts gently as the user scrolls.
   ============================================================= */
.project-view__phones {
  background-color: var(--color-bg);
  padding-block: 100px;
}
/* 4 row strips stacked vertically — each strip is a 4-phone Figma export
   that preserves the natural in-row drop shadows (sliced into 16
   individual phones previously, the shadows got clipped at cell edges). */
.project-view__phones-rows {
  /* 24-col grid: cols 3 → 22 (start at col 3, leave 2 cols empty on the right). */
  grid-column: 3 / span 20;
  display: flex;
  flex-direction: column;
  gap: 32px;
}
.project-view__phones-row {
  display: block;
  /* Bleed the row PNG outward so the VISIBLE phone edges land on the
     column rails the parent claims (cols 3→22). The exported PNG has
     ~5.54% cream margin on the left and ~4.70% on the right before the
     phone silhouettes begin. Scaling the PNG to 111.4% width and
     pulling 6.17% to the left snaps the leftmost phone edge to the
     start of column 3 and the rightmost phone edge to the end of
     column 22, with the cream "padding" baked into the export landing
     in cols 1-2 and 23-24 (the same cream as the page bg, so the bleed
     is invisible). */
  width: 111.4%;
  max-width: none;
  margin-left: -6.17%;
  flex-shrink: 0;
  height: auto;
}
@media (max-width: 1279px) {
  .project-view__phones-rows {
    /* 12-col grid: cols 3 → 10 (start at col 3, leave 2 cols empty on the right). */
    grid-column: 3 / span 8;
    gap: 24px;
  }
}
@media (max-width: 1180px) {
  .project-view__phones {
    padding-block: 60px;
    /* Pull the phone grid up so the gap between the "PRIMER 5.0" headline
       above and the first row of phones is 40px on mobile (was the sum of
       primer-50 padding-bottom + phones padding-top = 120px). */
    padding-top: 20px;
  }
}
@media (max-width: 767px) {
  .project-view__phones-rows {
    grid-column: 1 / -1;
    gap: 16px;
  }
}

/* =============================================================
   .project-view__primer-stats — Google Primer headline metrics
   -------------------------------------------------------------
   A cream band beneath the Primer feature image. Four stat columns in a
   row: gold Obviously number over an ink label (the labels override the
   .project-view__pinned-stat-label cream color so they read on the cream
   page background). Reference: Figma 185:980.
   ============================================================= */
.project-view__primer-stats {
  /* Full-bleed black band beneath the feature image. */
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  background-color: var(--color-ink);
  padding-block: 100px;
}
.project-view__primer-stats-row {
  grid-column: 3 / span 20;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  column-gap: 24px;
  text-align: center;
}
/* Center each stat's number + label and keep the labels at their default
   white so they read against the black band. */
.project-view__primer-stats .project-view__pinned-stat {
  text-align: center;
}
.project-view__primer-stats .project-view__pinned-stat-num {
  text-align: center;
}
.project-view__primer-stats .project-view__pinned-stat-label {
  color: var(--color-white);
  text-align: center;
}

@media (max-width: 1180px) {
  .project-view__primer-stats {
    padding-block: 60px;
  }
  .project-view__primer-stats-row {
    grid-column: 2 / span 10;
    /* 2×2 grid on tablet/mobile so the labels stay readable. */
    grid-template-columns: 1fr 1fr;
    row-gap: 48px;
  }
}
@media (max-width: 767px) {
  .project-view__primer-stats-row {
    grid-column: 1 / -1;
  }
}

/* =============================================================
   .project-view__primer-50 — "Primer 5.0" chapter heading
   -------------------------------------------------------------
   Plain cream band on the Google Primer page that opens the reviews
   section. Reuses the case-study headline treatment. Figma 185:981.
   ============================================================= */
.project-view__primer-50 {
  background-color: var(--color-bg);
  padding-block: 100px;
}
.project-view__primer-50-title {
  grid-column: 3 / span 20;
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-ink);
}
@media (max-width: 1279px) {
  .project-view__primer-50-title {
    grid-column: 2 / span 10;
  }
}
@media (max-width: 1180px) {
  .project-view__primer-50 {
    padding-block: 60px;
    /* Tighten the bottom so the headline sits 40px above the phone grid. */
    padding-bottom: 20px;
  }
}
@media (max-width: 767px) {
  .project-view__primer-50-title {
    grid-column: 1 / -1;
  }
}

/* =============================================================
   .project-view__reviews-intro — full-screen sticky reviews headline
   -------------------------------------------------------------
   Mirrors the homepage outro: a tall section whose headline sticks to
   the vertical center of the viewport while the band is in view, then
   releases as the page scrolls past it. Headline uses the same Headline 1
   treatment as the homepage outro. Figma 185:994 / 185:995.
   ============================================================= */
.project-view__reviews-intro {
  background-color: var(--color-bg);
  /* The cards' own height defines the section's scroll runway. The
     headline pins to viewport center while the cards scroll over it. */
  position: relative;
  padding-block: 50vh;
}

/* Sticky headline — pinned to the vertical center of the viewport while
   the section is in view. Sits BEHIND the cards (z-index: 0) so the cards
   scroll over it. The grid-row spans both items so cards + pin share the
   same row and overlay on the z-axis. */
.project-view__reviews-intro-pin {
  grid-column: 3 / span 20;
  grid-row: 1;
  /* `align-self: start` collapses this grid item to the headline's own
     height (instead of stretching to the cards-column row height). That
     way the sticky box is the size of the headline, and the centering
     translate3d(-50%) actually centers it in the viewport. */
  align-self: start;
  position: sticky;
  top: 50vh;
  transform: translate3d(0, -50%, 0);
  will-change: transform;
  z-index: 0;
  pointer-events: none;
}
.project-view__reviews-intro-headline {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--color-ink);
  pointer-events: auto;
}

/* Cards column — overlays the headline (z-index: 1). Each card lands on a
   slightly different rail (zig-zag across the grid) so they don't feel
   like a single stacked column flying past. The parallax-y data attribute
   on each card drives a per-card vertical drift (see scripts/scroll.js) so
   they pass the centered headline at different speeds. */
.project-view__reviews-cards {
  /* 10-col span centered on the 24-col outer grid (col 8 → col 17). */
  grid-column: 8 / span 10;
  grid-row: 1;
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 40px;
  pointer-events: none;
  /* Push the first card a full viewport below the section's top so the
     headline lands sticky-centered first; the user then scrolls and the
     cards rise from below the fold to pass over it. Without this, the
     first card sits next to the headline at section-enter time. */
  padding-top: 100vh;
}

.project-view__reviews-card {
  display: block;
  width: 70%;
  height: auto;
  border-radius: 16px;
  background-color: var(--color-white);
  box-shadow: 8px 8px 48px 0 rgba(0, 0, 0, 0.1);
  pointer-events: auto;
  /* Cursor parallax via `transform` so it composes with the scroll
     parallax that the global [data-parallax-y] rule writes onto the
     independent `translate` property. The `transform` is transitioned
     for a smooth ease-back; `translate` stays untouched by transitions
     so scroll-parallax updates feel locked to the scroll signal. */
  transform: translate3d(
    calc(var(--parallax-x, 0) * 10px),
    calc(var(--parallax-y, 0) * 10px),
    0
  );
  transition: transform 450ms cubic-bezier(0.65, 0, 0.35, 1);
}
/* Snappy follow while hovered; the base eases back when the cursor leaves. */
.project-view__reviews-card.is-parallaxing {
  transition: transform 150ms linear;
}
/* Zig-zag the cards across the column so they don't all sit on the same
   left rail — adds visual energy as they pass the centered headline. */
/* Centered horizontally within the now-narrower 10-col cards column. */
.project-view__reviews-card--1,
.project-view__reviews-card--2,
.project-view__reviews-card--3,
.project-view__reviews-card--4,
.project-view__reviews-card--5 {
  align-self: center;
  margin-inline: 0;
}

@media (max-width: 1279px) {
  .project-view__reviews-intro-pin,
  .project-view__reviews-cards {
    grid-column: 2 / span 10;
  }
}
@media (max-width: 1180px) {
  /* On mobile, drop the sticky-over pattern entirely — the long headline
     doesn't read well as a viewport-centered pin on narrow screens, and the
     cards stack naturally below it. */
  .project-view__reviews-intro {
    padding-top: 60px;
    padding-bottom: 60px;
  }
  .project-view__reviews-intro-pin {
    position: static;
    top: auto;
    transform: none;
    will-change: auto;
    grid-row: auto;
    margin-bottom: 60px;
  }
  .project-view__reviews-cards {
    grid-row: auto;
    gap: 40px;
    padding-block: 0;
  }
  .project-view__reviews-card,
  .project-view__reviews-card--1,
  .project-view__reviews-card--2,
  .project-view__reviews-card--3,
  .project-view__reviews-card--4,
  .project-view__reviews-card--5 {
    width: 100%;
    align-self: stretch;
    margin-inline: 0;
  }
}
@media (max-width: 767px) {
  .project-view__reviews-intro-pin,
  .project-view__reviews-cards {
    grid-column: 1 / -1;
  }
}

/* =============================================================
   .project-view__next — "Next project" footer
   -------------------------------------------------------------
   Dark full-bleed band closing each interior page. Holds a "Next
   Project" headline and the NEXT project's home thumbnail (the reused
   .project component, so it inherits the home card's hover expansion
   and click-to-open behavior). Reference: Figma 161:1032.
   ============================================================= */
.project-view__next {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  background-color: var(--color-ink);
  padding-block: 100px;
  /* The reused next-project card uses overflow:visible + clip-path so the
     home grid's hover-spill works there. clip-path only clips PAINT, not
     LAYOUT, so the spilled shot was extending the document height past
     the visible card and letting the page scroll into empty space below
     it (most noticeable for the Google Primer card whose rotated montage
     extends ~270px past the card). overflow:clip on this section hard-
     caps the layout so the page can never scroll past the card. */
  overflow: clip;
}

.project-view__next-label {
  grid-column: 3 / span 20;
  margin: 0 0 40px;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-cream);
}

/* The reused home card carries the home stack's large block margins
   (and first/last-of-type overrides) — neutralize them here; the band's
   padding owns the spacing. */
.project-view__next .project,
.project-view__next .project:first-of-type,
.project-view__next .project:last-of-type {
  margin: 0;
}

/* Desktop renders the live layered card (logo + screenshots + parallax);
   the baked static PNG is mobile-only. */
.project-view__next-static {
  display: none;
}

@media (max-width: 1180px) {
  /* Tablet / mobile: drop the scrolly crossfade. Section height becomes
     natural, pin is no longer sticky, stations flow normally
     with image first. */
  .project-view__split {
    height: auto;
  }
  .project-view__split-pin {
    position: static;
    height: auto;
    grid-template-columns: 1fr;
  }
  .project-view__split-image {
    grid-row: 1;
    aspect-ratio: 4 / 3;
  }
  .project-view__split-stack {
    grid-row: 2;
    background-color: var(--color-ink);
    /* Stations flow vertically again on mobile. */
    display: flex;
    flex-direction: column;
  }
  .project-view__split-text,
  .project-view__split-stack > .project-view__split-text {
    position: static;
    inset: auto;
    opacity: 1 !important;
    pointer-events: auto;
    padding: 56px 24px;
    background-color: transparent;
  }
  /* Full-width variant: no image row, and the inner .grid (not station
     padding) owns the horizontal rails when stacked. */
  .project-view__split--full .project-view__split-stack {
    grid-row: auto;
  }
  .project-view__split--full .project-view__split-text {
    padding-inline: 0;
  }
  /* Mobile only: drop the image cluster and the first station
     ("Redefining the System") for the standard split, leaving just the
     "Results" paragraph on the ink panel. */
  .project-view__split:not(.project-view__split--full) .project-view__split-image,
  .project-view__split:not(.project-view__split--full)
    .project-view__split-stack
    > .project-view__split-text:first-child {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Reduced-motion: drop the crossfade and pin too — same fallback
     as mobile so the user just sees stations stack naturally. */
  .project-view__split {
    height: auto;
  }
  .project-view__split-pin {
    position: static;
    height: auto;
  }
  .project-view__split-stack {
    display: flex;
    flex-direction: column;
  }
  .project-view__split-text,
  .project-view__split-stack > .project-view__split-text {
    position: static;
    opacity: 1 !important;
    pointer-events: auto;
    transition: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .project-view__hscroll-skip {
    transform: none !important;
    transition: opacity 200ms linear, background-color 180ms linear,
      color 180ms linear;
  }
}

/* =============================================================
   .project-view__section-intro — full-bleed chapter heading
   -------------------------------------------------------------
   A simple full-width section used as a section/chapter break
   within a project view. Matches Figma node 139:430 — 80px
   vertical padding, white background, single uppercase display
   headline using the same typography as .project-view__hscroll-
   title so chapter headers in the case study read consistently.

   Markup:
     <section class="project-view__section-intro" data-nav-theme="light"
              aria-label="…">
       <div class="project-view__section-intro-inner grid">
         <h2 class="project-view__section-intro-title">…</h2>
       </div>
     </section>
   ============================================================= */
.project-view__section-intro {
  position: relative;
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  background-color: var(--color-white);
  padding-block: 80px;
}

.project-view__section-intro-title {
  margin: 0;
  /* Mirrors .project-view__hscroll-title's grid placement so
     section headings line up with the carousel title above. */
  grid-column: 3 / span 20;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(36px, 5.625vw, 72px);
  line-height: 1.0278;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-ink);
}

@media (max-width: 1279px) {
  .project-view__section-intro-title {
    grid-column: 2 / span 10;
  }
}
@media (max-width: 767px) {
  .project-view__section-intro {
    padding-block: 56px;
  }
  .project-view__section-intro-title {
    grid-column: 1 / -1;
  }
}

/* When project view is open, swap the brand wordmark from black to
   off-white so it reads against the purple masthead. The brand is the
   "back to home" target, kept clickable throughout.

   Color + filter transitions are added so the dynamic nav theme
   (see body[data-nav-theme="dark"] block below) crossfades smoothly
   as the user scrolls past the boundary between a colored section
   and a light content section. */
body.project-view-open .site-header__brand {
  color: var(--color-bg);
  transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
body.project-view-open .site-header__icon img,
body.project-view-open .interior-nav__icon-mono {
  transition: filter 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
body.project-view-open .interior-nav__project {
  transition:
    color 220ms cubic-bezier(0.2, 0.8, 0.2, 1),
    transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
body.project-view-open .site-header__icon img {
  filter: invert(1);
}

/* Dynamic nav theme — set by scripts/nav-theme.js on every scroll
   based on which [data-nav-theme] section is currently behind the
   sticky header.

   - `data-nav-theme="light"` (set on the masthead and any other
     dark/colored section) → nav reads LIGHT (cream wordmark + nav
     links, icons inverted to white). This is also what the default
     state below covers (no body attribute).

   - `data-nav-theme="dark"` (set on cream/light content sections
     like .project-view__body-grid) → nav reads DARK (ink wordmark
     + nav links, icons un-inverted to their native dark stroke).

   Explicit `[data-nav-theme="light"]` rules are listed for clarity
   and as a safety net: if the JS sets the attribute to "light"
   when the masthead is in the nav band, these rules guarantee the
   cream state wins over any other state that may have been left
   on body. */
body.project-view-open[data-nav-theme="light"] .site-header__brand,
body.project-view-open[data-nav-theme="light"] .interior-nav__project {
  color: var(--color-bg);
}
body.project-view-open[data-nav-theme="light"] .site-header__icon img,
body.project-view-open[data-nav-theme="light"] .interior-nav__icon-mono {
  filter: invert(1);
}

body.project-view-open[data-nav-theme="dark"] .site-header__brand,
body.project-view-open[data-nav-theme="dark"] .interior-nav__project {
  color: var(--color-ink);
}
body.project-view-open[data-nav-theme="dark"] .site-header__icon img,
body.project-view-open[data-nav-theme="dark"] .interior-nav__icon-mono {
  filter: none;
}

@media (prefers-reduced-motion: reduce) {
  body.project-view-open .site-header__brand,
  body.project-view-open .interior-nav__project,
  body.project-view-open .site-header__icon img,
  body.project-view-open .interior-nav__icon-mono {
    transition: none;
  }
}

/* Interior project pages: crossfade the wordmark between "ALEX MILLER"
   and "BACK HOME" on brand hover/focus. The default span stays in flow
   on both home and interior so the link's content box is always sized
   to "Alex Miller" — that pins the wordmark to the exact same pixel
   position regardless of which view is active. The (wider) hover span
   is absolutely positioned at the link's left edge, so on hover it
   expands rightward past the link's content box from the same left
   anchor, with no layout shift. The link has overflow:visible by
   default and the home-nav (.site-header__nav) is display:none on
   project-view, leaving clear space to the right. */
body.project-view-open .site-header__brand:hover .site-header__brand-text--default,
body.project-view-open .site-header__brand:focus-visible .site-header__brand-text--default {
  opacity: 0;
  pointer-events: none;
  transform: translateY(-100%);
}
body.project-view-open .site-header__brand:hover .site-header__brand-text--hover,
body.project-view-open .site-header__brand:focus-visible .site-header__brand-text--hover {
  opacity: 1;
  pointer-events: auto;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .site-header__brand-text {
    transition: opacity 200ms linear;
  }
  .site-header__brand-text--default,
  body.project-view-open .site-header__brand:hover .site-header__brand-text--default,
  body.project-view-open .site-header__brand:focus-visible .site-header__brand-text--default,
  .site-header__brand-text--hover,
  body.project-view-open .site-header__brand:hover .site-header__brand-text--hover,
  body.project-view-open .site-header__brand:focus-visible .site-header__brand-text--hover {
    transform: none;
  }
}

/* =============================================================
   Letter bounce (used on hover, see scripts/letter-bounce.js)
   ============================================================= */
.letter-bounce__word {
  display: inline-block;
  white-space: nowrap;
}

.letter-bounce__char {
  display: inline-block;
  will-change: transform;
  transform-origin: 50% 100%;
}

@keyframes letter-bounce {
  0%   { transform: translateY(0)    rotate(0deg); }
  50%  { transform: translateY(-8px) rotate(-2deg); }
  100% { transform: translateY(0)    rotate(0deg); }
}

/* Per-letter trigger: JS adds `.is-bouncing` on mouseover of each char,
   removes it on animationend. That way the bounce plays through cleanly
   even if the cursor moves on, and each letter only fires when its own
   hitbox is entered. */
.letter-bounce__char.is-bouncing {
  animation: letter-bounce 420ms cubic-bezier(0.33, 0, 0.2, 1) both;
}

/* Keyboard fallback: tabbing to the link plays a staggered wave across
   all letters, since keyboard users can't hover individual chars. */
.letter-bounce:focus-visible .letter-bounce__char {
  animation: letter-bounce 420ms cubic-bezier(0.33, 0, 0.2, 1) both;
  animation-delay: calc(var(--i, 0) * 35ms);
}

@media (prefers-reduced-motion: reduce) {
  .letter-bounce__char.is-bouncing,
  .letter-bounce:focus-visible .letter-bounce__char {
    animation: none;
  }
}

/* =============================================================
   Interior nav clearance — masthead-inner offset
   -------------------------------------------------------------
   On project view pages, the .site-header is absolutely positioned
   over the masthead at z:11 and contains the BACK HOME wordmark +
   the .interior-nav row (Yahoo / Asana / Google + social icons).
   At xl that header band measures ~80px tall; the masthead's
   screenshots sit at percentages of masthead-inner and would
   otherwise begin at ~37px from the top, clipping into the nav
   row's strikethrough on hover.

   The smallest --shot-*-top value across projects is Yahoo desktop
   at 6.22%, which on a 600px masthead places the image's top edge
   ~37px from the masthead top. With the 80px-tall site-header sitting
   ON TOP of the masthead at z:11, a 37px offset means the image is
   ~43px BEHIND the nav row — overlapped by the strikethrough text
   and the social icons. We need at least
     header-height (80) + 20 (visible gap) − 37 (Yahoo's natural offset) = 63px
   of additional inner offset to give every project a clean 20px+
   gap below the nav. 65px is the rest value used here (small safety
   margin for sub-pixel layout differences across viewports).

   The morph synchronization is owned by scripts/project-view.js: it
   runs a parallel Web Animation on the masthead-inner that
   interpolates `top: 0px` → `top: 65px` over the same 850ms /
   cubic-bezier(0.65, 0, 0.35, 1) curve as the masthead box morph.
   This means at t=0 the inner is at top:0 (matching the home
   thumbnail's source rect for a seamless FLIP) and at t=850ms the
   inner has glided into its safe rest position — no post-morph
   slide-down to chase, no visible drop after the masthead settles.

   Google Primer is excluded because its montage uses heavy negative
   --shot-*-top values (-58%/-67%) and is heavily clipped at the
   masthead's top edge by design — adding a 65px shift would expose
   pixels the design intentionally crops away. See the GP-specific
   override below this rule.

   This rule has no effect on the home page: every .project-view
   article carries `hidden` and is `display: none` until the user
   clicks a project card. ============================================================= */
.project-view__masthead-inner {
  top: 65px;
}
/* Google Primer one-off: shift the rotated montage UP by 100px from
   the standard interior position so the larger phones align with
   the design intent of the Figma masthead. The negative offset is
   safe because GP's montage is heavily clipped at the masthead's
   top edge by design, and the masthead's `overflow: hidden` makes
   the spilled top portion invisible — there's no collision with
   the nav elements that sit above the masthead. The JS animation
   (scripts/project-view.js) interpolates from `top: 0` → this rest
   value during the morph, so the FLIP source still matches the
   home thumbnail's frame exactly. */
.project-view[data-project-view="google-primer"] .project-view__masthead-inner {
  /* Final resting position: was top:-260px; nudged up 40px and left 25px
     per design. The FLIP morph animates both top and left from 0 so the
     entrance still starts flush with the home thumbnail. */
  top: -300px;
  left: -25px;
}

/* =============================================================
   Interior (project-view) page — tablet / mobile optimizations
   below 1180px. Appended last so these win over the base
   project-view rules at equal specificity. The FLIP morph is
   skipped on mobile (see scripts/project-view.js), so reframing
   the masthead here is safe.
   ============================================================= */
@media (max-width: 1180px) {
  /* --- Masthead: shrink the banner so the screenshot's BOTTOM is cropped
     by the masthead edge the way it is on desktop (rather than the whole
     shot floating fully visible), and lower the inner 20px. --- */
  .project-view {
    --masthead-h: clamp(260px, 40vw, 480px);
  }
  /* Google Primer masthead: match the Yahoo/Asana mobile banner height
     so all three interior pages share a consistent crop. */
  .project-view[data-project-view="google-primer"] {
    --masthead-h: calc(clamp(260px, 40vw, 480px) - 10px);
  }
  /* Google Primer masthead-inner: the desktop rule lifts the inner
     -300px to position the rotated montage above the 600px desktop band.
     On mobile we want the OPPOSITE — anchor the inner to the BOTTOM of
     the shorter banner so the montage's heaviest content sits along the
     baseline and the top phones can spill up into the nav area. The
     inner box is 600px tall by default (matches desktop --masthead-h);
     anchoring its bottom to the banner bottom is `top: masthead-h − 600px`. */
  .project-view[data-project-view="google-primer"] .project-view__masthead-inner {
    /* Anchor the visible rotated-montage content to the bottom of the
       250px banner. The PNG places the visible phones in the upper
       region of its image rect with empty space below, so positive top
       pushes the visible content down into the banner's lower portion.
       110% scale anchored to top-left so the up/left translate composes
       cleanly with the resize. */
    top: 10px;
    left: -50px;
    transform: scale(1.39);
    transform-origin: 0 0;
  }
  /* Yahoo masthead: crop 10px higher from the bottom by trimming the
     banner height (the shot's bottom is clipped 10px sooner). */
  .project-view[data-project-view="yahoo"] {
    --masthead-h: calc(clamp(260px, 40vw, 480px) - 10px);
  }
  /* Asana masthead: cap at the same banner height as Yahoo on mobile. */
  .project-view[data-project-view="asana"] {
    --masthead-h: calc(clamp(260px, 40vw, 480px) - 10px);
  }
  /* Lower the Asana desktop screenshot on mobile so its bottom is cropped
     by the masthead edge (was -80px → now 0). */
  .project-view[data-project-view="asana"] .project-view__shot--desktop {
    transform: translateY(0px) rotate(var(--shot-desktop-rotate, 0deg));
  }
  .project-view__masthead-inner {
    top: 40px;
  }

  /* --- Tighten the default vertical section padding (100px → 60px) so
     the interior sections aren't over-spaced on smaller screens. --- */
  .project-view__body-grid,
  .project-view__showcase,
  .project-view__vision,
  .project-view__strategy,
  .project-view__next {
    padding-block: 60px;
  }

  /* --- Next project card on mobile ---------------------------------
     The label's `grid-column: 3 / span 20` overflows the 8/4-col mobile
     grid, spawning empty implicit columns whose gutters collapse the card
     to ~228px. Snap the label to the full content width so the card fills
     it, then swap the small live composite for the baked desktop PNG. --- */
  .project-view__next-label {
    grid-column: 1 / -1;
  }
  .project-view__next .project__logo,
  .project-view__next .project__shot {
    display: none;
  }
  .project-view__next .project__link {
    background: transparent;
    border-radius: 0;
    overflow: visible;
  }
  .project-view__next-static {
    display: block;
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: contain;
  }

  /* --- Full-width montage feature: the wide artwork over-zooms under
     object-fit:cover on a portrait screen. Halve the band to 50vh and
     scale the image down to fit (contain), with the Yahoo-purple backdrop
     filling the letterbox so the montage's own purple bg reads seamless.
     Scoped to the feature that actually has an image so the other
     projects' grey placeholder bands are untouched. --- */
  .project-view__feature:has(.project-view__feature-img) {
    height: 50vh;
    /* Sit directly under the intro's 60px bottom padding so the gap between
       the "Project Hometown" copy and this montage is exactly 60px (was the
       60px padding PLUS the feature's 96px top margin). */
    margin-top: 0;
  }
  .project-view__feature:has(.project-view__feature-img)
    .project-view__feature-inner {
    background-color: #7d2eff;
  }
  /* Fill the 50vh band edge-to-edge (cover) instead of letterboxing the
     wide montage with purple bands — keeps the band size, crops the sides. */
  .project-view__feature-img {
    object-fit: cover;
  }

  /* --- Body content: stack Role/Scope above the Context copy
     (the base rules keep them side-by-side, which is too tight). --- */
  .project-view__body {
    grid-column: 2 / span 10;
    row-gap: 32px;
  }
  .project-view__info,
  .project-view__copy {
    grid-column: 1 / -1;
  }

  /* --- Strategy: stack the three columns into one. --- */
  .project-view__vision-body {
    grid-column: 2 / span 10;
  }
  .project-view__vision-columns {
    grid-template-columns: 1fr;
    gap: 32px;
  }

  /* Showcase stacking is handled by the ≤1279 block above (it triggers
     earlier because the desktop overlap is built for the 24-col grid). */

  /* --- Carousel: keep it horizontal, but let a card shrink toward
     one-per-viewport so the screenshots aren't oversized. Match the
     track padding to the new card width so the first/last card still
     centers (this also fixes the native-scroll ≤767 fallback, which
     otherwise parks the first card off to the right at rest). --- */
  .project-view__hscroll-card {
    width: clamp(280px, 86vw, 900px);
  }
  .project-view__hscroll-track-inner {
    padding-inline: calc(50vw - clamp(280px, 86vw, 900px) / 2);
  }
}

/* Phone (≤767px) — go full-bleed content width on the stacked blocks. */
@media (max-width: 767px) {
  .project-view__body,
  .project-view__vision-body,
  .project-view__showcase-title,
  .project-view__showcase-aside,
  .project-view__showcase-cluster {
    grid-column: 1 / -1;
  }
}
