/* ════════════════════════════════════════════════════════════════
   CYCLE — pitch deck
   Four fixed-viewport slides over one continuous rainforest
   panorama that pans a viewport-width per slide.
   Layout follows Knaflic: one takeaway per slide, decluttered,
   a single focal point carried by colour and scale.
   ════════════════════════════════════════════════════════════════ */

:root {
  color-scheme: only dark;

  /* Forest palette */
  --deep:    #0B2015;
  --forest:  #12331F;
  --accent:  #8FE3B4;
  --accent-d:#2D6A4F;

  --ink:     #F2FAF3;
  --ink-dim: rgba(242, 250, 243, .84);
  --ink-mut: rgba(242, 250, 243, .66);

  /* Dark neumorphism: same dual-shadow logic as preqal.org, tuned
     for a translucent surface floating on photography. */
  --surf:    rgba(16, 42, 27, .50);
  --surf-2:  rgba(16, 42, 27, .66);
  --neu:     7px 7px 18px rgba(0, 0, 0, .42), -5px -5px 14px rgba(255, 255, 255, .07);
  --neu-sm:  4px 4px 11px rgba(0, 0, 0, .38), -3px -3px 8px rgba(255, 255, 255, .06);
  --neu-in:  inset 3px 3px 8px rgba(0, 0, 0, .46), inset -2px -2px 6px rgba(255, 255, 255, .06);
  --blur:    blur(9px) saturate(115%);

  --radius:  18px;
  --radius-sm: 12px;

  /* Chrome reserves */
  --chrome-t: clamp(52px, 8vh, 78px);
  --chrome-b: clamp(48px, 7vh, 68px);
  /* Space for the nav arrows — derived from the arrows themselves, not
     guessed. An arrow's inner edge is its offset plus its width:
     clamp(8px,1.6vw,26px) + clamp(40px,5.6vw,60px), i.e. 7.2vw once
     both are in their fluid range. The old 7vw rail was therefore
     always a shade NARROWER than the arrows it was meant to clear,
     which is why content kept landing 2px underneath them. +16px is
     the visible gap; the 58px floor covers small screens, where the
     arrows stop shrinking and sit at a fixed 48px inner edge. */
  --rail:     clamp(58px, calc(7.2vw + 16px), 104px);
  --gut:      clamp(18px, 3.4vw, 56px);

  /* Fluid type keyed to vmin: one scale that behaves in both
     orientations. The JS shrink-to-fit handles the extremes. */
  --fs-eyebrow: clamp(14px, 2.3vmin, 22.5px);
  /* one headline size for every slide — there is no h2 step any more */
  --fs-h1:      clamp(37px, 9.77vmin, 108px);
  /* This carried a ~15% uplift to compensate for EB Garamond's small
     x-height. The lede is set in Montserrat now, so that uplift no
     longer buys anything optically — it is kept only because this
     value (via --fs-standfirst) is the agreed size of slides 1-3's
     standfirst text, and changing it would change that. */
  --fs-lede:    clamp(17.5px, 3.1vmin, 29px);
  --fs-body:    clamp(13.5px, 2vmin, 18px);
  --fs-small:   clamp(12.5px, 1.8vmin, 15.5px);
  --fs-stat:    clamp(30px, 6.8vmin, 66px);
  --fs-chip:    clamp(11px, 1.6vmin, 14.5px);

  /* Step colours for the cycle diagram — sampled from the CYCLE logo */
  --c1: #D9A62B;   /* gold   */
  --c2: #D13030;   /* red    */
  --c3: #2E6FBF;   /* blue   */
  --c4: #2FA8D1;   /* cyan   */
  --c-green: #2FA06C;
}

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

html, body {
  height: 100%;
  background: var(--deep);
}

body {
  font-family: 'Montserrat', system-ui, -apple-system, 'Segoe UI', sans-serif;
  color: var(--ink);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  overflow: hidden;                 /* the deck never scrolls */
  overscroll-behavior: none;
}
html.js body { position: fixed; inset: 0; }

img { max-width: 100%; display: block; }
em { font-style: normal; color: var(--accent); }

/* ── Accessibility ─────────────────────────────────────────────── */
.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 60;
  background: var(--accent); color: var(--deep);
  padding: 12px 20px; font-weight: 700; text-decoration: none;
  border-radius: 0 0 var(--radius-sm) 0;
}
.skip-link:focus { left: 0; }

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

.sr-live {
  position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}

/* ── The panning panorama ──────────────────────────────────────── */
.stage { position: fixed; inset: 0; z-index: 0; overflow: hidden; }

/* Ken Burns: the forest breathes, starting zoomed in and easing out
   over 26s, then drifting back. Separate layer so it composes with
   the pan's own translate. Slow enough to stay ambient, wide enough
   that the scene reads as alive rather than still. */
