/* ============================================================
   Gaspard Beharel — personal site
   Tokens, layout and components for the four-screen one-pager.
   ============================================================ */

:root {
  --font-serif: 'Newsreader', Georgia, serif;
  --font-sans: 'Space Grotesk', sans-serif;
  --font-cursive: 'La Belle Aurore', cursive;
  --paper: #ffffff;
  --ink: #0a0a0a;
  --mut: #6b6b6b;
  --hair: rgba(10, 10, 10, .13);
  --hair-chip: rgba(10, 10, 10, .16);
  --num: #a3a09a;

  /* Experience deck. --gh-exp-step is the scroll runway one covering takes —
     both the section's extra height and the position of its closing snap
     marker, so the two cannot drift. --gh-exp-band is how much of a covered
     card stays visible, and it is sized so that card's header (logo + company
     + role) still fits inside it; --gh-exp-card-h is the card's own height.
     The three are independent but --gh-exp-band and the card header's
     padding/min-height move together — change the band and check the header
     still clears it.

     67.5svh is 90svh less a quarter: the covering was taking the better part
     of a screen to play out, which read as a long wait before the second card
     arrived. */
  --gh-exp-step: 67.5svh;

  /* The stopping point. Once the covering has finished, the section HOLDS the
     finished stack for this much more scrolling before it will let go.

     During the hold nothing moves — the stage stays pinned and the covering is
     clamped at its end — so the reader's scroll is absorbed rather than acted
     on. Without it the gesture that completes the covering carries straight on
     to the closing quote, and the finished stack is never a place you land.

     It reads as distance rather than as a timer: the reader has to scroll past
     it to leave, and half of it plus half a screen is what that costs. */
  --gh-exp-dwell: 40svh;
  --gh-exp-band: 76px;
  --gh-exp-card-h: min(58svh, 500px);
  /* Gap between the copy and the workplace photo. A token rather than a
     literal in three places because the reduced-motion unwind has to restore
     it, and a hard-coded desktop value there silently overrode the mobile one
     — the same mistake the section's padding made. */
  --gh-exp-photo-gap: 16px;
}

*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  overscroll-behavior-y: none;
  /* EXPERIMENT (feat/smooth-scrolling): no scroll-snap at all. The site
     scrolls continuously, like a document — no pull toward a screen edge,
     no blocking, no focus stops. On feat/experience this was
     `scroll-snap-type: y mandatory` with an aligned .gh-sec on every
     screen; the whole system is stripped on this branch only, to be
     compared against. */
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-serif);
  font-size: 19px;
  line-height: 1.62;
  font-weight: 400;
  overflow-x: hidden;
}

::selection { background: var(--ink); color: var(--paper); }

/* ---- Screens ------------------------------------------------ */

.gh-screen {
  position: relative;
  min-height: 100vh;   /* fallback for browsers without dvh */
  min-height: 100svh;  /* stable height with mobile toolbar expanded */
  min-height: 100dvh;  /* real visible height — adapts to the browser toolbar */
  display: flex;
  align-items: center;
  padding: 80px 40px;
}

.gh-inner {
  max-width: 720px;
  margin: 0 auto;
  width: 100%;
}

.gh-inner--center { text-align: center; }

/* ---- Scroll-reveal ----------------------------------------- */
[data-reveal] {
  opacity: 0;
  transform: translateY(26px);
  transition: opacity 1s cubic-bezier(.2, .7, .2, 1),
              transform 1s cubic-bezier(.2, .7, .2, 1);
}
[data-reveal].gh-in { opacity: 1; transform: none; }

/* ---- Language Switcher (Top Right) ------------------------ */
.gh-lang-switcher {
  position: fixed;
  top: 32px;
  right: 40px;
  z-index: 900;
  pointer-events: auto;
  animation: gh-fade-in 0.8s cubic-bezier(.2, .7, .2, 1) backwards;
  animation-delay: 0.2s;
}

.gh-lang-capsule {
  display: inline-flex;
  align-items: center;
  padding: 3px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border: 1px solid var(--hair-chip);
  box-shadow: 0 4px 20px rgba(10, 10, 10, 0.05);
  transition: border-color 0.25s ease, box-shadow 0.25s ease;
}

.gh-lang-capsule:hover {
  border-color: rgba(10, 10, 10, 0.28);
  box-shadow: 0 6px 24px rgba(10, 10, 10, 0.08);
}

.gh-lang-btn {
  font-family: var(--font-sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  line-height: 1;
  padding: 6px 11px;
  border-radius: 999px;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: all 0.22s cubic-bezier(.2, .8, .2, 1);
  user-select: none;
}

.gh-lang-btn--active {
  background: var(--ink);
  color: var(--paper);
  box-shadow: 0 2px 8px rgba(10, 10, 10, 0.22);
  cursor: default;
}

.gh-lang-btn--link {
  color: var(--mut);
  background: transparent;
  cursor: pointer;
}

.gh-lang-btn--link:hover {
  color: var(--ink);
  background: rgba(10, 10, 10, 0.06);
}

.gh-lang-btn--link:active {
  transform: scale(0.96);
}

/* ---- CV download FAB (top left, mirrors the switcher) ------ */
.gh-cv {
  position: fixed;
  top: 32px;
  left: 40px;
  z-index: 900;
  animation: gh-fade-in 0.8s cubic-bezier(.2, .7, .2, 1) backwards;
  animation-delay: 0.3s;
}
.gh-cv-btn {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 1px solid var(--hair-chip);
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  box-shadow: 0 4px 20px rgba(10, 10, 10, 0.05);
  color: var(--mut);
  cursor: pointer;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: all 0.22s cubic-bezier(.2, .8, .2, 1);
  user-select: none;
}
.gh-cv-btn svg {
  width: 18px;
  height: 18px;
  display: block;
  transition: transform 0.22s cubic-bezier(.2, .8, .2, 1);
}
.gh-cv-btn:hover {
  color: var(--ink);
  border-color: rgba(10, 10, 10, 0.28);
  box-shadow: 0 6px 24px rgba(10, 10, 10, 0.08);
  transform: translateY(-2px);
}
.gh-cv-btn:hover svg { transform: scale(1.08); }
.gh-cv-btn:active { transform: scale(0.96); }

.gh-cv-pop {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  min-width: 176px;
  padding: 10px;
  border-radius: 14px;
  background: rgba(255, 255, 255, 0.9);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--hair-chip);
  box-shadow: 0 14px 40px rgba(10, 10, 10, 0.14);
  opacity: 0;
  transform: translateY(-4px) scale(0.98);
  transform-origin: top left;
  pointer-events: none;
  transition: opacity 0.22s ease, transform 0.22s cubic-bezier(.2, .8, .2, 1);
}
.gh-cv.is-open .gh-cv-pop {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}
.gh-cv-pop-title {
  margin: 2px 6px 0;
  font-family: var(--font-sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--ink);
}
.gh-cv-pop-hint {
  margin: 1px 6px 6px;
  font-family: var(--font-sans);
  font-size: 9.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--num);
}
.gh-cv-opt {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 7px 8px;
  border-radius: 9px;
  text-decoration: none;
  font-family: var(--font-sans);
  color: var(--ink);
  transition: background 0.18s ease;
}
.gh-cv-opt:hover { background: rgba(10, 10, 10, 0.055); }
.gh-cv-opt-name { font-size: 12px; font-weight: 500; }
.gh-cv-opt-tag {
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.1em;
  color: var(--num);
}