.zoom {
  position: absolute; inset: 0;
  animation: stage-breathe 26s ease-in-out infinite alternate;
  will-change: transform;
}
@keyframes stage-breathe {
  from { transform: scale(1.21); }
  to   { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .zoom { animation: none; }
}

.pan {
  position: absolute; top: 0; left: 0;
  width: 400%; height: 100%;
  background: var(--deep) url('assets/img/pano.jpg') center / cover no-repeat;
  /* percentage translate is of the element's own width: 25% = one viewport */
  transform: translate3d(calc(var(--i, 0) * -25%), 0, 0);
  transition: transform 900ms cubic-bezier(.62, .02, .18, 1), opacity .7s ease;
  will-change: transform;
  opacity: 0;
}
.pan.is-ready { opacity: 1; }
html:not(.js) .pan { opacity: 1; }
@media (max-width: 800px) {
  .pan { background-image: url('assets/img/pano-sm.jpg'); }
}

/* Scrim keeps white text ≥4.5:1 even over the brightest sky in the photo */
.scrim {
  position: absolute; inset: 0;
  background:
    /* deepest where the text sits, opening up over the forest on the right */
    linear-gradient(100deg, rgba(7, 24, 15, .92) 0%, rgba(7, 24, 15, .80) 38%,
                            rgba(7, 24, 15, .52) 66%, rgba(7, 24, 15, .38) 100%),
    linear-gradient(to bottom, rgba(7, 24, 15, .55) 0%, rgba(7, 24, 15, .12) 36%,
                               rgba(7, 24, 15, .16) 62%, rgba(7, 24, 15, .60) 100%);
}
/* Slides that carry full-width content need an even field behind them */
body:has(#model.is-active) .scrim,
body:has(#impact.is-active) .scrim,
body:has(#contact.is-active) .scrim {
  /* lighter than it looks: two stacked gradients multiply, so .84/.12
     still composites to ~86% dark behind text while leaving enough
     forest visible for the Ken Burns drift to register */
  background:
    linear-gradient(100deg, rgba(7, 24, 15, .84) 0%, rgba(7, 24, 15, .72) 50%, rgba(7, 24, 15, .60) 100%),
    linear-gradient(to bottom, rgba(7, 24, 15, .46) 0%, rgba(7, 24, 15, .10) 40%, rgba(7, 24, 15, .48) 100%);
}
.scrim { transition: background .6s ease; }

/* ── Persistent chrome ─────────────────────────────────────────── */
.chrome-top, .chrome-bottom {
  position: fixed; left: 0; right: 0; z-index: 30;
  display: flex; align-items: center; justify-content: space-between;
  gap: 16px;
  padding-inline: var(--gut);
  pointer-events: none;
}
.chrome-top    { top: 0;    height: var(--chrome-t); padding-top: env(safe-area-inset-top); }
.chrome-bottom { bottom: 0; height: var(--chrome-b); padding-bottom: env(safe-area-inset-bottom); }
.chrome-top a, .chrome-bottom a, .chrome-bottom button { pointer-events: auto; }

.brand { display: flex; align-items: center; gap: 11px; text-decoration: none; color: inherit; }
.brand-mark {
  width: clamp(38px, 5.6vh, 54px); aspect-ratio: 1; border-radius: 50%;
  background: rgba(255, 255, 255, .93); box-shadow: var(--neu-sm);
  display: grid; place-items: center; flex-shrink: 0; overflow: hidden;
}
.brand-mark img { width: 80%; height: 80%; object-fit: contain; }
.brand-name {
  display: block; font-weight: 800; letter-spacing: .1em;
  font-size: clamp(16px, 2.4vh, 21px); line-height: 1.15;
}
.brand-sub {
  display: block; font-size: clamp(9.5px, 1.3vh, 12px); font-weight: 600;
  letter-spacing: .1em; text-transform: uppercase; color: var(--ink-mut);
}
/* Desktop masthead 20% larger — logo, name and strapline together, so
   the lockup scales as one rather than drifting apart. Desktop only:
   the strapline is hidden on phones anyway, and the header's own
   height reserve (--chrome-t) is tighter there. */
@media (min-width: 901px) {
  .brand { gap: 13px; }
  .brand-mark { width: clamp(45.6px, 6.72vh, 64.8px); }
  .brand-name { font-size: clamp(19.2px, 2.88vh, 25.2px); }
  .brand-sub { font-size: clamp(11.4px, 1.56vh, 14.4px); }
}

.counter {
  font-size: clamp(13px, 1.8vh, 16px); font-weight: 700;
  letter-spacing: .12em; color: var(--ink-mut);
}
.counter #counter-now { color: var(--accent); }
.counter-sep { margin: 0 3px; opacity: .5; }

/* ── Deck + slides ─────────────────────────────────────────────── */
.deck { position: fixed; inset: 0; z-index: 10; }

.slide {
  position: absolute; inset: 0;
  /* align-items:start, not center: when the unscaled box is taller than the
     area the browser clamps centring to start anyway, which threw the
     shrink-to-fit maths off. JS re-centres via --fit-y instead. */
  display: grid; align-items: start; justify-items: center;
  /* minmax(0,1fr), not the implicit auto-sized column: an auto track
     grows to fit a descendant's intrinsic width, and .team's 90vw
     breakout (below) has exactly that — it was inflating .slide-inner
     itself and desyncing every element's centring on the slide, not
     just the carousel. 1fr pins the track to the available space. */
  grid-template-columns: minmax(0, 1fr);
  padding: calc(var(--chrome-t) + 4px) var(--rail) calc(var(--chrome-b) + 4px);
  opacity: 0; visibility: hidden;
  transition: opacity .46s ease, visibility 0s .46s;
}
.slide.is-active {
  opacity: 1; visibility: visible;
  transition: opacity .46s ease .12s, visibility 0s;
}

.slide-inner {
  width: min(100%, 1180px);
  /* flow-root: without a BFC the children's margins collapse through this
     box, so scrollHeight under-reports and the fit maths lets content
     spill into the chrome. */
  display: flow-root;
  /* position:relative isn't for positioning here — it just makes this
     the reliable offsetParent for .slide-body, so JS can read "how much
     vertical space did the eyebrow+headline consume" as a plain
     offsetTop instead of hand-summing heights and margins. */
  position: relative;
}

/* Wraps everything after the eyebrow+headline (built at runtime, see
   app.js) so it can shrink independently of them. Previously --fit
   scaled the ENTIRE slide-inner as one block from its top-left corner,
   which meant the eyebrow+headline rendered at a different size AND a
   different vertical starting point on every slide, purely because of
   how much THAT slide's body content happened to need to shrink —
   About's oversized lede+carousel forced far more shrink than
   Contact's compact form, so the "page header" looked inconsistent
   slide to slide despite sharing identical CSS. Scaling only the body
   keeps the header at its natural, CSS-intended size and position on
   every slide, and the body still shrinks (from its own top-left, same
   reasoning as the old slide-inner scale) to whatever room is left
   below it. */
.slide-body {
  display: flow-root;
  transform: scale(var(--body-fit, 1));
  transform-origin: top left;
}
/* The entrance animation must never land on .slide-body itself: `rise`
   animates `transform`, and an animated transform REPLACES the
   element's own `transform: scale(var(--body-fit))` for as long as it
   runs. That silently un-scaled the body mid-animation — which is what
   made fit()'s old getBoundingClientRect check read a full-size box,
   see phantom overflow, and shrink a correctly-fitted slide to ~0.53
   of its proper size on every return to it. The body's CHILDREN carry
   the stagger instead, so the effect is unchanged visually. */
/* :not(.has-entered) — the entrance plays once per slide, on its first
   reveal, not every time you navigate back to it. Revisiting a slide
   used to replay it, which lifted the eyebrow and headline 14px and
   settled them again: a visible twitch in the hero text every single
   time you came back to a slide you had already seen. */
.slide.is-active:not(.has-entered) .slide-inner > .eyebrow,
.slide.is-active:not(.has-entered) .slide-inner > .headline,
.slide.is-active:not(.has-entered) .slide-body > * { animation: rise .5s cubic-bezier(.2, .7, .3, 1) backwards; }
.slide.is-active:not(.has-entered) .slide-inner > .headline { animation-delay: .06s; }
.slide.is-active:not(.has-entered) .slide-body > *:nth-child(1) { animation-delay: .12s; }
.slide.is-active:not(.has-entered) .slide-body > *:nth-child(2) { animation-delay: .18s; }
.slide.is-active:not(.has-entered) .slide-body > *:nth-child(3) { animation-delay: .24s; }
.slide.is-active:not(.has-entered) .slide-body > *:nth-child(n+4) { animation-delay: .3s; }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } }

/* ── Shared slide typography ───────────────────────────────────── */
.eyebrow {
  display: flex; align-items: center; gap: 10px;
  font-size: var(--fs-eyebrow); font-weight: 700;
  letter-spacing: .2em; text-transform: uppercase; color: var(--ink-mut);
  margin-bottom: clamp(8px, 1.7vmin, 18px);
}
.eyebrow .num {
  color: var(--deep); background: var(--accent);
  padding: 2px 7px; border-radius: 4px; letter-spacing: .08em;
}

.headline {
  font-size: var(--fs-h1); font-weight: 800; line-height: 1.04;
  letter-spacing: -.02em;
  max-width: 17ch;
  text-wrap: balance;
  text-shadow: 0 2px 24px rgba(0, 0, 0, .38);
}
/* Every slide's headline is the SAME size (--fs-h1) — the other three
   used to step down to --fs-h2, which read as the deck's "page header"
   changing size slide to slide. Only max-width differs, and that's a
   wrapping constraint (these headlines are longer than About's), not a
   size one. */
.slide:not(#about) .headline { max-width: 22ch; }

/* Break the contact headline where the sense divides, not where
   text-wrap:balance would put it. Only once there's room for the
   first clause on one line; narrower screens wrap naturally. */
/* Two kinds of authored break: .lb is the desktop break, .br-m the
   phone breaks. The slide-4 break after "research it," carries BOTH
   classes — it is wanted in each layout, for different reasons — and
   the cascade resolves it: this base rule hides both, the min-width
   block below re-shows .lb, the max-width block re-shows .br-m, and
   in the 701-900px band neither fires so the text wraps naturally. */
.headline .lb, .headline .br-m { display: none; }
@media (min-width: 901px) {
  .headline .lb { display: inline; }
  .s-contact .headline { text-wrap: wrap; max-width: 26ch; }
}
@media (max-width: 700px) {
  .headline .br-m { display: inline; }
}

.lede {
  /* Montserrat (inherited), not the serif this used to set: the lede
     shares the standfirst role with slide 3's closing note, which has
     always inherited the sans, so they now match in face as well as
     size. EB Garamond is still loaded and used — see .sign-off. */
  font-size: var(--fs-lede); line-height: 1.55; color: var(--ink-dim);
  max-width: 60ch;
  margin-top: clamp(10px, 2.3vmin, 24px);
  text-shadow: 0 1px 14px rgba(0, 0, 0, .34);
}
.lede strong { color: var(--ink); font-weight: 600; }
.lede-tight { margin-top: clamp(6px, 1.4vmin, 14px); max-width: 52ch; }

.footnote {
  /* same standfirst size and leading as slides 1 and 2's opening lines */
  font-size: var(--fs-standfirst); line-height: 1.34;
  color: var(--ink-mut); max-width: 62ch;
  margin-top: clamp(8px, 2vmin, 20px);
}

/* ── Chips ─────────────────────────────────────────────────────── */
.chip-row {
  display: flex; flex-wrap: wrap; gap: clamp(6px, 1.2vmin, 12px);
  list-style: none;
  margin-top: clamp(12px, 3vmin, 32px);
}
.chip {
  font-size: var(--fs-chip); font-weight: 700;
  letter-spacing: .07em; text-transform: uppercase;
  padding: clamp(6px, 1.1vmin, 11px) clamp(11px, 1.8vmin, 19px);
  border-radius: 999px;
  background: var(--surf); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  box-shadow: var(--neu-sm);
  color: var(--ink-dim);
}
.chip-row-sm { margin-top: clamp(10px, 2.2vmin, 22px); }
.chip-row-sm .chip { text-transform: none; letter-spacing: .02em; font-weight: 600; }

/* ── 01 · team carousel ────────────────────────────────────────── */
:root {
  /* +15% over the base row to hold the larger member text */
  --tm-h:   clamp(188px, 30vh, 215px);
  --tm-gap: clamp(8px, 1.4vmin, 14px);
}
/* Desktop rows are shorter than that base. The carousel is the only
   thing making slide 1 taller than its slot (measured at 1440x900:
   593px of carousel against a 523px budget, while the lede beside it
   needs just 442px), and being over that budget is what forced slide
   1's body to scale down — which in turn rendered its opening line
   visibly smaller than the identically-sized opening line on slide 2.
   Sizing the three rows to fit the slot keeps every slide's body type
   at its true CSS size instead. Three rows still show: one faded, one
   highlighted, one faded. */
@media (min-width: 901px) {
  :root { --tm-h: clamp(166px, 20vh, 202px); }
}

.about-cols {
  /* carousel column widened 30% (.58fr -> .75fr of the same 2fr total).
     minmax(0, Nfr) on both tracks: a bare fr track grows to fit its
     content's intrinsic size when that content is wider than the track,
     and .team's full-bleed breakout is exactly that. (These two tracks
     used to be deliberately asymmetric to accommodate per-element
     inline width corrections in fit(); those are gone — the body is
     widened as a whole now — so both can be protected again.) */
  display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, .75fr);
  gap: clamp(28px, 4.5vmin, 72px);
  align-items: center;
  margin-top: clamp(10px, 2.2vmin, 24px);
}
/* Ranged left, not justified: the other two standfirst texts have
   always been left-aligned, so justifying this one made the same role
   look different slide to slide — and in a narrow phone column
   justification opens visible rivers between words. */
.about-cols .lede { margin-top: 0; }
/* The deck's standfirst size: the opening statement on slides 1 and 2
   and the closing note on slide 3 all share it, so the same role reads
   at the same size wherever it appears. One token rather than three
   rules, so they can't drift apart. Multipliers taper on compact
   screens, where the columns stack and the full step would push the
   slide past the shrink-to-fit floor into the chrome. */
:root { --fs-standfirst: calc(var(--fs-lede) * 1.5925); }
/* These two steps are set by what slide 1 can actually hold, not by
   taste: the three standfirst texts only render at the same size when
   slide 1 fits its slot without the auto-shrink kicking in, and on a
   stacked phone layout its opening line sits above a full-width
   carousel rather than beside it. Sized so slide 1 lands at scale 1
   and all three therefore render identically. */
@media (max-width: 900px), (max-height: 600px) {
  :root { --fs-standfirst: calc(var(--fs-lede) * 1.02); }
}
@media (max-width: 420px), (max-height: 430px) {
  :root { --fs-standfirst: calc(var(--fs-lede) * 0.9); }
}
/* 1.34, not the 1.42 the smaller lede uses: leading should tighten as
   type grows, and at this size 1.42 both read loose and made slide 1's
   opening line the tallest thing on that slide (505px against a 523px
   budget once the carousel was sized down), which would have forced
   the whole body to scale — the exact thing that made this line render
   smaller than slide 2's identical one. */
.s-about .lede,
.s-model .lede { font-size: var(--fs-standfirst); line-height: 1.34; }

.team-h {
  /* Sized to span the carousel exactly, on one line. Measured: this
     string at weight 700 with .16em tracking, uppercased, is 19.72x
     its own font size wide, so dividing the container's inline size
     by a whisker more than that fills the width and leaves a hair of
     slack. A container query, not vw: the carousel is 90dvw on
     phones but a grid column on desktop, and only the container knows
     which. The clamp is a guard against absurdity, not a design
     bound - it is set wide enough never to bind in practice, because
     if it binds the heading stops spanning. */
  font-size: clamp(10px, calc(100cqw / 19.9), 80px);
  white-space: nowrap;
  font-weight: 700;
  letter-spacing: .16em; text-transform: uppercase; color: var(--ink-mut);
  /* Deep enough that the up chevron, which straddles the viewport's
     top edge, clears this line instead of clipping the "15". */
  margin-bottom: clamp(17px, 2.4vmin, 24px);
}

/* Two rows tall — see the height note on .team-vp. */
.team-vp {
  /* Two rows, not three. The cards had to grow by half to fit a whole
     bio, and at three rows that made the carousel 156px taller, which
     the slide's shrink-to-fit paid for by scaling ALL of slide 1 down
     about 15% - the bio fitted but everything got harder to read. Two
     of the taller rows occupy 314px against the 320px three short ones
     used, so the slide is back to its original scale and the bio still
     fits. The cost is seeing two members at a time instead of three;
     the list scrolls continuously, so none are hidden. */
  height: calc(2 * var(--tm-h) + 1 * var(--tm-gap));
  /* Negative margin widens the clipping box, the equal padding puts
     the rows back where they were — so card width is untouched (and
     the bios stay inside their line budget) while the selected card's
     scale-up has somewhere to bleed instead of having its rounded
     corners sliced off by this overflow. Percentage rather than a
     flat 8px: the bleed a scale-up needs is proportional to card
     width (1.25% each side), so a fixed value that suited a 335px
     phone card fell 5px short on an 810px one and sliced it. */
  margin-inline: -2%;
  padding-inline: 2%;
  overflow: hidden;
  /* gentle edge fade only: the outer rows stay whole, never clipped */
  -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,.55), #000 14%, #000 86%, rgba(0,0,0,.55));
  mask-image: linear-gradient(to bottom, rgba(0,0,0,.55), #000 14%, #000 86%, rgba(0,0,0,.55));
}
.team-list {
  list-style: none;
  display: flex; flex-direction: column;
  gap: var(--tm-gap);
  will-change: transform;
}

.tm {
  /* Held in variables because the LinkedIn panel has to cancel the
     vertical padding to reach the card's top and bottom edges. */
  --tm-pad-y: clamp(8px, 1.3vmin, 12px);
  --tm-pad-x: clamp(10px, 1.5vmin, 15px);
  position: relative;   /* anchors the one-shot shine on selection */
  height: var(--tm-h); flex-shrink: 0; overflow: hidden;
  display: flex; gap: clamp(12px, 2vmin, 20px); align-items: center;
  /* No right padding: the LinkedIn panel runs to the card's own edge,
     which also makes its 18% resolve against very nearly the full card
     width rather than an inset content box. */
  padding: var(--tm-pad-y) 0 var(--tm-pad-y) var(--tm-pad-x);
  border-radius: var(--radius-sm);
  opacity: .30; filter: saturate(.75);
  transition: opacity .5s ease, filter .5s ease, background .5s ease,
              transform .45s cubic-bezier(.22, 1, .36, 1), box-shadow .45s ease;
}
.tm-mid {
  opacity: 1; filter: none;
  background: var(--surf); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  /* Lifted and deepened while selected, back to flat when not — the
     transition on .tm carries it both ways, so deselection is the
     same move in reverse rather than a snap. */
  transform: scale(1.025);
  box-shadow: 0 12px 30px rgba(0, 0, 0, .46), 0 3px 8px rgba(0, 0, 0, .32),
              inset 0 1px 0 rgba(255, 255, 255, .07);
}
/* One-shot shine, fired by selection. The overlay only EXISTS while
   .tm-mid is on the card, so the animation runs once when the class
   lands and is gone when it leaves — no reset logic, and re-selecting
   the same member later plays it again. app.js toggles the class with
   classList.toggle(name, force), which is a no-op when the state is
   unchanged, so the ~10Hz highlight pass does not retrigger it. */
.tm-mid::after {
  content: "";
  position: absolute;
  top: -50%; bottom: -50%; left: -60%; right: -60%;
  background: linear-gradient(
    100deg,
    transparent 42%,
    rgba(255, 255, 255, .12) 47%,
    rgba(255, 255, 255, .5) 50%,
    rgba(255, 255, 255, .12) 53%,
    transparent 58%
  );
  transform: translateX(-100%);
  /* 1.66s, from an original .95s: a 30% speed cut then a further 18%
     (.95 / 0.7 / 0.82), leaving the sweep at 57% of its first speed.
     Unlike the looping shine on the social icons — where a cycle is a
     crossing plus a rest and only the crossing reads as the "speed" —
     this is a single sweep with no hold, so the whole duration IS the
     crossing and scales directly. */
  animation: card-sheen 1.66s cubic-bezier(.4, 0, .3, 1) 1 both;
  pointer-events: none;
  z-index: 3;
}
@keyframes card-sheen {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
  .tm-mid { transform: none; }
  .tm-mid::after { animation: none; opacity: 0; }
}
html:not(.js) .tm { opacity: 1; filter: none; height: auto; }
html:not(.js) .team-vp { height: auto; overflow: visible; -webkit-mask-image: none; mask-image: none; }

.tm-ava {
  flex-shrink: 0;
  width: clamp(96px, 13.5vmin, 122px); aspect-ratio: 1; border-radius: 50%;
  display: grid; place-items: center;
  background: rgba(255, 255, 255, .10);
  border: 3px solid var(--accent);
  color: rgba(242, 250, 243, .8);
  overflow: hidden;
}
.tm-ava svg { width: 72%; height: 72%; }
/* A real headshot fills the disc. The adjacent-sibling selector is what
   makes the fallback work without JS having to toggle a class: the
   silhouette is hidden only while an <img> actually precedes it, so
   app.js removing a broken <img> brings the silhouette straight back. */
.tm-ava img {
  grid-area: 1 / 1;
  width: 100%; height: 100%;
  object-fit: cover; object-position: center 22%;
  display: block;
}
.tm-ava img + svg { display: none; }
/* placeholder tint rotates through the logo palette */
.tm:nth-child(5n+1) .tm-ava { border-color: var(--c1); }
.tm:nth-child(5n+2) .tm-ava { border-color: var(--c2); }
.tm:nth-child(5n+3) .tm-ava { border-color: var(--c-green); }
.tm:nth-child(5n+4) .tm-ava { border-color: var(--c3); }
.tm:nth-child(5n)   .tm-ava { border-color: var(--c4); }

/* Avatar and tier badge as one column. */
.tm-id {
  flex-shrink: 0;
  align-self: stretch;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: clamp(3px, .5vmin, 6px);
}
/* The avatar is now sized off the CARD's height rather than the
   viewport. Every width-based clamp left a different amount of room
   underneath it - measured 8px at 768px, 12px at 560px, but 37px on a
   landscape phone - so any badge big enough to read at one breakpoint
   clipped at another. A share of the stretched column leaves the same
   proportion free everywhere. Two selectors deep so it outranks the
   four .tm-ava width clamps in the media queries below without
   depending on source order. */
/* Derived from --tm-h, not from a percentage of the stretched column.
   iOS Safari resolves a child's percentage height against a stretched
   flex item unreliably (treats it as indefinite), so height:66% fell
   to auto, aspect-ratio had nothing to bind, and the avatar took its
   content's intrinsic size — the img's 240px attribute on a photo
   card, the svg default elsewhere. The id column blew out to most of
   the card and crushed the text to nothing on real iPhones, while
   Chrome resolved the same percentage fine, which is why every local
   sweep passed. --tm-h minus the card's own padding is the same
   number the percentage was aiming at, computed from definite tokens. */
.tm-id .tm-ava {
  --tm-ava: calc((var(--tm-h) - 2 * var(--tm-pad-y)) * .66);
  width: var(--tm-ava);
  height: var(--tm-ava);
}

.tm-badge {
  flex-shrink: 0;
  font-size: clamp(8.5px, 1.05vmin, 10px);
  font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
  /* Wraps at EVERY width, not just narrow ones. On one line this pill
     is the widest thing in the card - 126px of a 425px card at 1920,
     nearly a third - which left the bio 196px for 17.8px type and sent
     it to six lines. Breaking after "EXECUTIVE" costs a few pixels of
     height and returns about 55px to the text column. */
  line-height: 1.15; white-space: normal; text-align: center;
  max-width: 13ch;
  padding: .38em .72em;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, .45);
  box-shadow: 0 1px 2px rgba(0, 0, 0, .35), inset 0 1px 0 rgba(255, 255, 255, .6);
}
/* The narrow bright band either side of 50% is what reads as metal. A
   flat tint, or a plain two-stop gradient, just reads as a coloured
   pill - the specular highlight is the whole effect. */
.tm-badge-exec {
  color: #3B2B06;
  background-image: linear-gradient(158deg, #7A5B12 0%, #C9A13D 17%, #F7E7AA 41%, #FFFAE4 50%, #EFD894 59%, #B7902E 82%, #6C4D0D 100%);
}
.tm-badge-carib {
  color: #23282B;
  background-image: linear-gradient(158deg, #6B7277 0%, #A8B1B7 17%, #E7EDF0 41%, #FFFFFF 50%, #DAE1E5 59%, #99A2A8 82%, #656C71 100%);
}
/* Below the wide layouts the pill is the widest thing in the card, not
   the avatar, and a single line of it was taking the text column down
   to 59px at 320px - narrower than the name it sits beside. Wrapping
   to two lines cuts the column to the width of "EXECUTIVE" and hands
   the difference back to the text. The avatar's share drops to make
   room for the second line, and the floor on the font size goes up:
   these are scaled down by the fit system, so the 8.5px base was
   reaching the eye at 5.4px. */
@media (max-width: 1100px) {
  .tm-badge { font-size: clamp(10px, 1.05vmin, 11px); }
  .tm-id .tm-ava { --tm-ava: calc((var(--tm-h) - 2 * var(--tm-pad-y)) * .56); }
}

/* Takes the slack so the LinkedIn link is pushed to the card's right
   edge. min-width:0 is load-bearing: without it a flex item refuses to
   shrink below its min-content width, and the long member names would
   push the link off the end of an overflow:hidden card. */
.tm-body { flex: 1 1 auto; min-width: 0; }

/* LinkedIn link, one per member. It is the row's third flex child
   rather than anything inside .tm-body, because the card has a fixed
   height and clips: content added to the text column would eat into
   the bio instead of sitting beside it. */
/* The whole right-hand 18% of the card is the LinkedIn target, rather
   than a small disc floating in it. Stretch plus negative vertical
   margins cancel the card's padding so the panel reaches all four of
   its own edges; the card's overflow:hidden and border-radius then
   clip its outer corners to the card's curve, so it needs no radius
   of its own. */
.tm-li {
  /* A flex basis resolves against the CONTENT box, which is the card
     minus its left padding, so a flat 18% measured 17.4-17.8% of the
     card itself. Content = card - padX, so 18% of the card is 18% of
     the content plus 18% of that padding. */
  flex: 0 0 calc(12% + var(--tm-pad-x) * .12);
  align-self: stretch;
  margin-block: calc(-1 * var(--tm-pad-y));
  display: grid; place-items: center;
  border-left: 1px solid rgba(255, 255, 255, .16);
  background: rgba(255, 255, 255, .07);
  color: var(--ink);
}
/* Sized off the panel so it tracks the card rather than the viewport,
   with a ceiling so it does not become a billboard on wide screens. */
.tm-li svg { width: min(46%, 30px); height: auto; fill: currentColor; }
@media (hover: hover) and (pointer: fine) {
  .tm-li { transition: background .2s ease, color .2s ease; }
  .tm-li:hover { background: var(--accent); color: var(--deep); }
}
/* Off-centre cards sit at 30% opacity, but their links are still
   tabbable. Lift the whole card while anything inside it holds focus,
   so a keyboard user never lands a focus ring on faded content. */
.tm:focus-within { opacity: 1; filter: none; }

.tm-name {
  /* +15% over the shared body token, scoped to the carousel only */
  font-size: calc(var(--fs-body) * 1.15); font-weight: 700; color: var(--ink);
  line-height: 1.25; margin-bottom: 3px;
}
.tm-role {
  /* Its own line, not a run trailing the name. Inline, a long title
     broke wherever the name happened to end - "Dr. Stefan Gravesande
     Co-" / "founder & President" - which reads as a typo rather than
     a title. A block puts the break where it belongs, and every card
     then has the same two-line shape regardless of name length. */
  display: block;
  font-weight: 600; color: var(--accent);
  font-size: .82em; letter-spacing: .04em;
  /* No nowrap. It kept the role from breaking beside the name, but it
     also made the whole title one unbreakable run, and the real
     titles are longer than the placeholders were: "Co-founder &
     Committee Member" ran 15px past the text column at 1024px, where
     "Co-founder & President" had always just fit. A role wrapping
     between words is ordinary; a card clipping its title is not. */
}
.tm-bio {
  font-size: calc(var(--fs-small) * 1.15); color: var(--ink-dim); line-height: 1.42;
  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
/* Desktop rows are shorter than the base (see the --tm-h override
   above), so the avatar and bio are sized to actually fit inside one.
   This has to sit AFTER the .tm-ava/.tm-bio rules it overrides: an
   earlier attempt lived up beside the --tm-h override, lost to the
   later base rule at equal specificity, and silently did nothing —
   leaving a 122px avatar and a 3-line bio needing 154px of row in a
   130px row, i.e. both visibly clipped. */
@media (min-width: 901px) {
  .tm-ava { width: clamp(74px, 9.8vmin, 96px); }
  .tm-bio { -webkit-line-clamp: 4; }
}

/* Step buttons: up above, down below, centred on the carousel */
/* container-type so .team-h can size itself against the carousel's own
   width. Safe here: both .about-cols tracks are already minmax(0, Nfr)
   precisely so this element's intrinsic width never drives the grid,
   which is the same independence inline-size containment asserts. */
.team { position: relative; container-type: inline-size; }
.team-btn {
  position: absolute; left: 50%; translate: -50% 0; z-index: 8;
  width: clamp(34px, 4.6vmin, 42px); aspect-ratio: 1;
  display: grid; place-items: center;
  border: none; border-radius: 50%; cursor: pointer;
  background: var(--surf-2); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  box-shadow: var(--neu-sm);
  color: var(--ink);
  transition: background .18s ease, transform .18s ease, color .18s ease;
}
.team-btn svg {
  width: 46%; height: 46%; fill: none;
  stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round;
}
.team-btn:hover { color: var(--accent); transform: translateX(0) scale(1.08); }
.team-btn:active { transform: scale(.94); box-shadow: inset 2px 2px 6px rgba(0, 0, 0, .4); }
/* Straddles the viewport's top edge, the way .team-down straddles the
   bottom. It used to sit fully INSIDE the viewport, landing 33-38px
   into the first card - directly on the member's name. Measured
   against the viewport's offset within .team (28px on phones rising
   to 47px at 1920) less half the button. */
.team-up { top: clamp(22px, 3.6vmin, 38px); }
.team-down { bottom: calc(clamp(34px, 4.6vmin, 42px) / -2); }
html:not(.js) .team-btn { display: none; }

/* Desktop: lede left, diagram right, same row, 50/50. On mobile this
   collapses back to stacked (see the compact media query below). */
.model-cols {
  display: grid; grid-template-columns: .92fr 1.08fr;
  align-items: center;
  gap: clamp(20px, 3.6vmin, 60px);
  margin-top: clamp(10px, 2.2vmin, 24px);
}
.model-cols .lede { margin-top: 0; }

/* ── 02 · the cycle infographic ────────────────────────────────── */
/* Diagram sits right within its column; open space to its left is
   where the step labels pop out. */
.cycle-area {
  position: relative;
  display: flex; justify-content: flex-end;
  /* shared with .cy-l below: .cy-labels is a SIBLING of .cycle-fig
     here, not nested inside it, so .cy-l's containing block is this
     element, not the ring. A var keeps the popover's right-edge anchor
     (cycle-fig's own left edge) in sync with the ring's width instead
     of hand-duplicating the clamp() and letting the two drift apart. */
  --cy-fig-w: clamp(253px, 50.6vmin, 462px);
}

.cycle-fig {
  position: relative;
  /* +10% over the original desktop size */
  width: var(--cy-fig-w);
  aspect-ratio: 1;
}
.cy-ring { position: absolute; inset: 0; width: 100%; height: 100%; }
.cy-swooshes { filter: drop-shadow(2px 3px 4px rgba(0, 0, 0, .38)); }

/* The rotating layer: swooshes + nodes spin, the core stays still.
   JS (WAAPI) drives the rotation so it can decelerate to a stop with
   step 01 back at the top-left; .cy-nt counter-rotates in sync so the
   numbers stay upright. */
.cy-spin { position: absolute; inset: 0; }
.cy-nt {
  display: grid; place-items: center;
  width: 100%; height: 100%; border-radius: 50%;
}

.cy-node {
  position: absolute;
  transform: translate(-50%, -50%);
  aspect-ratio: 1; border-radius: 50%;
  display: grid; place-items: center;
  border: 3px solid rgba(255, 255, 255, .92);
  font-family: inherit; font-weight: 800;
  letter-spacing: .02em; cursor: pointer;
  box-shadow: 4px 5px 13px rgba(0, 0, 0, .5), inset -2px -3px 6px rgba(0, 0, 0, .2),
              inset 2px 3px 6px rgba(255, 255, 255, .3);
  transition: transform .18s ease, box-shadow .18s ease;
}
.cy-node:hover { transform: translate(-50%, -50%) scale(1.07); }
.cy-node:active { transform: translate(-50%, -50%) scale(.96); }
.cy-node.is-open { box-shadow: 0 0 0 4px rgba(255, 255, 255, .35), 4px 5px 13px rgba(0, 0, 0, .5); }

/* staggered positions + sizes, mirroring the reference's rhythm */
.cy-n1 { left: 24.7%; top: 27.8%; width: 17.5%; background: var(--c1); color: var(--deep); font-size: clamp(13px, 2.5vmin, 23px); }
.cy-n2 { left: 70.3%; top: 23%;   width: 20.5%; background: var(--c2); color: #fff;        font-size: clamp(15px, 2.9vmin, 27px); }
.cy-n3 { left: 77.7%; top: 67%;   width: 20.5%; background: var(--c3); color: #fff;        font-size: clamp(15px, 2.9vmin, 27px); }
.cy-n4 { left: 28.8%; top: 74.3%; width: 18.5%; background: var(--c4); color: var(--deep); font-size: clamp(14px, 2.7vmin, 25px); }

.cy-core {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  display: grid; place-items: center; gap: 6px;
  text-align: center; pointer-events: none;
}
.cy-core img {
  width: clamp(46px, 9.6vmin, 84px); height: auto;
  background: rgba(255, 255, 255, .95); border-radius: 50%;
  padding: 6%;
  box-shadow: 5px 6px 14px rgba(0, 0, 0, .45);
}
.cy-word {
  font-weight: 800; letter-spacing: .26em; text-indent: .26em;
  font-size: clamp(14px, 2.5vmin, 23px);
}
.cy-word b:nth-child(1) { color: var(--c2); }
.cy-word b:nth-child(2) { color: var(--c1); }
.cy-word b:nth-child(3) { color: #4ECf95; }
.cy-word b:nth-child(4) { color: #6FA8E8; }
.cy-word b:nth-child(5) { color: var(--c4); }

.cy-labels { display: contents; list-style: none; }
/* Desktop: one fixed popover slot, vertically centred on the ring, in
   the open space to its left — whichever step is active shows here.
   A single steady position (not one computed per node) reads far
   more predictably than the ring's own numbers moving underneath it.
   Anchored with right:100% (not left:0): .cy-labels lives INSIDE
   .cycle-fig, so left:0 would sit at the ring's own left edge and the
   popover's width would extend rightward into the ring/nodes. right:100%
   plus a margin instead places it entirely in the free space that
   .cycle-area (flex, justify-content:flex-end) leaves to .cycle-fig's left. */
.cy-l {
  position: absolute; z-index: 5;
  width: min(165px, 13vw);
  right: var(--cy-fig-w); top: 50%;
  margin-right: clamp(10px, 1.6vmin, 18px);
  text-align: left;
}

/* Ribbon buttons — the reference's coloured subtitle bars, clickable.
   width:100% (not shrink-to-fit): a button's natural content width
   ignores its positioned ancestor's narrower box, so at this box's
   190px slot the "Shape Policy"-length labels would overflow past it
   into the lede text regardless of .cy-l's own width. Forcing 100%
   plus letting .rib-t wrap keeps every ribbon inside the fixed slot. */
.rib {
  display: flex; align-items: center; gap: 10px;
  width: 100%; max-width: 100%; box-sizing: border-box;
  border: none; cursor: pointer; font-family: inherit;
  padding: clamp(7px, 1.3vmin, 11px) clamp(12px, 1.9vmin, 18px);
  border-radius: 5px;
  font-size: clamp(13px, 1.9vmin, 17px); font-weight: 700; line-height: 1.15;
  box-shadow: 3px 4px 10px rgba(0, 0, 0, .45);
  transition: transform .16s ease, box-shadow .16s ease, filter .16s ease;
}
.rib-t { flex: 1 1 auto; white-space: normal; min-width: 0; }
.rib:hover { transform: translateY(-2px); filter: brightness(1.08); }
.rib:active { transform: translateY(0); }
.rib-1 { background: var(--c1); color: var(--deep); }
.rib-2 { background: var(--c2); color: #fff; }
.rib-3 { background: var(--c3); color: #fff; }
.rib-4 { background: var(--c4); color: var(--deep); }
.rib-num { font-size: .82em; opacity: .82; font-weight: 800; }
.rib-x {
  width: 8px; height: 8px; flex-shrink: 0;
  border-right: 2.4px solid currentColor; border-bottom: 2.4px solid currentColor;
  transform: rotate(45deg) translate(-1px, -1px);
  transition: transform .2s ease;
  opacity: .85;
}
.rib.is-open .rib-x { transform: rotate(225deg) translate(-1px, -1px); }

.cy-desc { margin-top: clamp(6px, 1.2vmin, 10px); }
.cy-desc p {
  font-size: var(--fs-body); color: var(--ink-dim); line-height: 1.45;
  background: var(--surf-2); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  border-radius: var(--radius-sm);
  padding: clamp(9px, 1.5vmin, 14px) clamp(11px, 1.7vmin, 16px);
  box-shadow: var(--neu-sm);
  text-align: left;
}
/* Without JS the toggles can't run — show every description */
html:not(.js) .cy-desc[hidden] { display: block; }

/* Desktop + mouse: the diagram stands alone. Labels appear only while
   the pointer is on the diagram (or a step is expanded) and fade out
   again on leave. Hides the whole .cy-l so an open description can
   never outlive its ribbon. Compact/touch layouts keep them visible. */
@media (hover: hover) and (min-width: 901px) and (min-height: 601px) {
  html.js .cy-l {
    visibility: hidden; opacity: 0;
    transform: translateY(calc(-50% + 6px));
    transition: opacity .3s ease, transform .3s ease, visibility 0s .3s;
  }
  html.js .cy-l.is-shown {
    visibility: visible; opacity: 1; transform: translateY(-50%);
    transition: opacity .3s ease, transform .3s ease, visibility 0s;
  }
}

/* ── 03 · stats + timeline ─────────────────────────────────────── */
.stats {
  list-style: none;
  /* minmax(0, 1fr), not bare 1fr: a bare fr track refuses to shrink
     below its content's min-content width, so on a tablet the longest
     label ("BIODIVERSITY SITES") pushed its card 15px past the grid's
     own right edge and out under the next-slide arrow. Allowing the
     track to shrink lets the label wrap instead of the card overflow. */
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(8px, 1.2vw, 18px);
  margin-top: clamp(14px, 3.2vmin, 34px);
}
.stat {
  padding: clamp(12px, 2.4vmin, 26px) clamp(8px, 1.4vmin, 16px);
  border-radius: var(--radius);
  background: var(--surf); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  box-shadow: var(--neu);
  border: 1px solid rgba(255, 255, 255, .6);
  text-align: center;
  /* lets .stat-num size itself against this card — see its font-size */
  container-type: inline-size;
}
/* Mouse-driven pointers only, so a tap on a phone doesn't leave a card
   stuck in its hovered state. Scaled with a transform rather than by
   changing size or padding: it composes with the fit scale on
   .slide-body and never reflows the grid the cards sit in. */
@media (hover: hover) and (pointer: fine) {
  .stat {
    transition: transform .28s cubic-bezier(.22, 1, .36, 1),
                border-color .28s ease, box-shadow .28s ease;
  }
  .stat:hover {
    transform: scale(1.045);
    border-color: rgba(255, 255, 255, .95);
    box-shadow: var(--neu), 0 10px 28px rgba(0, 0, 0, .34);
  }
}
.stat-num {
  display: block;
  font-size: var(--fs-stat); font-weight: 800; line-height: 1;
  /* Capped against the card's OWN width, not just the viewport.
     --fs-stat is keyed to vmin, which says nothing about how wide a
     card ends up once the grid has divided the row: at 1024px the
     longest figure wanted 187px inside a 126px card and painted
     straight over its neighbour. 27cqw is the widest "2,500+" can be
     and still sit inside its card. The plain declaration above stays
     as the fallback wherever container units aren't supported. */
  font-size: min(var(--fs-stat), 27cqw);
  color: var(--accent); font-variant-numeric: tabular-nums;
  letter-spacing: -.02em;
}
.stat-lbl {
  display: block; margin-top: clamp(4px, 1vmin, 10px);
  font-size: var(--fs-chip); font-weight: 700;
  letter-spacing: .08em; text-transform: uppercase; color: var(--ink-mut);
}
/* The stats row, the contact form and the contact aside each used to
   carry their own 12px inset here to clear the nav arrows. All three
   are gone: --rail now clears the arrows for the whole slide, so the
   inset would only push these blocks 12px right of the heading above
   them — which is exactly the misalignment it caused. Clearance
   belongs to the slide, not to individual blocks inside it. */
/* Two across from tablet width down, not just phone width. Four
   columns below ~900px leaves each card narrower than its own number:
   at 768px the tracks are free to shrink (see minmax above) and
   "2,500+" simply ran out of its card into the next one. Two columns
   give the figures room instead of shrinking the type to suit. */
@media (max-width: 900px) {
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .timeline { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.timeline {
  list-style: none;
  display: grid; grid-template-columns: repeat(4, 1fr);
  gap: clamp(6px, 1vw, 16px);
  margin-top: clamp(12px, 2.4vh, 26px);
  padding-top: clamp(10px, 2vmin, 20px);
  border-top: 1px solid rgba(143, 227, 180, .28);
}
.timeline li { position: relative; padding-left: 14px; }
.timeline li::before {
  content: ''; position: absolute; left: 0; top: .55em;
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent); box-shadow: 0 0 0 3px rgba(143, 227, 180, .18);
}
.t-year {
  display: block; font-size: clamp(13.5px, 2.1vmin, 18px); font-weight: 800;
  color: var(--ink); letter-spacing: .04em;
}
.t-place { display: block; font-size: var(--fs-body); color: var(--ink-mut); line-height: 1.35; }

/* ── 04 · contact ──────────────────────────────────────────────── */
.contact-grid {
  display: grid; grid-template-columns: 1.15fr .85fr;
  gap: clamp(14px, 2.2vw, 40px);
  margin-top: clamp(12px, 2.8vmin, 30px);
  align-items: start;
}

.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(8px, 1vw, 14px); }
.field { margin-bottom: clamp(7px, 1.5vmin, 14px); }
.field label {
  display: block; font-size: var(--fs-chip); font-weight: 700;
  letter-spacing: .09em; text-transform: uppercase; color: var(--ink-mut);
  margin-bottom: 4px;
}

/* "We're looking for" — a real fieldset/legend so the four options are
   announced as one named group, and real radios so only one can be
   chosen. border:0 because a fieldset carries a browser default one. */
.looking-for { border: 0; }
.looking-for legend {
  padding: 0;
  font-size: var(--fs-chip); font-weight: 700;
  letter-spacing: .09em; text-transform: uppercase; color: var(--ink-mut);
  margin-bottom: 5px;
}
.lf-opts {
  display: flex; flex-wrap: wrap; gap: 8px clamp(12px, 1.8vw, 22px);
  /* lets .lf-opt size itself against this row — see its font-size */
  container-type: inline-size;
}
/* Scoped under .looking-for so it outranks `.field label`, which would
   otherwise force these into the uppercase, letter-spaced, full-width
   treatment meant for field NAMES — these are options to read. */
.looking-for .lf-opt {
  display: flex; align-items: center; gap: 8px;
  font-size: calc(var(--fs-body) * 1.1);
  /* Capped against the row's own width, which above phone widths is
     the FORM COLUMN rather than the viewport — at 1024px the three
     choices wanted 526px inside a 494px column and wrapped. A vw-based
     cap can't see that, since the column is a fraction of the page
     that changes with the contact grid. */
  --lf-up: 1;
  font-size: min(calc(var(--fs-body) * 1.1 * var(--lf-up)), calc((100cqw - 130px) / 24.5));
  color: var(--ink-dim);
  line-height: 1.3; cursor: pointer;
  letter-spacing: normal; text-transform: none; font-weight: 400;
  margin-bottom: 0;
}
/* Drawn as a square tickbox to sit with the consent box below it,
   while remaining a radio input so choosing one clears the others. */
.looking-for .lf-opt input[type="radio"] {
  appearance: none; -webkit-appearance: none;
  /* padding:0 is load-bearing — these sit inside a .field, so they
     inherit the text inputs' clamp() padding. A box can't shrink below
     its own padding+border, so that quietly overrode the width/height
     below and rendered them 23x19: oversized, and not even square. */
  width: 19px; height: 19px; flex-shrink: 0; margin: 0; padding: 0;
  border: 1.5px solid rgba(255, 255, 255, .55);
  border-radius: 4px;
  background: rgba(255, 255, 255, .06);
  display: grid; place-content: center;
  cursor: pointer;
  transition: background .18s ease, border-color .18s ease;
}
.looking-for .lf-opt input[type="radio"]::after {
  content: ''; width: 11px; height: 11px;
  background: var(--deep);
  transform: scale(0); transition: transform .15s ease;
  clip-path: polygon(14% 46%, 0 60%, 40% 100%, 100% 22%, 86% 8%, 38% 72%);
}
.looking-for .lf-opt input[type="radio"]:checked {
  background: var(--accent); border-color: var(--accent);
}
.looking-for .lf-opt input[type="radio"]:checked::after { transform: scale(1); }
.looking-for .lf-opt input[type="radio"]:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.looking-for .lf-opt:hover input[type="radio"] { border-color: rgba(255, 255, 255, .85); }
.looking-for .lf-opt:hover span { color: var(--ink); }
.looking-for .lf-opt input:checked + span { color: var(--ink); font-weight: 600; }
/* Phones: tightened just enough to keep all three choices on one line.
   The three labels plus their boxes need ~366px at the default sizing
   against ~298px of content width on a 390px screen, so type, boxes and
   both gaps all come down a little rather than any one of them a lot. */
@media (max-width: 600px) {
  /* Row gap as well as column gap: they wrap now, so the second row
     needs air under the first, not just between neighbours. */
  .looking-for .lf-opts { gap: 9px 14px; flex-wrap: wrap; }
  /* These no longer chase a single line on phones. At 390px the row
     has 274px, and the three choices at +9% with gaps worth the name
     need 327px — so any real spacing costs the single line. Two
     comfortable rows beat one cramped one, so they wrap, and the
     sizing is free to follow the viewport rather than a line budget.
     The 177px subtrahend is the two rails plus the radios and their
     gaps; it moves whenever --rail does. */
  .looking-for .lf-opt {
    font-size: clamp(11.6px, calc((100vw - 177px) / 19.1), 18.4px);
    gap: 6px;
  }
  .looking-for .lf-opt input[type="radio"] { width: 15px; height: 15px; border-radius: 3px; }
  .looking-for .lf-opt input[type="radio"]::after { width: 10px; height: 10px; }
}
.field input, .field textarea {
  width: 100%;
  padding: clamp(8px, 1.5vmin, 14px) clamp(10px, 1.4vmin, 16px);
  font-family: inherit; font-size: max(16px, var(--fs-body)); color: var(--ink);
  background: rgba(8, 26, 16, .55);
  border: 1px solid rgba(143, 227, 180, .34);   /* carries the 3:1 boundary, not the shadow */
  border-radius: var(--radius-sm);
  box-shadow: var(--neu-in);
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
}
.field textarea { resize: none; min-height: clamp(48px, 10vmin, 92px); }
.field input::placeholder, .field textarea::placeholder { color: var(--ink-mut); }
.field input[aria-invalid="true"], .field textarea[aria-invalid="true"] { border-color: #FF9B8A; }

.consent-row {
  display: flex; gap: 9px; align-items: flex-start;
  margin: clamp(6px, 1.3vmin, 12px) 0 clamp(9px, 1.7vmin, 16px);
}
.consent-row input[type="checkbox"] {
  width: 18px; height: 18px; margin-top: 2px; flex-shrink: 0;
  accent-color: var(--accent); cursor: pointer;
}
.consent-row label { font-size: var(--fs-body); color: var(--ink-mut); line-height: 1.4; cursor: pointer; }
/* Phones: the pointer to the privacy notice gets its own line instead
   of wrapping mid-sentence, which split "privacy notice" across two
   lines and left a stray "notice." under the link. */
@media (max-width: 700px) {
  .consent-note { display: block; }
}
.consent-row a, .nojs-note a { color: var(--accent); font-weight: 600; }

.hp-field { position: absolute; left: -9999px; opacity: 0; height: 0; overflow: hidden; }
html:not(.js) #contact-form { display: none; }
.nojs-note { font-size: var(--fs-body); color: var(--ink-dim); }

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  border: none; cursor: pointer; font-family: inherit; font-weight: 700;
  border-radius: 999px; text-decoration: none;
  transition: transform .16s ease, box-shadow .16s ease, background .16s ease;
  touch-action: manipulation;
}
.btn-primary {
  background: var(--accent); color: var(--deep);
  font-size: clamp(12.5px, 1.8vmin, 16px);
  padding: clamp(9px, 1.7vmin, 15px) clamp(18px, 2.6vmin, 32px);
  min-height: 40px;
  box-shadow: var(--neu-sm);
}
.btn-primary:hover { background: #A8ECC6; }
.btn-primary:active { transform: scale(.97); box-shadow: inset 2px 2px 6px rgba(0, 0, 0, .3); }
.btn[disabled] { opacity: .6; cursor: not-allowed; }

.form-status { margin-top: 8px; font-size: var(--fs-body); font-weight: 600; min-height: 1em; }
.form-status.ok  { color: var(--accent); }
.form-status.err { color: #FFB3A5; }

.contact-aside { display: grid; gap: clamp(8px, 1.8vmin, 17px); }
/* Transparent everywhere except phones: the contact items and sign-off
   stay direct grid items of .contact-aside, so every layout above
   700px is untouched by the wrapper's existence. */
.aside-text { display: contents; }
.c-item .c-lbl {
  display: block; font-size: var(--fs-chip); font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase; color: var(--accent);
  margin-bottom: 2px;
}
.c-item p, .c-item a { font-size: var(--fs-body); color: var(--ink-dim); line-height: 1.45; }
.c-item a { color: var(--ink); text-decoration: none; border-bottom: 1px solid rgba(143, 227, 180, .45); }
.c-item a:hover { color: var(--accent); }

/* Social links. Desktop: a horizontal row sitting under the sign-off,
   which is simply where they fall as the aside's last child. */
.socials {
  display: flex; flex-wrap: wrap; list-style: none;
  gap: clamp(9px, 1.5vmin, 14px);
  margin-top: clamp(2px, 1vmin, 8px);
}
.socials a {
  display: grid; place-items: center;
  width: clamp(34px, 4.6vmin, 44px); aspect-ratio: 1;
  border-radius: 50%;
  background: var(--surf);
  border: 1px solid rgba(255, 255, 255, .55);
  color: var(--ink);
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  position: relative;
  overflow: hidden;
}
.socials svg { width: 52%; height: 52%; fill: currentColor; }

/* Metal shine: a bright band sweeping across each disc on a loop. It is
   a pseudo-element rather than a moving background-position because
   only transform animates off the main thread, and this runs forever.
   The band is wider than the disc and rotated, so the leading edge
   reaches the far side before the trailing edge clears the near one,
   which reads as a curved sweep across metal rather than a flat wipe.
   Each icon starts a beat after the one before, so the row ripples
   instead of flashing in unison. */
.socials a::after {
  content: "";
  position: absolute;
  top: -50%; bottom: -50%; left: -60%; right: -60%;
  background: linear-gradient(
    100deg,
    transparent 42%,
    rgba(255, 255, 255, .16) 47%,
    rgba(255, 255, 255, .78) 50%,
    rgba(255, 255, 255, .16) 53%,
    transparent 58%
  );
  transform: translateX(-100%);
  animation: sheen 4.7s cubic-bezier(.5, 0, .35, 1) infinite;
  pointer-events: none;
}
@keyframes sheen {
  /* 4.7s cycle: a 3s crossing (63.83%) then 1.7s parked off the right.
     All the still time sits in the tail, with no hold at the head, so
     the gap between one sweep and the next IS the 1.7s written here.
     An earlier version split the stillness across both ends, which
     made the real gap the sum of the two and easy to misread. */
  0%          { transform: translateX(-100%); }
  63.83%, 100% { transform: translateX(100%); }
}
.socials li:nth-child(2) a::after { animation-delay: .15s; }
.socials li:nth-child(3) a::after { animation-delay: .30s; }
.socials li:nth-child(4) a::after { animation-delay: .45s; }
/* Mouse pointers only, so a tap can't leave one stuck hovered. */
@media (hover: hover) and (pointer: fine) {
  .socials a { transition: background .22s ease, color .22s ease, border-color .22s ease, transform .22s cubic-bezier(.22, 1, .36, 1); }
  .socials a:hover { background: var(--accent); border-color: var(--accent); color: var(--deep); transform: translateY(-2px); }
}
@media (prefers-reduced-motion: reduce) {
  .socials a { transition: none; }
  .socials a:hover { transform: none; }
  /* A looping highlight is exactly the kind of unprompted motion this
     query exists to stop, so it goes entirely rather than slowing. */
  .socials a::after { animation: none; opacity: 0; }
}
.sign-off {
  font-family: 'EB Garamond', Georgia, serif; font-style: italic;
  /* 3x the old clamp(14px, 2.5vmin, 22px) — this line is the deck's
     sign-off, not a caption, so it carries display weight. */
  font-size: clamp(42px, 7.5vmin, 66px); color: var(--accent);
  margin-top: clamp(2px, 1vmin, 9px);
  line-height: 1.15;
}

/* ── Navigation arrows ─────────────────────────────────────────── */
.nav {
  position: fixed; top: 50%; z-index: 40;
  translate: 0 -50%;
  width: clamp(40px, 5.6vw, 60px); aspect-ratio: 1;
  display: grid; place-items: center;
  /* Hairline rim. box-sizing is border-box globally, so this costs no
     outer size — it reads as an edge on the glass, not a frame. */
  border: 1px solid rgba(226, 232, 230, .34);
  border-radius: 50%; cursor: pointer;
  background: var(--surf-2); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  /* Deeper than the shared --neu token: these float free over the
     photograph rather than sitting on a surface, so they need to read
     as lifted from any background the panorama pans past. */
  box-shadow: 0 14px 30px rgba(0, 0, 0, .5), 0 5px 12px rgba(0, 0, 0, .38),
              -5px -5px 14px rgba(255, 255, 255, .06),
              inset 0 1px 0 rgba(255, 255, 255, .1);
  color: var(--ink);
  transition: transform .18s ease, background .18s ease, opacity .25s ease,
              box-shadow .18s ease, border-color .18s ease;
  /* Floats on `translate`, NOT `transform`. Two reasons: the -50%
     vertical centring already lives on translate so the float can ride
     with it, and transform is left free for the hover/active scale —
     an animation on transform outranks those declarations and would
     kill the press feedback for as long as it ran, which here is
     forever. */
  animation: nav-float 4.6s ease-in-out infinite;
}
@keyframes nav-float {
  0%, 100% { translate: 0 calc(-50% - 3px); }
  50%      { translate: 0 calc(-50% + 3px); }
}
.nav svg { width: 44%; height: 44%; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.nav-prev { left: clamp(8px, 1.6vw, 26px); }
.nav-next { right: clamp(8px, 1.6vw, 26px); }
.nav:hover {
  background: rgba(45, 106, 79, .8); transform: scale(1.06);
  border-color: rgba(226, 232, 230, .6);
  box-shadow: 0 18px 38px rgba(0, 0, 0, .55), 0 6px 14px rgba(0, 0, 0, .42),
              -5px -5px 14px rgba(255, 255, 255, .07),
              inset 0 1px 0 rgba(255, 255, 255, .12);
}
.nav:active { transform: scale(.94); box-shadow: inset 3px 3px 8px rgba(0, 0, 0, .4); }
.nav[disabled] { opacity: 0; pointer-events: none; }
/* Both animations listed together, not one replacing the other: they
   drive different properties (float on translate, nudge on transform),
   so declaring nudge alone here would have dropped the float from this
   arrow only. The negative delay offsets the float's phase so the two
   arrows breathe out of step rather than in lockstep. */
.nav-next:not([disabled]) {
  animation: nav-float 4.6s ease-in-out -2.3s infinite,
             nudge 2.6s ease-in-out 1.4s 3;
}
@media (prefers-reduced-motion: reduce) {
  .nav, .nav-next:not([disabled]) { animation: none; translate: 0 -50%; }
}
@keyframes nudge {
  0%, 100% { transform: translateX(0); }
  50%      { transform: translateX(5px); }
}

/* ── Dots ──────────────────────────────────────────────────────── */
.dots { display: flex; align-items: center; gap: clamp(6px, .9vw, 12px); }
.dot {
  border: none; background: none; cursor: pointer; padding: 6px 2px;
  display: flex; align-items: center; gap: 7px;
}
.dot::before {
  content: ''; width: 7px; height: 7px; border-radius: 50%;
  background: rgba(242, 250, 243, .34);
  box-shadow: var(--neu-sm);
  transition: background .2s ease, width .2s ease;
}
.dot.is-on::before { background: var(--accent); width: 22px; border-radius: 4px; }
.dot span {
  font-size: clamp(10.5px, 1.5vh, 13px); font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-mut);
}
.dot.is-on span { color: var(--ink); }
.dot:hover span { color: var(--ink); }

.credit { font-size: clamp(8.5px, 1.1vh, 10.5px); color: rgba(242, 250, 243, .45); letter-spacing: .04em; }
.credit a { color: inherit; }

/* ── Responsive ────────────────────────────────────────────────── */
/* Cycle diagram: stack when the side-label layout runs out of room */
@media (max-width: 900px), (max-height: 600px) {
  .model-cols { grid-template-columns: 1fr; gap: clamp(10px, 2vmin, 20px); }
  .cycle-area {
    display: flex; flex-direction: column; align-items: center;
    gap: clamp(10px, 2vmin, 20px);
  }
  /* +25% over the original mobile size */
  .cycle-fig { width: clamp(213px, 42.5vmin, 325px); }
  /* popovers become a static stacked accordion */
  .cy-l {
    position: static; width: auto; max-width: none; text-align: left;
  }
  .cy-labels {
    display: grid; width: 100%;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
    gap: clamp(7px, 1.6vmin, 14px) clamp(14px, 2.4vmin, 24px);
  }
  .rib { width: 100%; }
  .rib-x { margin-left: auto; }

  /* Stacked mobile: lede and the carousel are now rows, not columns —
     the old gap read as barely any breathing room between the lede's
     last line and "THE ENSEMBLE" label right below it. */
  .about-cols { grid-template-columns: minmax(0, 1fr); gap: clamp(24px, 5.5vmin, 40px); }
  /* Stacked layout puts the carousel BELOW a full-width opening line
     rather than beside it, so it is what pushed slide 1 over its slot
     (measured at 390x844: 805px of content against a 596px slot, of
     which the carousel was 506px). Being over the slot is what made
     slide 1's opening line render smaller than the identical line on
     slide 2. Rows are sized to their own content instead: a two-line
     bio needs 99px including padding, so the three rows now cost 316px
     instead of 478px. Three rows still show, one faded either side. */
  /* Flat, not a clamp: a row's content is a fixed 99px here (avatar,
     a name that wraps to two lines on a 360px screen, and a two-line
     bio), so a viewport-height-based floor just produced rows too
     short for their own content on smaller phones — 92px rows around
     99px of content, clipped. Row height answers to the content, not
     to the viewport. */
  :root { --tm-h: 152px; }
  .tm-ava { width: clamp(52px, 13vmin, 72px); }
  .tm-bio { -webkit-line-clamp: 4; }
  /* Break the carousel out of the slide's own side padding so it
     measures against the true screen width, not the content column.
     Pure margin math (not margin+transform): the slide is itself
     scaled down by the shrink-to-fit system, and mixing a % margin
     (resolved against the containing block) with a transform's own %
     (resolved against the element's box) turned out to compose
     incorrectly once that ancestor scale wasn't 1 — off-centre by
     however much the two reference frames disagreed. A single
     margin expression avoids the mismatch entirely. */
  /* dvw, not vw: mobile browsers reserve vw against the viewport size
     with browser chrome (address bar) HIDDEN, which is wider than
     what's actually visible whenever that chrome is showing — a real
     device can overflow here even though vw's own arithmetic (0.9 +
     0.9 = fits) is correct. dvw tracks the chrome-aware, currently
     visible viewport instead. */
  /* Divided by --body-fit because a viewport unit does NOT survive the
     shrink-to-fit system. That system gives .slide-body a layout width
     of targetW/scale and then scales it back down by `scale`, so a
     child sized 90dvw in layout px renders at 90dvw x scale. At scale
     0.95 that is a 5% error nobody sees; at 0.64 - a real phone with
     its address bar showing - the carousel came out 224px instead of
     351px and the name column fell to 119px, which wrapped member
     names one word per line. Dividing puts the width in the same
     inflated coordinate space as its parent, so it renders at exactly
     90dvw again. The 50% in the margin is already in that space.
     Safe against the fit solver's own feedback: the carousel's height
     is fixed by --tm-h, so nothing here changes the height the solver
     is measuring. */
  .team {
    width: calc(90dvw / var(--body-fit, 1));
    margin-inline: calc(50% - (45dvw / var(--body-fit, 1)));
  }
}
/* Short but wide (landscape phones): keep the two columns side by side
   and shrink the carousel rows instead of stacking everything */
@media (max-height: 600px) and (min-width: 700px) {
  /* landscape phones keep the two-column layout, so the carousel
     stays inside its own grid column instead of breaking full-bleed */
  .team { width: auto; margin-left: 0; transform: none; }
  .about-cols { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); }
  :root { --tm-h: 155px; }
  .tm-bio { -webkit-line-clamp: 4; font-size: 12.5px; }
  .tm-name { font-size: 14.5px; }
  .tm-ava { width: 62px; border-width: 2px; }
  .team-h { margin-bottom: 4px; }
}

@media (max-width: 1000px) {
  .contact-grid { grid-template-columns: 1fr; }
  .contact-aside { grid-template-columns: repeat(2, 1fr); }
  .sign-off { grid-column: 1 / -1; }
}
@media (max-width: 700px) {
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .timeline { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Strapline stays, under "CYCLE", rather than being dropped on
     phones. It has to hold one line: left to wrap it orphans
     "ENSEMBLE" onto a second line and crowds the eyebrow below.
     Sized against its own column rather than the viewport — the
     column is what's left after the logo and the slide counter, which
     no vw figure can express. .brand and .brand-text take the leftover
     space explicitly so that column has a width of its own to measure,
     instead of one derived from the text sitting in it. Tracking
     pulled in from .1em to buy a little more room. */
  .brand { flex: 1 1 auto; min-width: 0; }
  .brand-text { flex: 1 1 auto; min-width: 0; container-type: inline-size; }
  .brand-sub {
    display: block;
    font-size: min(10px, calc(100cqw / 31));
    letter-spacing: .02em;
    line-height: 1.2;
    white-space: nowrap;
  }
  .dot span { display: none; }
  .dot { padding: 8px 4px; }
  .headline, .slide:not(#about) .headline { max-width: 100%; }
}
/* Narrow phones: the model slide is the densest — hold its type at
   readable sizes by tightening the layout instead of letting the
   shrink-to-fit scale the whole slide down.

   The per-slide headline font-size clamps that used to live here are
   gone. They existed to force each mobile headline to wrap into the
   same number of lines as desktop, which required sizing each one
   differently — directly at odds with every headline being the same
   size. Uniform size wins; line counts now fall where the text
   naturally wraps. */
@media (max-width: 600px) {
  /* the flat 14px override that used to pin .s-model .lede here is
     gone too: it made slide 2's opening line a different size from
     slide 1's, which share the standfirst role and now share its token */
  /* +25% over the original mobile size (matches the compact rule above) */
  .cycle-fig { width: 188px; }
  .cy-core img { width: 40px; }
  .cy-word { font-size: 11px; }
  .cy-n1, .cy-n2, .cy-n3, .cy-n4 { font-size: 12px; border-width: 2px; }
  .cy-labels { gap: 6px; }
  .rib { font-size: 14px; padding: 7px 12px; }
  .cy-desc { padding-top: 4px; }
  .cy-desc p { font-size: 12.5px; line-height: 1.32; }
  .chip-row-sm { margin-top: 8px; }
  .chip-row-sm .chip { font-size: 10px; padding: 4px 9px; }
}

@media (max-width: 560px) {
  .cy-l p { line-height: 1.35; }
  .contact-aside { grid-template-columns: 1fr; }
  .credit { display: none; }
  /* Slide indicator centred on the screen. The bottom bar is a
     space-between flex row, so once the credit above is hidden the
     dots were left on their own against the left edge. Centred
     absolutely rather than by switching the row to
     justify-content:center, so it lands on the true middle of the
     screen rather than the middle of whatever remains in the row —
     .chrome-bottom is fixed, so it is the containing block.
     Deliberately tied to the SAME breakpoint that hides the credit:
     centring it any wider than this walks the dots straight into the
     credit text, which they did at 561-700px. */
  .dots {
    position: absolute;
    left: 50%; top: 50%;
    transform: translate(-50%, -50%);
  }
  .chip-row-sm { gap: 5px; }
  .chip-row-sm .chip { padding: 5px 10px; }
}
/* Not-very-tall viewports (small phones, and any phone once the
   browser's own chrome is showing). Every headline is one shared size
   now, and at full --fs-h1 that header eats enough of a ~560px-tall
   viewport to squeeze the body below the readability floor (measured
   0.45 on Impact at 375x560). Stepping the shared token down keeps all
   four slides identical to each other — the uniformity is in the
   token, so it survives — while handing the body back its room.
   Placed BEFORE the 520px tier so that stricter tier still wins on the
   shortest screens. */
@media (max-height: 620px) {
  :root { --fs-h1: clamp(26px, 7.2vmin, 58px); }
}
/* Phones: slide 4's headline is authored as three fixed lines (see
   .br-m), and the longest — "build it with us." — measures 8.15x the
   font size in this face at weight 800. The shared token therefore
   caps at what lets that line fit the column, or every phone would
   overflow the forced break. Every headline shrinks together — the
   deck's one-size rule holds; the cost is ~13% off the headline on a
   390px phone. Two width terms because the column is 100vw - 2*rail
   and the rail changes law at 583px (fixed 58px below, 7.2vw + 16px
   above); 8.2 not 8.15 is the whisker of safety; the 4px inside the
   subtractions is rounding headroom. Placed after the max-height
   step above so the min() wins when both conditions hold. */
@media (max-width: 700px) {
  :root {
    --fs-h1: min(clamp(37px, 9.77vmin, 108px),
                 calc((100vw - 120px) / 8.2),
                 calc((85.6vw - 36px) / 8.2));
  }
}
@media (max-width: 700px) and (max-height: 620px) {
  :root {
    --fs-h1: min(clamp(26px, 7.2vmin, 58px),
                 calc((100vw - 120px) / 8.2),
                 calc((85.6vw - 36px) / 8.2));
  }
}
/* Short viewports (landscape phones). Everything stays on screen — these
   rules buy the space by tightening layout, not by dropping content. */
@media (max-height: 520px) {
  :root {
    --chrome-t: 42px; --chrome-b: 36px;
    --fs-h1: clamp(28px, 7.4vmin, 45px);
    --fs-lede: clamp(13px, 2.3vmin, 16.5px);
    --fs-body: clamp(10.5px, 1.6vmin, 13px);
    --fs-stat: clamp(21px, 5.2vmin, 34px);
  }
  .brand-sub, .credit { display: none; }
  .lede { line-height: 1.42; }
  .cy-labels { grid-template-columns: repeat(2, 1fr); }
  .stat { padding: 9px 6px; }
  .timeline { margin-top: 9px; padding-top: 8px; }

  /* Contact: lift the three inputs into one row so the slide stays 1:1 */
  #contact-form {
    display: grid; grid-template-columns: 1fr 1fr 1.2fr;
    gap: 5px 10px; align-items: start;
  }
  .field-row { display: contents; }
  .field { margin-bottom: 0; }
  /* The options are a fieldset, so in this 3-column layout they would
     otherwise take a single cell beside the name field. Own full-width
     row, above everything, which is where they belong. */
  .looking-for { grid-column: 1 / -1; grid-row: 1; }
  .looking-for .lf-opts { gap: 4px 14px; }
  .field:has(textarea) { grid-column: 3; grid-row: 2; }
  .field textarea { min-height: 34px; }
  .consent-row { grid-column: 1 / -1; margin: 2px 0 0; }
  #cf-submit { grid-column: 1 / -1; justify-self: start; min-height: 32px; padding: 6px 18px; }
  .form-status { grid-column: 1 / -1; margin-top: 2px; }
  .contact-grid { gap: 10px 18px; }
  .contact-aside { gap: 5px; }
  .sign-off { font-size: 39px; margin-top: 0; }
}
@media (max-height: 400px) {
  .footnote { display: none; }
}

/* Phones: the form's own text runs 18% larger. Field names, the group
   legend, the choices, what you type, the consent line and the button
   all move together — a form reads as one block, so lifting only some
   of it just makes the rest look like a mistake. The aside beside it
   is deliberately left alone: it is reference detail, not input. */
@media (max-width: 700px) {
  /* `.field > label`, not `.field label`: the choice labels are also
     labels inside a .field (the fieldset carries both classes), and an
     id-scoped rule here would outrank .looking-for .lf-opt and strip
     the choices of their width-aware cap. Field names are direct
     children; the choices sit inside .lf-opts. */
  #contact-form .field > label,
  #contact-form .looking-for legend { font-size: calc(var(--fs-chip) * 1.18); }
  #contact-form .looking-for .lf-opt { --lf-up: 1.18; }
  #contact-form .field input,
  #contact-form .field textarea { font-size: max(16px, calc(var(--fs-body) * 1.18)); }
  #contact-form .consent-row label { font-size: calc(var(--fs-body) * 1.18); }
  #contact-form .btn-primary { font-size: calc(clamp(12.5px, 1.8vmin, 16px) * 1.18); }
}

/* Phones: the social links become a vertical column to the LEFT of the
   contact details, instead of a row under them. Placed last in the
   stylesheet on purpose — the aside's column count is set in three
   earlier breakpoints (2 columns under 1000px, 1 under 560px), and
   this has to win over all of them.

   Two cells, one row: icons on the left, the whole text stack on the
   right. This replaces an earlier attempt that left the three text
   blocks as separate rows and spanned the icons across them with
   grid-row: 1 / -1. That silently did nothing — -1 counts back from
   the last line of the EXPLICIT grid, and these rows are all implicit
   (no grid-template-rows), so the icons stayed in row 1 and stretched
   it to their own 258px while the email line inside it needed 44px.
   The 214px of leftover row was the gap under the email address. */
@media (max-width: 700px) {
  .contact-aside {
    grid-template-columns: auto minmax(0, 1fr);
    column-gap: clamp(12px, 3.4vw, 22px);
    align-items: start;
  }
  .aside-text {
    display: flex;
    flex-direction: column;
    gap: clamp(8px, 1.8vmin, 17px);
    grid-column: 2;
    grid-row: 1;
    min-width: 0;
  }
  /* The column is measured against the text beside it, not sized on its
     own: stretch makes it as tall as the whole text stack, and
     space-between pins the first icon's top edge to the top of "Email"
     and the last icon's bottom edge to the bottom of the sign-off, with
     the slack shared between them. The width is set so four icons are
     always shorter than that stack — otherwise there is no slack to
     distribute and the last icon hangs below the sign-off. */
  .socials {
    grid-column: 1;
    grid-row: 1;
    flex-direction: column;
    flex-wrap: nowrap;
    align-self: stretch;
    justify-content: space-between;
    margin-top: 0;
    gap: 0;
  }
  /* Sized for thumbs, allowing for the shrink. These sit inside
     .slide-body, which the fit system scales down, so a CSS size is not
     the size a finger actually gets: at the desktop sizing they
     rendered 26px square. Also capped so four of them plus the gaps
     stay inside the text stack's height — see the space-between note
     above. */
  /* The 42px ceiling is set by the WORST case, not the common one. The
     text stack is at its shortest around 560px (176px, where the
     address and sign-off stop wrapping but the type has not grown yet)
     while a vw-driven icon is at its largest — four 52px discs needed
     208px and hung 32px below the sign-off. Four 42px discs need 168px
     and clear it everywhere from 320px up. */
  .socials a { width: clamp(34px, 10.8vw, 42px); }
}

@media (prefers-reduced-motion: reduce) {
  .pan { transition: none; }
  .slide, .nav { transition: opacity .01s; }
  .slide.is-active .slide-inner > *,
  .slide.is-active .slide-body > * { animation: none; }
  .nav-next:not([disabled]) { animation: none; }
  .stat { transition: none; }
  .stat:hover { transform: none; }
}

/* ── No-JS fallback: the deck degrades into a normal scrolling page ─ */
html:not(.js) body { overflow: auto; }
html:not(.js) .deck { position: static; }
html:not(.js) .stage { position: fixed; }
html:not(.js) .slide,
html:not(.js) .slide[hidden] {
  display: grid; position: relative; inset: auto;
  opacity: 1; visibility: visible;
  min-height: 100vh;
}
html:not(.js) .nav, html:not(.js) .dots, html:not(.js) .counter { display: none; }

/* ── Privacy / legal page ──────────────────────────────────────── */
body.legal-page { overflow: auto; position: static; }
.legal {
  position: relative; z-index: 10;
  max-width: 760px; margin: 0 auto;
  padding: clamp(70px, 12vh, 120px) var(--gut) 80px;
}
.legal h1 { font-size: clamp(26px, 4vh, 40px); font-weight: 800; letter-spacing: -.02em; }
.legal .updated { color: var(--ink-mut); font-size: 14px; margin: 6px 0 34px; }
.legal h2 { font-size: clamp(17px, 2.6vh, 22px); font-weight: 700; margin: 34px 0 10px; color: var(--accent); }
.legal p, .legal li { font-size: 15.5px; color: var(--ink-dim); }
.legal ul { padding-left: 22px; margin: 10px 0; }
.legal li { margin-bottom: 6px; }
.legal a { color: var(--accent); font-weight: 600; }
.legal .card {
  padding: 22px 26px; margin: 20px 0; border-radius: var(--radius);
  background: var(--surf); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  box-shadow: var(--neu);
}
.legal-foot {
  position: relative; z-index: 10;
  text-align: center; padding: 0 var(--gut) 40px;
  font-size: 12px; color: rgba(242, 250, 243, .45);
}
.legal .back {
  display: inline-block; margin-top: 44px;
  background: var(--accent); color: var(--deep);
  padding: 13px 26px; border-radius: 999px;
  font-weight: 700; font-size: 15px; text-decoration: none;
  box-shadow: var(--neu-sm);
}

/* ── Carousel heading width guards ──────────────────────────────────
   The heading spans the carousel (see .team-h), but two layouts need
   the span bounded, and they need OPPOSITE bounds — which is why this
   is two queries and not one. */

/* Phones and small tablets — the same query that creates the
   full-bleed, so the two never disagree about which layout is live.
   The carousel is a 90dvw full-bleed that runs UNDER the
   next-slide arrow, so spanning it fully hid the last letter behind
   that button. Stop at the arrow's left edge, derived rather than
   guessed: the team's right edge is 95dvw and the arrow's left edge is
   100dvw minus its own inset and width, so the overlap is
   (inset + width - 5dvw). Divided by --body-fit to land in the same
   inflated space cqw uses, less 8px of air. */
@media (max-width: 900px), (max-height: 600px) {
  .team-h {
    font-size: clamp(10px, calc(
      (95dvw - clamp(8px, 1.6vw, 26px) - clamp(40px, 5.6vw, 60px) - 8px)
      / var(--body-fit, 1) / 19.9), 80px);
  }
}

/* Landscape phones: here the carousel is NOT full-bleed but a grid
   column, and that column already extends past the right edge of the
   viewport — a pre-existing condition the old 14px heading was simply
   too small to reveal. Spanning it would push the text 220px
   off-screen, so the size is capped instead. The heading gives up
   spanning on this one layout rather than running off the display. */
@media (max-height: 600px) and (min-width: 700px) {
  .team-h { font-size: clamp(10px, calc(100cqw / 19.9), 34px); }
}