/* ---- Handwritten hero notes (point at FAB & GitHub dock) --- */
/* Cursive = the same hand that writes the name; slight rotation and
   a late entrance make them feel annotated after the fact. They fade
   away off the hero together with the Rails colophon (main.js §4). */
.gh-hero-note {
  position: fixed;
  z-index: 900;
  display: flex;
  align-items: flex-start;
  gap: 7px;
  font-family: var(--font-cursive);
  font-size: 18px;
  line-height: 1;
  color: var(--ink);
  pointer-events: none;
  opacity: 1;
  transition: opacity 0.45s ease, transform 0.45s ease;
  animation: gh-fade-in 0.9s cubic-bezier(.2, .7, .2, 1) backwards;
  animation-delay: 0.85s;
}
.gh-hero-note--cv {
  top: 82px;
  left: 58px;
  transform: rotate(-2deg);
}
.gh-hero-note--cv svg { margin-top: -4px; }
.gh-hero-note--projects {
  bottom: 84px;
  right: 52px;
  transform: rotate(1.4deg);
}
.gh-hero-note--projects svg { margin-top: 16px; }
.gh-hero-note--hidden { opacity: 0; }
/* While the download popover is open, the resume note steps aside —
   the popover would otherwise land on top of it. Fades back when closed. */
.gh-cv.is-open ~ .gh-hero-note--cv { opacity: 0; }

/* ---- Built-with Rails Badge (Bottom Left) ----------------- */
.gh-rails-badge {
  position: fixed;
  bottom: 5px;
  left: 5px;
  z-index: 900;
  text-decoration: none;
  animation: gh-fade-in 0.8s cubic-bezier(.2, .7, .2, 1) backwards;
  animation-delay: 0.25s;
  transition: opacity 0.45s ease, transform 0.45s ease;
}

/* Hidden once the reader starts scrolling (see main.js). */
.gh-rails-badge--hidden {
  opacity: 0;
  transform: translateY(10px);
  pointer-events: none;
}

.gh-rails-capsule {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  /* No oval outline — just a faint frosted wash so scrolling text stays
     readable behind the badge. */
  background: rgba(255, 255, 255, 0.72);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

.gh-rails-badge:hover .gh-rails-label {
  color: var(--ink);
}

.gh-rails-logo {
  height: 12px;
  width: auto;
  display: block;
  transition: transform 0.25s cubic-bezier(.2, .8, .2, 1);
}

.gh-rails-badge:hover .gh-rails-logo {
  transform: scale(1.06);
}

.gh-rails-label {
  font-family: var(--font-sans);
  font-size: 9px;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: #9a9a9a;
  transition: color 0.25s ease;
}

/* ---- Persistent Social Dock (Bottom Right) ---------------- */
.gh-social-dock {
  position: fixed;
  bottom: 32px;
  right: 40px;
  z-index: 900;
  pointer-events: auto;
  animation: gh-fade-in 0.8s cubic-bezier(.2, .7, .2, 1) backwards;
  animation-delay: 0.35s;
}

.gh-social-capsule {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border: 1px solid var(--hair-chip);
  box-shadow: 0 4px 20px rgba(10, 10, 10, 0.05);
  transition: border-color 0.25s ease, box-shadow 0.25s ease;
}

.gh-social-capsule:hover {
  border-color: rgba(10, 10, 10, 0.28);
  box-shadow: 0 6px 24px rgba(10, 10, 10, 0.08);
}

.gh-social-btn {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: var(--mut);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
  text-decoration: none;
  transition: all 0.22s cubic-bezier(.2, .8, .2, 1);
  user-select: none;
}

.gh-social-btn svg {
  width: 18px;
  height: 18px;
  display: block;
  transition: transform 0.22s cubic-bezier(.2, .8, .2, 1);
}

.gh-social-btn:hover {
  color: var(--ink);
  background: rgba(10, 10, 10, 0.07);
  transform: translateY(-2px);
}

.gh-social-btn:hover svg {
  transform: scale(1.08);
}

.gh-social-btn:active {
  transform: scale(0.94);
}

@keyframes gh-fade-in {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* ============================================================
   01 — Hero
   ============================================================ */
.gh-hero {
  position: relative;
  min-height: 100vh;   /* fallback for browsers without dvh */
  min-height: 100svh;  /* stable height with mobile toolbar expanded */
  min-height: 100dvh;  /* real visible height — adapts to the browser toolbar */
  padding: 34px 40px 26px;
}
.gh-hero-stack {
  position: absolute;
  top: 44%;
  left: 40px;
  right: 40px;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(30px, 4.2vw, 50px);
}
.gh-portrait {
  width: clamp(92px, 12vw, 128px);
  height: clamp(92px, 12vw, 128px);
  border-radius: 50%;
  overflow: hidden;
  box-shadow: 0 0 0 1px var(--hair);
  background: var(--paper);
  display: block;
  flex: none;
  transition: box-shadow 0.45s ease;
}
.gh-portrait img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 26%;
  filter: grayscale(1) contrast(1.03);
  display: block;
  transition: filter 0.55s cubic-bezier(.2, .8, .2, 1),
              transform 0.55s cubic-bezier(.2, .8, .2, 1);
}
/* Color reveal: the graduation portrait blooms into color on hover,
   with a whisper of zoom — a small moment of life in the monochrome hero. */
.gh-portrait:hover {
  box-shadow: 0 0 0 1px rgba(10, 10, 10, 0.28), 0 6px 24px rgba(10, 10, 10, 0.10);
}
.gh-portrait:hover img {
  filter: grayscale(0) contrast(1.02);
  transform: scale(1.04);
}

/* ---- Hero Journey Capsule (FR → CN → JP → US) ------------- */
.gh-hero-journey {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  position: relative;
  /* Lift above the name's letter spans: .gh-l's ±0.45em padding (with
     negative margin) extends its hit area over the capsule and would
     otherwise steal the hover.
     👇 margin-bottom = extra distance between the flags capsule and the
     name; growing it pushes capsule + portrait up and the name down,
     because the whole stack re-centers around its middle. */
  z-index: 1;
  margin: -2px 0 clamp(8px, 1.1vw, 14px);
  animation: gh-fade-in 0.9s cubic-bezier(.2, .7, .2, 1) backwards;
  animation-delay: 0.15s;
}

.gh-journey-capsule {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 5px 13px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.85);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border: 1px solid var(--hair-chip);
  box-shadow: 0 4px 18px rgba(10, 10, 10, 0.04);
  transition: border-color 0.25s ease, box-shadow 0.25s ease;
}

.gh-journey-capsule:hover {
  border-color: rgba(10, 10, 10, 0.24);
  box-shadow: 0 6px 22px rgba(10, 10, 10, 0.07);
}

/* ---- Journey Tooltip --------------------------------------- */
.gh-journey-step {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  cursor: default;
  outline: none;
  user-select: none;
}

.gh-journey-step:focus-visible {
  border-radius: 4px;
  outline: 1.5px solid var(--ink);
  outline-offset: 2px;
}

.gh-journey-tooltip {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  padding: 4px 8px;
  background: rgba(10, 10, 10, 0.9);
  color: #ffffff;
  font-family: var(--font-sans);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.04em;
  white-space: nowrap;
  border-radius: 6px;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.2s cubic-bezier(.2, .7, .2, 1), transform 0.2s cubic-bezier(.2, .7, .2, 1);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  z-index: 1000;
}

.gh-journey-tooltip::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border-width: 4px;
  border-style: solid;
  border-color: rgba(10, 10, 10, 0.9) transparent transparent transparent;
}

.gh-journey-step:hover .gh-journey-tooltip,
.gh-journey-step:focus .gh-journey-tooltip,
.gh-journey-step:focus-visible .gh-journey-tooltip {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
}

.gh-journey-flag {
  width: 17.6px;
  height: 12px;
  border-radius: 3px;
  overflow: hidden;
  display: inline-flex;
  box-shadow: 0 1px 4px rgba(10, 10, 10, 0.10);
  flex-shrink: 0;
  -webkit-mask-image: -webkit-radial-gradient(white, black);
  isolation: isolate;
  box-sizing: border-box;
}

/* Japan is white-on-white; a hairline border delimits its rounded shape. */
.gh-flag-jp {
  border: 1px solid rgba(10, 10, 10, 0.18);
}

.gh-journey-flag svg {
  width: 100%;
  height: 100%;
  display: block;
}

.gh-journey-code {
  font-family: var(--font-sans);
  font-size: 8.8px;
  font-weight: 600;
  letter-spacing: 0.08em;
  color: var(--mut);
  line-height: 1;
  transition: color 0.2s ease;
}

.gh-journey-step:hover .gh-journey-code {
  color: var(--ink);
}

.gh-journey-arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--num);
  opacity: 0.7;
}

.gh-journey-arrow svg {
  width: 8.8px;
  height: 8.8px;
  display: block;
}

/* Styled by class (not #id) so the infinite-loop clone — which keeps its
   classes but has its ids stripped — retains the handwritten font. */
.gh-name {
  position: relative;
  text-align: center;
  margin: 0;
  /* Base cushion for the cursive side-bearings (esp. the g descender) in case
     the JS auto-fit doesn't run; the script overrides this with exact values. */
  padding: 0 0.22em;
  font-family: var(--font-cursive);
  /* The locale stores the properly cased name ("Gaspard Beharel") so the
     markup carries the real spelling a crawler should read; the cursive
     design wants it lowercase, so draw it that way here. Rendering is
     unchanged — main.js lowercases before measuring the glyphs to match. */
  text-transform: lowercase;
  font-weight: 400;
  font-size: 5.6em;
  line-height: 1.02;
  letter-spacing: normal;
  color: var(--ink);
  white-space: nowrap;
}

/* The auto-fit measures this element against the cursive face, so it is held
   unpainted until main.js has done so — otherwise the reader watches the
   fallback width snap to the real one (measured: 151px at 1280px wide, and
   an 18% size change at 390px). main.js adds .is-fitted; <noscript> in the
   layout restores it for readers without JS.

   opacity, not visibility: .gh-name is the page's only <h1>, and
   visibility: hidden would drop it out of the accessibility tree while
   giving nothing back visually. opacity holds the paint identically and
   leaves the heading readable to assistive tech and to text search.

   gh-name-reveal is a second safety net that does not depend on JS at all:
   if main.js never runs, the name still appears after 2.5s. forwards is
   permanent — the reveal is one-shot and cannot be re-armed by removing
   .is-fitted. */
.gh-js .gh-name {
  opacity: 0;
  animation: gh-name-reveal 0s linear 2.5s forwards;
}
.gh-js .gh-name.is-fitted { opacity: 1; }
@keyframes gh-name-reveal { to { opacity: 1; } }

.gh-l, .gh-name-char {
  display: inline-block;
  animation: gh-breathe 3.4s ease-in-out infinite;
  /* iOS Safari rasterizes each animated span into a compositing layer sized
     to its border box, clipping the cursive ink that overhangs it (the g's
     descender loop on the left, the h/r swashes on the right). Padding
     enlarges the painted box; the negative margins cancel it in layout. */
  padding: 0.45em;
  margin: -0.45em;
}
.gh-name-gap { display: inline-block; width: 0.32em; }

@keyframes gh-breathe {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.05); }
}

/* ---- Hero tagline (student · maker · relentless) ------------ */
/* Italic serif — the language of the site's quotes, not the UI's
   small caps. The two nouns stay quiet gray; the temperament word
   carries the weight. Hugs the name with a negative pull against
   the stack's gap, and arrives last during the entrance cascade. */
.gh-hero-tag {
  /* 👇 DISTANCE TO THE NAME: actual gap = hero stack gap (30–50px
     depending on screen width) + this margin. Raise the numbers to
     push the tagline further from the name; use negatives to pull it
     closer. */
  margin: clamp(10px, 1.6vw, 18px) 0 0;
  font-family: var(--font-serif);
  font-style: italic;
  font-weight: 300;
  font-size: clamp(15px, 2vw, 21px);
  letter-spacing: 0.01em;
  line-height: 1;
  color: var(--mut);
  text-align: center;
  white-space: nowrap;
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 0.55em;
  animation: gh-fade-in 0.9s cubic-bezier(.2, .7, .2, 1) backwards;
  animation-delay: 0.5s;
}
.gh-hero-tag .gh-tag-dot {
  color: var(--num);
  font-weight: 300;
}
.gh-hero-tag strong {
  font-style: italic;
  font-weight: 400;
  color: var(--ink);
}

/* ============================================================
   02 — Présentation
   ============================================================ */
.gh-eyebrow {
  font-family: var(--font-sans);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--mut);
  margin: 0 0 34px;
}
.gh-eyebrow--tight { margin-bottom: 22px; }

.gh-lead { margin: 0 0 28px; color: var(--mut); }
/* Class-based (not #id) so the infinite-loop clones — whose ids are
   stripped — render exactly like the originals. */
.gh-body { margin: 0; color: var(--ink); }
.gh-body + .gh-body { margin-top: 26px; }

/* Closing "the ask" line — sans (UI voice) against the serif story,
   so it reads as a purposeful statement, not another paragraph. */
.gh-cta {
  margin: 34px 0 0;
  font-family: var(--font-sans);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: 0.01em;
  line-height: 1.55;
  color: var(--ink);
}
.gh-cta .gh-num { color: var(--num); font-weight: 400; }

/* Inline emlyon quick-link badge, sitting in the lead sentence. */
.gh-school {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  color: inherit;
  margin-left: 0px;
  margin-right: 2px; /* breathing room between the badge and the comma after it */
  vertical-align: middle;
  position: relative;
  top: -2px; /* nudge the badge up so it sits optically centered on the line */
}
.gh-school-badge {
  width: 1.8em;
  height: 1.8em;
  display: block;
  border-radius: 6px;
  box-shadow: 0 2px 8px rgba(10, 10, 10, 0.08), 0 0 12px rgba(219, 10, 32, 0.30);
  transform-origin: center;
  transition: transform 0.25s cubic-bezier(.2, .8, .2, 1), box-shadow 0.25s ease;
}
.gh-school:hover .gh-school-badge {
  transform: scale(1.5) rotate(-2deg);
  box-shadow: 0 7px 20px rgba(10, 10, 10, 0.18), 0 0 24px rgba(219, 10, 32, 0.45);
}
.gh-school:active .gh-school-badge {
  transform: scale(1.08);
}
.gh-school:focus-visible {
  outline: none;
}
.gh-school:focus-visible .gh-school-badge {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.gh-num { color: var(--num); }

/* ============================================================
   03 — Builder (projects)
   ============================================================ */
.gh-intro {
  margin: 0 0 44px;
  width: 100%;
  color: var(--ink);
  font-size: 21px;
  line-height: 1.6;
}

/* --- Project row: story left, live terminal right ------------ */
.gh-proj {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.12fr);
  gap: clamp(20px, 4vw, 52px);
  align-items: center;
  padding: clamp(28px, 5.5vh, 54px) 0;
  border-top: 1px solid var(--hair);
}
.gh-proj-title {
  margin: 0 0 6px;
  font-family: var(--font-serif);
  font-weight: 400;
  font-size: clamp(21px, 2.6vw, 27px);
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: var(--ink);
}
.gh-proj-tag {
  margin: 0 0 14px;
  font-family: var(--font-serif);
  font-style: italic;
  font-weight: 300;
  font-size: 15.5px;
  line-height: 1.4;
  color: var(--mut);
}
.gh-proj-desc {
  margin: 0 0 14px;
  font-size: 16px;
  line-height: 1.62;
  color: var(--ink);
}
.gh-proj-meta {
  margin: 0 0 14px;
  font-family: var(--font-sans);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--num);
}
.gh-proj-link {
  display: inline-block;
  font-family: var(--font-sans);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid rgba(10, 10, 10, 0.28);
  padding-bottom: 1px;
  transition: border-color 0.25s ease;
}
.gh-proj-link:hover { border-color: var(--ink); }
.gh-proj-vis { margin: 0; min-width: 0; }

/* --- Simulated terminal -------------------------------------- */
.gh-term {
  position: relative;
  border-radius: 10px;
  background: #141413;
  box-shadow: 0 10px 32px rgba(10, 10, 10, 0.16),
              0 2px 6px rgba(10, 10, 10, 0.08),
              inset 0 0 0 1px rgba(255, 255, 255, 0.05);
  overflow: hidden;
  transition: box-shadow 0.3s ease, transform 0.3s cubic-bezier(.2, .8, .2, 1);
}
.gh-proj:hover .gh-term {
  transform: translateY(-3px);
  box-shadow: 0 16px 44px rgba(10, 10, 10, 0.22),
              0 3px 8px rgba(10, 10, 10, 0.10),
              inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.gh-term-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
  background: rgba(255, 255, 255, 0.02);
}
.gh-term-dots i {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #3d3c38;
  margin-right: 5px;
}
.gh-term-name {
  font-family: var(--font-sans);
  font-size: 10.5px;
  letter-spacing: 0.08em;
  color: #7f7d78;
}
.gh-term-body {
  padding: 14px 16px 16px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
  font-size: 12px;
  line-height: 1.8;
  color: #d9d6cf;
}
.gh-term-body p {
  margin: 0 0 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.t-dim { color: #6f6d68; }
.t-em { color: #fff; font-weight: 600; letter-spacing: 0.04em; }
.t-row {
  display: grid;
  /* ch = monospace character cells; total + gaps stays inside the panel,
     so the ≈/yr column can never collide with the right edge. */
  grid-template-columns: 5ch 7ch 9ch 7ch 6ch 2ch;
  align-items: baseline;
  gap: 8px;
  padding: 2px 0;
  white-space: nowrap;
  transition: color 0.3s ease;
}
.t-state { font-weight: 600; }
.t-row.is-hot { color: #ff6b62; }
.t-row.is-hot .t-em { color: #ff8a80; }
.t-foot { color: #6f6d68; margin-top: 6px; }
.t-alert { display: none; color: #ff6b62; text-shadow: 0 0 12px rgba(255, 80, 70, 0.45); }
.t-alert.is-on { display: block; animation: gh-alert 2.6s ease both; }
/* "No window right now" — the quiet state, shown in calm gray. */
.t-alert.calm { color: #6f6d68; text-shadow: none; animation: none; }
@keyframes gh-alert {
  from { opacity: 0; transform: translateY(3px); }
  12%  { opacity: 1; transform: none; }
}
.t-log p { display: flex; gap: 10px; align-items: baseline; }
.t-up { color: #6fbf88; font-style: normal; }
.t-dn { color: #d76d63; font-style: normal; }
.t-caret {
  display: inline-block;
  width: 7px;
  height: 13px;
  background: #d9d6cf;
  vertical-align: -2px;
  animation: gh-caret 1.1s steps(1) infinite;
}
@keyframes gh-caret { 50% { opacity: 0; } }
.gh-term-cap {
  margin-top: 8px;
  font-family: var(--font-sans);
  font-size: 9.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--num);
  text-align: right;
}

/* ============================================================
   04 — Contact
   ============================================================ */
.gh-quote {
  font-family: var(--font-serif);
  font-style: italic;
  font-weight: 300;
  font-size: clamp(26px, 4vw, 42px);
  line-height: 1.45;
  letter-spacing: -0.01em;
  margin: 0 auto 64px;
  white-space: nowrap;
  color: var(--ink);
}

.gh-signature {
  margin-top: 72px;
  font-family: var(--font-cursive);
  /* Same deal as .gh-name: cased in the locale, drawn lowercase. */
  text-transform: lowercase;
  font-size: clamp(38px, 6vw, 60px);
  line-height: 1;
  color: var(--ink);
}

/* The closing ask and the page's terminal action. The pill is the only
   bordered element on screen 4, which is what makes it read as the thing to
   do — no filled button exists anywhere else in the theme, and the single
   ink-on-paper inversion is ::selection, so the fill arrives on hover/focus
   rather than sitting there as the heaviest object on a white page. */
/* Deliberately the widest gap in the section after the name: the quote and
   the signature are one sentence, the ask and the pill are the action, and
   the space is what separates the two groups. */
.gh-ask {
  margin: 104px 0 0;
  font-family: var(--font-sans);
  font-size: 14px;
  line-height: 1.55;
  color: var(--mut);
}

.gh-ask-btn {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  margin-top: 28px;
  padding: 11px 22px;
  /* Declared, not emergent: without it the height is font-size x the inherited
     body line-height + padding, so a change to any of those three silently
     drops the target below 44px with nothing to catch it. */
  min-height: 44px;
  border-radius: 999px;
  border: 1px solid var(--hair-chip);
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  box-shadow: 0 4px 20px rgba(10, 10, 10, 0.05);
  font-family: var(--font-sans);
  /* Never smaller than the ask line above it — a label that recedes from its
     own helper text reads as secondary when it is the page's terminal action. */
  font-size: 14px;
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
  transition: all 0.22s cubic-bezier(.2, .8, .2, 1);
}

.gh-ask-btn svg {
  display: block;
  transition: transform 0.22s cubic-bezier(.2, .8, .2, 1);
}

.gh-ask-btn:hover,
.gh-ask-btn:focus-visible {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--paper);
  transform: translateY(-2px);
  box-shadow: 0 8px 26px rgba(10, 10, 10, 0.16);
}

.gh-ask-btn:hover svg,
.gh-ask-btn:focus-visible svg { transform: translateX(3px); }

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

.gh-ask-btn:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

/* ---- Contact actions row ----------------------------------- */
.gh-contact-actions {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  margin-top: 38px;
  flex-wrap: wrap;
}

.gh-contact-actions .gh-ask-btn {
  margin-top: 0;
}

/* 21st.dev ButtonGroup copy element with linear compacting morph */
.gh-btn-group {
  display: inline-flex;
  align-items: stretch;
  border-radius: 999px;
  border: 1px solid var(--hair-chip);
  background: rgba(255, 255, 255, 0.85);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  box-shadow: 0 2px 8px rgba(10, 10, 10, 0.04);
  overflow: hidden;
  transition: border-color 0.4s linear,
              box-shadow 0.4s linear,
              background 0.4s linear;
}

.gh-btn-group-text {
  display: inline-flex;
  align-items: center;
  width: 220px;
  box-sizing: border-box;
  padding: 0 16px;
  min-height: 44px;
  font-family: var(--font-sans);
  font-size: 13px;
  font-weight: 500;
  color: var(--ink);
  user-select: all;
  white-space: nowrap;
  overflow: hidden;
  opacity: 1;
  transition: width 0.5s linear,
              padding 0.5s linear,
              opacity 0.25s linear;
}

.gh-copy-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  min-height: 44px;
  padding: 0;
  border: none;
  border-left: 1px solid var(--hair-chip);
  background: transparent;
  color: var(--mut);
  cursor: pointer;
  user-select: none;
  outline: none;
  transition: width 0.5s linear,
              padding 0.5s linear,
              border-color 0.4s linear,
              color 0.2s linear,
              background 0.2s linear;
}

.gh-copy-btn:hover {
  color: var(--ink);
  background: rgba(10, 10, 10, 0.03);
}

.gh-btn-group:hover {
  border-color: rgba(10, 10, 10, 0.28);
}

.gh-copy-btn:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: -2px;
}

.gh-copy-icon {
  display: block;
  transition: opacity 0.2s linear;
}

.gh-copied-feedback {
  display: none;
  align-items: center;
  gap: 6px;
  color: #10b981; /* emerald-500 */
  font-family: var(--font-sans);
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
}

.gh-copied-icon {
  display: block;
  color: #10b981;
}

/* Copied Compacted State:
   Constant linear speed in both directions, zero dead-zone. */
.gh-btn-group.is-copied {
  border-color: #10b981;
  box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.3), 0 4px 18px rgba(16, 185, 129, 0.12);
  background: #ffffff;
}

.gh-btn-group.is-copied .gh-btn-group-text {
  width: 0;
  padding: 0;
  opacity: 0;
  pointer-events: none;
}

.gh-btn-group.is-copied .gh-copy-btn {
  width: auto;
  padding: 0 18px;
  border-left-color: transparent;
  background: transparent;
}

.gh-btn-group.is-copied .gh-copy-icon {
  display: none;
}

.gh-btn-group.is-copied .gh-copied-feedback {
  display: inline-flex;
  animation: gh-copied-linear-in 0.25s linear both;
}

@keyframes gh-copied-linear-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* Accessibility helper */
.gh-sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

/* .gh-screen reserves the arrow zone at the bottom, so its content sits centred
   ABOVE the arrows rather than in the viewport. That read fine while the screen
   held only a quote and a signature, but the action pill made the group look
   high. Symmetric padding re-centres it at any viewport height, and 96px is
   small enough to keep the tallest case on one screen: 392px of content plus
   192px of padding still clears a 600px-tall window. Apply to the loop clone
   too — it inherits this by class, and a mismatch would re-flow the wrap. */
.gh-contact {
  padding-top: 96px;
  padding-bottom: 96px;
}

/* ============================================================
   05 — Experience (scroll-stacking cards)
   ============================================================ */

/* display:block is not cosmetic — it overrides .gh-screen's
   `display:flex; align-items:center`. See the armed rule below.
   Base state: an ordinary, readable vertical list. This is what a reader
   without JavaScript gets, and what reduced-motion keeps. Every rule that
   turns the list into a stack hangs off .gh-exp-armed. */
/* NO padding on the section — the stage carries it.
   That is deliberate: the armed state gives the section a fixed 190svh height
   and pins a 100svh stage inside it, so the padding has to belong to the
   stage or the two would add up to more than the viewport. Declaring it in
   one place means the armed and unarmed states cannot drift, and the
   reduced-motion unwind has no padding to undo at a second breakpoint —
   which is exactly the bug it had when this lived on the section. */
.gh-experience {
  display: block;
  /* padding:0 is load-bearing, and its absence was a bug for several
     revisions.

     `.gh-screen` gives every section 80px top and 168px bottom of padding. A
     sticky child is constrained by its parent's CONTENT box, so leaving that
     padding in place shortened the stage's travel by 168px: it came unstuck
     at p=0.75 instead of p=1, and the last quarter of the covering played out
     while the whole card was already sliding out of view. Measured — stage
     top was -168 at the point the covering completed.

     The padding belongs on the stage anyway, which is the sticky element that
     is actually one viewport tall. */
  padding: 0;
}

.gh-exp-stage {
  position: relative;
  padding: 80px 40px;
}

/* EXPERIMENT (feat/smooth-scrolling): these markers stay. They are no longer
   snap positions — there is no snap on this branch — but main.js section 8
   MEASURES the covering's distance off .gh-exp-snap-end, so their boxes and
   positions are load-bearing for the stacking effect itself. */
.gh-exp-snap-start,
.gh-exp-snap-end {
  position: absolute;
  left: 0;
  right: 0;
  height: 1px;
  pointer-events: none;
}

/* Marks where the covering begins, for section 8's measurements. */
.gh-exp-snap-start { top: 0; }

/* The point where the covering completes: one runway below the section's top.
   Section 8 reads this position to size the covering; here it is purely that
   anchor — no snap, no stop, no buffer. */
.gh-exp-snap-end {
  top: var(--gh-exp-step);
  pointer-events: none;
}

/* Same relationship to the deck as .gh-eyebrow has to .gh-inner on the other
   screens: left-aligned inside the 720px column, sitting directly above the
   content it labels. Only the horizontal centring of the column is added —
   .gh-eyebrow's own `margin: 0 0 34px` already supplies the gap below. */
.gh-exp-heading {
  width: 100%;
  max-width: 720px;
  margin: 0 auto 34px;
  text-align: left;
}

.gh-exp-deck {
  display: flex;
  flex-direction: column;
  gap: 28px;
  max-width: 720px;
  margin: 0 auto;
}

/* Heavier glass than the site's chrome on purpose: the switcher and the dock
   are frosted so they recede over text, but a card has to read as a
   deliberate object on a white page. A lower-opacity white, a much larger
   blur, a bright inner top edge and a soft drop shadow give it an edge
   without going opaque — which is the whole ask, since a plain white card
   on a white page shows nothing but its border. */
.gh-exp-card {
  position: relative;
  width: 100%;
  padding: 18px 26px 24px;
  border: 1px solid var(--hair-chip);
  border-radius: 16px;
  /* 0.86, not the 0.55 this carried before the photos. At 0.55 the surface was
     fine over white and over the other card's TEXT, but mid-cover the card
     beneath now shows a photo, and it blurred through as a coloured smudge
     under the covering card's copy. The reader is invited to stop mid-cover,
     so that state has to stay legible. */
  background: rgba(255, 255, 255, 0.86);
  backdrop-filter: blur(28px) saturate(180%);
  -webkit-backdrop-filter: blur(28px) saturate(180%);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset,
              0 10px 34px rgba(10, 10, 10, 0.10),
              0 2px 8px rgba(10, 10, 10, 0.06);
  transform-origin: top center;
  will-change: transform;
}

/* ---- Card header ------------------------------------------------ */
/* The title block takes the left, the company mark the right. Titles come
   FIRST in the DOM so tab and screen-reader order match the eye; space-between
   pushes the mark to the right edge rather than source order doing it, which
   is what keeps the two separable.

   This block is also the thing --gh-exp-band exists to protect: 18px of card
   padding-top plus this 37px header has to fit inside the 76px band, or a
   covered card can no longer name itself. min-height matches the logo chip
   so the arithmetic in frontend_assets_test.rb models the real box. */
.gh-exp-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 37px;
}

/* The mark is a link out to the company's site — the header is the only place
   a reader can act on the company. The link WRAPS the chip rather than being
   it, so the chip keeps its own geometry and the link carries the focus ring
   and the motion. Affordances follow the site's other icon links: lift and
   tint on hover, 2px ink outline on keyboard focus. */
.gh-exp-logo-link {
  flex: none;
  display: block;
  border-radius: 10px;
  text-decoration: none;
  transition: transform 0.22s cubic-bezier(.2, .8, .2, 1);
}

.gh-exp-logo-link:hover { transform: translateY(-2px); }
.gh-exp-logo-link:active { transform: scale(0.97); }

.gh-exp-logo-link:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.gh-exp-logo-link:hover .gh-exp-logo-wrap {
  box-shadow: 0 0 0 1px rgba(10, 10, 10, 0.28),
              0 6px 18px rgba(10, 10, 10, 0.10);
}

/* A rounded chip, mirroring .gh-school-badge (the emlyon mark in the lead).
   ONE fixed rectangle, identical for both companies, and each mark fills its
   full width.

   WIDTH-matched, not height-matched, and the measurement is what decided it.
   The two lockups are 3.5:1 and 4.3:1 and carry very different amounts of
   type per unit height. Normalised to a common HEIGHT, the Keolis wordmark
   renders 45% taller than the Swiss Life one — which reads as two different
   brands at two different sizes, and leaves the narrower mark floating in
   empty chip. Matched on WIDTH the gap drops to 15% and neither mark has
   side space, which is the whole point of the frame.

   The remaining 15% is inherent: the Swiss Life lockup is a two-line stack
   and the Keolis one is a single word, so they cannot both match on width
   AND type size. Closing it needs the Canva source, not this rule.

   The fixed height also keeps the header block — and therefore the band it
   has to fit inside — deterministic. 89px inner is the Swiss Life mark at
   its natural 3.47:1, and the taller mark decides the box.

   Sized down 20% from 126x46 at Gaspard's request so the header reads as
   metadata rather than a second title. The chip, its padding and the header's
   min-height all scale together — the band arithmetic above depends on the
   header height, so they cannot move independently. */
.gh-exp-logo-wrap {
  flex: none;
  width: 101px;
  height: 37px;
  padding: 5px 6px;
  border-radius: 9px;
  overflow: hidden;
  background: var(--paper);
  box-shadow: 0 0 0 1px var(--hair);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* Width-driven so each mark spans the frame. max-height contains the taller
   one rather than distorting it. */
.gh-exp-logo {
  width: 100%;
  height: auto;
  max-height: 100%;
  object-fit: contain;
  display: block;
}

.gh-exp-titles {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.gh-exp-title {
  margin: 0;
  font-family: var(--font-serif);
  font-weight: 400;
  font-size: 17px;
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The role is the quiet half of the title block: sans, muted, letterspaced —
   the UI voice, against the serif company name. Small caps shape without
   the tiny-caps size, which would fall under the band's budget. */
.gh-exp-role {
  font-family: var(--font-sans);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--num);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The top margin here is part of the band arithmetic, not a rhythm choice.
   --gh-exp-band cuts the covered card at a fixed height, and this line has to
   start BELOW that cut or the covering card slices through the middle of the
   letters — which reads as a rendering glitch rather than a deliberate edge.
   Desktop: 18px padding + 46px header + 32px = 96px, comfortably past the
   76px band. Mobile: 14 + 40 + 42 = 96px, past the same 76px band. If the band,
   the card's top padding, the header's min-height or this margin change, the
   other three have to be re-checked. */
.gh-exp-meta {
  margin: 32px 0 0;
  font-family: var(--font-sans);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--num);
}

.gh-exp-dot { margin: 0 6px; }

.gh-exp-points {
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
}

.gh-exp-points li {
  position: relative;
  padding-left: 18px;
  font-family: var(--font-serif);
  font-size: 15px;
  line-height: 1.55;
  color: var(--ink);
}

.gh-exp-points li + li { margin-top: 10px; }

/* A hairline rule rather than a bullet glyph: the theme has no bullets
   anywhere else, and the em dash already does this job in the copy. */
.gh-exp-points li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.72em;
  width: 9px;
  height: 1px;
  background: var(--num);
}

/* ---- Workplace photo -------------------------------------------- */
/* Closes the card on an image instead of on white space. Bleeds through the
   card's padding so it runs edge to edge, with the bottom corners rounded to
   match the card's.

   aspect-ratio here is the LIST state's height, not a design choice: without
   flex to stretch it, a wrap holding only an absolutely positioned image has
   no height at all and the photo would vanish for no-JS and reduced-motion
   readers. The armed rule below hands the sizing back to flex. */
.gh-exp-photo-wrap {
  position: relative;
  aspect-ratio: 3.5 / 1;
  margin: var(--gh-exp-photo-gap) -26px -24px;
  border-radius: 0 0 15px 15px;   /* 15 = the card's 16px less its 1px border */
  overflow: hidden;
}

/* Absolutely positioned so it fills the wrap at whatever height it is given,
   and `cover` so the two sources — both pre-cropped to one format — are never
   stretched out of proportion when the box's ratio differs from theirs. */
.gh-exp-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* A viewport with no vertical room to spare leaves the photo a sliver, which
   reads as a rendering fault rather than a design. It steps aside instead —
   the card keeps its white space, which is what it had before. */
@media (max-height: 680px) {
  .gh-exp-photo-wrap { display: none; }
}

.gh-exp-armed .gh-exp-photo-wrap {
  /* The photo takes the room left under the copy, but is CAPPED, and both
     halves of that matter.

     Grow: the two cards hold different amounts of text — Keolis three bullets
     and far more wrapping, Swiss Life two — so a fixed height would clip the
     longer card or leave the shorter one short.

     Cap: uncapped, the taller card hands the photo a portrait box. Measured
     on a 390x844 phone, the Swiss Life photo reached 270x314 — ratio 0.86 —
     and `cover` then crops a landscape source down to its middle. 200px keeps
     it landscape on both cards at every width they render at (ratio 3.6 on a
     720px card, 1.4 on a 270px one).

     margin-top:auto only has an effect once the cap bites, and then it pins
     the photo to the card's bottom so the slack sits above it rather than
     below. */
  flex: 1 1 auto;
  aspect-ratio: auto;
  max-height: 200px;
  min-height: 0;
  margin-top: auto;
}

/* ---- Armed: the stack -------------------------------------------- */
/* Everything below applies only once main.js has measured and is driving the
   transforms. It arms by putting gh-exp-armed on <html>, NOT on the section:
   the runway rule is `.gh-exp-armed .gh-experience` and the section IS
   .gh-experience, so a descendant combinator cannot match it — arming the
   section would leave that one rule dead while every other armed rule (all
   genuine descendants) still applied. The failure is quiet and half-working:
   the stage pins, the cards overlay, but there is no runway to travel
   through. Measured: 960px armed-on-section vs 1520px armed-on-html. */

/* `display: block` here is load-bearing. This section carries .gh-screen,
   which sets `display:flex; align-items:center`. In the armed state ALL
   THREE stage children (heading, deck, cue) are absolutely positioned, so
   the stage has no in-flow content and, as a row flex item, resolves to
   zero width. The deck's `min(720px, calc(100% - 80px))` then computes
   against 0, the cards collapse to their padding, and the whole section
   renders blank behind a correct 190svh height — a total failure that still
   measures right. The same flex context also centres the 100svh stage
   inside the 190svh section, pushing its static top 45svh down; a top:0
   sticky box can only ever be pushed further down, so the stage would pin
   only from p = 0.5 and the first half of the runway would do nothing.

   `align-self: flex-start` does NOT fix this — measured: it restores the pin
   but leaves the stage 0 wide, i.e. still blank. */
.gh-exp-armed .gh-experience {
  display: block;
  /* One covering per gap between cards, then the hold. The stage pins for the
     whole of it, so the extra --gh-exp-dwell is scrolling on which the section
     does not move. */
  height: calc(100svh + (var(--gh-exp-card-count, 2) - 1) * var(--gh-exp-step) + var(--gh-exp-dwell));
}

/* A centred column — eyebrow, then deck — which is the same group shape
   .gh-screen centres on every other screen. overflow clips the cards waiting
   below. The cue is absolutely positioned, so it takes no part in this. */
.gh-exp-armed .gh-exp-stage {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

/* IN flow, deliberately, and this is a reversal of an earlier decision.

   The eyebrow used to be absolutely positioned because main.js derived each
   hidden card's offset from (viewport + card height) / 2 — arithmetic that
   is only correct while the deck sits exactly in the middle of the stage. An
   eyebrow in flow shifted the deck down and every hidden card peeked above
   the fold before its turn.

   main.js now MEASURES the deck's offset inside the stage instead, so the
   deck can sit anywhere and the offset stays correct. That removes the
   constraint, and the eyebrow can sit directly above the deck the way every
   other screen's eyebrow sits above its content. */
.gh-exp-armed .gh-exp-heading { flex: none; }

.gh-exp-armed .gh-exp-deck {
  position: relative;
  flex: none;
  width: 100%;
  max-width: 720px;
  height: var(--gh-exp-card-h);
  display: block;
}

/* All cards fill the deck, so they overlay in source order. */
.gh-exp-armed .gh-exp-card {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
}

/* ---- Hero scroll invitation -------------------------------- */
.gh-hero-scroll {
  position: fixed;
  bottom: 28px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 900;
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
  white-space: nowrap;
  color: var(--mut);
  font-family: var(--font-sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  user-select: none;
  pointer-events: none;
  transition: opacity 0.4s cubic-bezier(.2, .7, .2, 1), transform 0.4s cubic-bezier(.2, .7, .2, 1);
  animation: gh-hero-scroll-in 0.8s cubic-bezier(.2, .7, .2, 1) backwards;
  animation-delay: 0.4s;
}

.gh-hero-scroll-arrow {
  width: 10px;
  height: 10px;
  display: block;
  color: var(--mut);
  animation: gh-scroll-pulse 2.4s ease-in-out infinite;
}

.gh-hero-scroll--hidden {
  opacity: 0;
  transform: translate(-50%, 8px);
  pointer-events: none;
}

@keyframes gh-scroll-pulse {
  0%, 100% { transform: translateY(0); opacity: 0.7; }
  50% { transform: translateY(3px); opacity: 1; }
}

@keyframes gh-hero-scroll-in {
  from {
    opacity: 0;
    transform: translate(-50%, -8px);
  }
  to {
    opacity: 1;
    transform: translate(-50%, 0);
  }
}

/* ---- Scroll-progress hairline ------------------------------ */
#gh-scroll-progress {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 950;
  width: 100%;
  height: 1.5px;
  background: var(--ink);
  transform-origin: 0 50%;
  transform: scaleX(0);
  pointer-events: none;
  will-change: transform;
}

/* ============================================================
   Mobile
   ============================================================ */
@media (max-width: 600px) {
  /* Compact everything so each screen (content + arrows) fits even the small
     toolbar-shown viewport of an iPhone SE — no overflow, no hidden arrows. */

  .gh-screen { padding: 20px 26px; }


  /* Narrower on mobile so the cursive "g" descender can never reach the left
     edge — safe even if the JS auto-fit doesn't run (e.g. an iOS quirk). */
  .gh-name { font-size: clamp(28.8px, 12.1vw, 50.4px); }

  /* Tagline: keep the (longer) French line inside 320px viewports */
  .gh-hero-tag { font-size: 13.5px; gap: 0.45em; }

  /* Eyebrows: less space above/below (this is the big top margin) */
  .gh-eyebrow { margin-bottom: 16px; }
  .gh-eyebrow--tight { margin-bottom: 14px; }

  /* Projects eyebrow: clear the fixed CV download button in the top-left
     and vertically align with the button's center (button is 32px at top:18px) */
  #gh-p3 .gh-eyebrow { padding-left: 40px; padding-top: 6px; }

  /* Screen 2 — compact the two paragraphs.
     Target the body paragraphs only, NOT the .gh-eyebrow (keeps it 12px). */
  /* Class selectors (not #gh-p2 …) so the loop clones — ids stripped —
     get the same mobile sizing as the real screens (no visible re-adjust
     when the loop teleports back). */
  .gh-lead, .gh-body { font-size: 14.5px; line-height: 1.4; }
  .gh-lead { margin: 0 0 12px; }
  .gh-body + .gh-body { margin-top: 16px; }
  .gh-cta { font-size: 13px; margin-top: 22px; line-height: 1.5; }

  /* Screen 3 — projects: single column, compact terminal */
  .gh-intro { font-size: 15px; line-height: 1.4; margin-bottom: 20px; }
  .gh-proj {
    grid-template-columns: 1fr;
    gap: 18px;
    padding: 26px 0;
  }
  .gh-proj-desc { font-size: 14.5px; line-height: 1.5; }
  .gh-term-body { font-size: 10.5px; padding: 11px 12px 12px; }
  .gh-term-bar { padding: 8px 10px; }
  .t-row { gap: 3px; }

  /* Screen 4 — tighten */
  .gh-quote { margin: 0 auto 40px; white-space: normal; }
  .gh-signature { margin-top: 44px; }
  /* Screen 4 must still fit one iPhone-SE screen with the toolbar shown.
     Symmetric padding keeps the quote group centred in the viewport. */
  .gh-contact { padding-top: 46px; padding-bottom: 46px; }
  .gh-ask { margin: 56px 0 0; font-size: 12.5px; line-height: 1.45; }
  /* 12px of vertical padding (not 9) keeps the pill a 44px touch target —
     the theme's icon buttons are 32px, but this is the page's one action. */
  .gh-contact-actions { margin-top: 28px; }
  .gh-ask-btn { padding: 12px 18px; font-size: 12.5px; gap: 7px; }
  .gh-ask-btn svg { width: 12px; height: 12px; }
  .gh-btn-group-text {
    width: 175px;
    font-size: 11px;
    padding: 0 12px;
    min-height: 40px;
  }
  .gh-copy-btn {
    width: 40px;
    min-height: 40px;
  }
  .gh-btn-group.is-copied .gh-copy-btn {
    padding: 0 14px;
  }

  /* Language Switcher & Social Dock mobile adjustments */
  .gh-lang-switcher { top: 18px; right: 18px; }
  .gh-lang-btn { font-size: 10px; padding: 5px 9px; }

  /* Rails badge mobile adjustment (bottom left, same as desktop) */
  .gh-rails-badge { bottom: 5px; left: 5px; }
  .gh-rails-capsule { gap: 5px; padding: 5px 10px; }
  .gh-rails-label { font-size: 9px; }
  .gh-rails-logo { height: 10.4px; }

  /* CV FAB mobile adjustment (mirrors switcher corner) */
  .gh-cv { top: 18px; left: 18px; }
  .gh-cv-btn { width: 32px; height: 32px; }
  .gh-cv-btn svg { width: 16px; height: 16px; }

  /* Handwritten notes on small screens */
  .gh-hero-note { font-size: 15px; gap: 5px; }
  .gh-hero-note--cv { top: 60px; left: 28px; }
  .gh-hero-note--projects { bottom: 68px; right: 26px; }

  .gh-social-dock { bottom: 18px; right: 10px; }
  .gh-social-capsule { gap: 2px; padding: 3px; }
  .gh-social-btn { width: 32px; height: 32px; }
  .gh-social-btn svg { width: 16px; height: 16px; }

  .gh-hero-scroll {
    bottom: 18px;
    font-size: 8.5px;
    letter-spacing: 0.08em;
    gap: 3px;
  }

  /* Journey Capsule mobile adjustment */
  .gh-journey-capsule {
    gap: 5.04px;
    padding: 2.7px 8.1px;
  }
  .gh-journey-flag {
    width: 12.96px;
    height: 8.64px;
    border-radius: 2.25px;
  }
  .gh-journey-code {
    font-size: 6.84px;
  }
  .gh-journey-arrow svg {
    width: 6.48px;
    height: 6.48px;
  }

  /* Screen 4 — experience. The band stays at the desktop value and the role
     is allowed to WRAP, because on a 390px screen the Keolis wordmark and
     "Data Automation and Procurement Intern" cannot both fit on one line:
     the chip alone takes 108px of a 287px header, leaving 169px for a role
     that needs 221px. Shrinking the chip does not rescue it — even at 16px
     the wordmark is unreadable and the chip is still 82px.

     That makes the header one line on Swiss Life and two on Keolis, so the
     meta line's margin must clear the TALLER case. The 1-line case is the
     binding lower bound and is what the test asserts:
       1 line : 14 + 34 + 42 = 90px  > 76  ✓
       2 lines: 14 + 48 + 42 = 104px > 76  ✓
     and the header itself has to fit INSIDE the band: 14 + 48 = 62 < 76 ✓. */
  /* 600px, not the 440px this was: the text wraps far more on a phone (Keolis
     runs 407px of copy against 284px for Swiss Life) and at 440 the photo was
     left a 7px sliver — measured, not guessed. 640 gave both cards the full
     200px photo but opened a 124px gap above Swiss Life's, which is the white
     space this was meant to close. 600 halves that gap and still leaves both
     photos landscape. */
  :root { --gh-exp-band: 76px; --gh-exp-card-h: min(72svh, 600px); --gh-exp-photo-gap: 12px; }

  /* One padding declaration for both states — see the base rule. */
  .gh-exp-stage { padding: 20px 26px; }

  /* And the section must cancel it here too. This block's `.gh-screen` rule
     re-applies the same padding LATER in the file than the base
     `.gh-experience { padding: 0 }`, so at this breakpoint the section
     silently got 20px/72px back and the stage came unstuck 71px early —
     measured, stageTop was -71 where the covering completed. */
  .gh-experience { padding: 0; }

  .gh-exp-card { padding: 14px 18px 18px; border-radius: 14px; }
  .gh-exp-head { gap: 10px; min-height: 32px; }
  /* Same uniform rectangle as desktop, scaled for a 320px screen AND down
     20%: 70px of inner width renders the Swiss Life mark 20.2px tall and the
     Keolis one 16.1px. */
  .gh-exp-logo-wrap { width: 80px; height: 32px; padding: 3px 5px; border-radius: 8px; }
  /* The company name wraps here too. "Swiss Life Asset Managers" needs 163px
     and has 118px beside a 102px chip — and the chip cannot shrink further,
     because the Keolis wordmark alone needs 87px of it. Measured; the name
     ellipsised before this. The band above is sized for the taller header
     this produces (worst case 49px, inside 76). */
  .gh-exp-title {
    font-size: 14.5px;
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
  }
  /* Wrapping beats an ellipsis on a job title. The header grows to two lines
     on Keolis and the band above is sized for that; the ellipsis stays as the
     last-resort net for anything longer still. */
  .gh-exp-role {
    font-size: 9px;
    letter-spacing: 0.07em;
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
  }
  /* 14 padding + 34 header + 42 = 90px, past the 76px band — see the block
     note above for the two-line case. Tightening this slices the meta line
     at the band edge. */
  .gh-exp-meta { margin-top: 42px; font-size: 9.5px; }
  .gh-exp-points { margin-top: 12px; }
  .gh-exp-points li { font-size: 13px; line-height: 1.5; padding-left: 15px; }
  .gh-exp-points li + li { margin-top: 8px; }

  /* Bleed and rounding follow the mobile card's own padding and radius. */
  .gh-exp-photo-wrap { margin: var(--gh-exp-photo-gap) -18px -18px; border-radius: 0 0 13px 13px; }
}

/* Respect reduced-motion preferences */
@media (prefers-reduced-motion: reduce) {
  .gh-l, .gh-name-char, .gh-lang-switcher, .gh-social-dock, .gh-hero-journey, .gh-rails-badge, .gh-hero-tag, .gh-cv, .gh-hero-note { animation: none; }
  .gh-lang-btn, .gh-social-btn, .gh-social-btn svg, .gh-journey-step, .gh-rails-logo, .gh-rails-capsule, .gh-rails-badge, .gh-portrait, .gh-portrait img, .gh-cv-btn, .gh-cv-btn svg, .gh-cv-pop, .gh-cv-opt, .gh-hero-note, .gh-ask-btn, .gh-ask-btn svg, .gh-copy-btn, [data-reveal], .gh-exp-card, .gh-exp-logo-link { transition: none; }

  .gh-hero-scroll,
  .gh-hero-scroll-arrow {
    animation: none !important;
    transition: none !important;
  }

  .gh-journey-tooltip {
    transition: none !important;
  }

  /* The module never arms under reduced motion, so the cards are a static
     list and carry no transform. This is the backstop for the case where the
     reader turns the preference ON mid-session: the module has already armed
     and written inline transforms, and only !important can retire them. The
     whole armed geometry has to be unwound, or the reader gets a worse result
     than the animation they were trying to avoid.

     Every armed rule needs its counterpart here. Missing the card's
     `position: absolute; inset: 0` leaves the deck static and the cards
     resolving inset against the SECTION, so they stretch over each other —
     measured: both cards at the same top offset, fully overlapping. */
  .gh-exp-card { transform: none !important; }

  /* Height only. The padding needs no unwinding because it lives on the
     stage in both states, so it is already correct at every breakpoint. */
  .gh-exp-armed .gh-experience { height: auto; }

  /* The armed stage is a 100svh flex column; hand it back to plain flow. The
     heading needs no unwinding — its only armed rule is `flex: none`, which
     is inert once the stage is a block — and overriding its margin here would
     undo the base rule's horizontal centring of the 720px column. */
  .gh-exp-armed .gh-exp-stage {
    position: static;
    display: block;
    height: auto;
    overflow: visible;
  }

  /* Back to the plain stacked list: flex column with the gap, cards in flow. */
  .gh-exp-armed .gh-exp-deck {
    position: static;
    display: flex;
    transform: none;
    height: auto;
    width: 100%;
  }
  .gh-exp-armed .gh-exp-card {
    position: relative;
    inset: auto;
    display: block;
  }

  /* The photo has to be unwound with everything else. Its armed rule sets
     `aspect-ratio: auto`, and once the card is back to `display: block` there
     is no flex to stretch it — so the wrap, whose only child is absolutely
     positioned, collapses to zero and the photo vanishes. Measured before
     this rule existed: 719x0 on both cards. */
  .gh-exp-armed .gh-exp-photo-wrap {
    flex: none;
    aspect-ratio: 3.5 / 1;
    max-height: none;
    margin-top: var(--gh-exp-photo-gap);
  }
}
