/* ============================================================
   TOKENS & BASE
   ============================================================ */
@font-face {
  font-family: 'Geist';
  src: url('../assets/fonts/Geist[wght].woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}

:root {
  --bg: #050505;
  --bg-raised: #0d0d0f;
  --bg-card: #111114;
  --ink: #f5f5f7;
  /* one step down from where this started (#a1a1a6). Still 6.25:1 on --bg,
     so body copy clears AA with room; --ink-faint below is already under
     that and is only ever used on labels and meta, never on running text. */
  --ink-dim: #8e8e93;
  --ink-faint: #6e6e73;
  --hairline: rgba(255, 255, 255, 0.09);
  /* the faint lifted fill, shared by the FAQ thread panel and the
     "Where I can add value" band so the two always match */
  --panel: rgba(255, 255, 255, 0.03);
  --accent: #0a84ff;          /* one interface blue, shared with the FAQ thread */
  --accent-warm: #fca5a5;
  /* spacing scale — use these for all layout rhythm */
  --s-1: 8px;
  --s-2: 16px;
  --s-3: 24px;
  --s-4: 40px;
  --s-5: 64px;
  --s-6: 96px;
  --s-7: 160px;

  /* ---------- type ramp ----------
     Sizes come off a fixed set of rungs — 14 17 19 21 24 28 32 40 48 56 —
     rather than a formula. No viewport units: type steps down at breakpoints
     instead of scaling continuously, so every size on screen is a size that
     was actually chosen.

     Leading is paired to each size rather than left to the call site, and
     comes off one of two curves: size + 4px, or size + 8px. Stored as the
     ratio so it survives a size change.

     h1 takes the +8 curve, not the +4 one its size would suggest. The tight
     curve is for headings that sit alone on a line; ours wrap — the hero
     intro runs several lines and the capabilities list is a paragraph in
     heading's clothing. On the +4 curve those read cramped next to body copy
     set at +8. Same absolute breathing room in both is what makes the page
     look evenly set.

     Tracking is deliberately NOT in the ramp — it stays hand-set per
     selector. */
  --fs-small: 14px;  --lh-small: 1.4285714;  /* 14 + 6 */  /* eyebrows, meta, labels, buttons, fine print */
  --fs-body:  17px;  --lh-body:  1.4705882;  /* 17 + 8 */  /* base — body copy, notes, hero, leads */
  --fs-h4:    19px;  --lh-h4:    1.2105263;  /* 19 + 4 */  /* card titles, doc h2 */
  --fs-h3:    21px;  --lh-h3:    1.1904762;  /* 21 + 4 */  /* modal titles */
  --fs-h2:    28px;  --lh-h2:    1.2142857;  /* 28 + 6 */  /* headings inside a card or panel */
  --fs-h1:    48px;  --lh-h1:    1.1666667;  /* 48 + 8 */  /* section, doc, and resume headings */
  /* regular. At the h1 rung the size carries the emphasis on its own, and
     Medium at 48px read heavier than anything else on the page. Same value
     as the bare-heading default below, so headings now differ from body copy
     by size and tracking rather than weight. */
  --fs-heading-weight: 400;

  --radius: 40px;
  /* inset shared by the two --panel surfaces (capabilities, questions), so
     the pair keeps one frame. Big enough to clear the 40px corner arc. */
  --panel-pad: 60px;
  --radius-sm: 12px;
  --radius-pill: 999px;
  --radius-bubble: 28px;
  --nav-h: 64px;
  /* 12-column grid: one max content width, one gutter, one page margin */
  /* 1152, widened from 1080. Kept on the same footing rather than nudged to a
     round number: 12 columns of 74px on the 24px --gutter, so --col stays a
     whole pixel and anything sized in spans keeps landing on the grid.

     The measure is still what caps this. Note the value list sets its own,
     wider column-gap rather than using --gutter, so its spans are narrower
     than --col math suggests: measured at a 1600 viewport a span-4 item is
     341px, and the paragraph gives up 36px to the counter column, leaving
     305px of text against its own 34ch cap (~289px). The cap still bites
     first, so the copy width is unchanged and only the air around it grew.
     Widen much past this and the columns outrun the measure instead, which
     buys gaps between paragraphs rather than a wider page. */
  --container: 1152px;
  --grid-cols: 12;
  --gutter: 24px;
  --page-margin: clamp(24px, 5vw, 72px);
  /* nav and footer run full-bleed rather than sitting in the 980px
     container — centering a thin bar in the middle of a wide screen reads
     oddly, so they get their own edge padding instead, capped at 60px */
  --edge-margin: clamp(24px, 5vw, 60px);
  /* one grid column, so anything outside .grid can still be sized in
     columns: span(n) = n * --col + (n - 1) * --gutter */
  --content-w: min(100% - 2 * var(--page-margin), var(--container));
  --col: calc((var(--content-w) - (var(--grid-cols) - 1) * var(--gutter)) / var(--grid-cols));

  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
  --dur: 0.6s;

  /* How far a leaving element rides up against the scroll. Deliberately
     smaller than the 14px an arriving one drops through, and much smaller
     than the hero's own 64px: the hero is one object leaving alone, where the
     travel is the effect, but this value applies to every title, note, panel
     and archive tile at once — at anything pronounced the whole page reads as
     sliding rather than passing. The dim is doing the work here; the lift is
     only there to keep the exit from being a straight fade. */
  --reveal-lift: 12px;

  --font: "Geist", -apple-system, BlinkMacSystemFont, "SF Pro Display",
          "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

/* Type step-downs. Redeclaring the tokens rather than restyling selectors:
   every call site follows automatically, and a step is one rung down the same
   ramp, never an in-between size. Leading moves with it — it belongs to the
   rung, not to the role.

   Only the headings step. --fs-small and --fs-body are already at the bottom
   of the ramp; the next rung down would cost readability on exactly the
   screens that can least afford it, so they hold at every width. */
@media (max-width: 1068px) {
  :root {
    --fs-h1: 40px;  --lh-h1: 1.2;        /* 40 + 8 */
    --fs-h2: 24px;  --lh-h2: 1.25;       /* 24 + 6 */
    --fs-h3: 19px;  --lh-h3: 1.2105263;  /* 19 + 4 */
  }
}
@media (max-width: 734px) {
  :root {
    --fs-h1: 28px;  --lh-h1: 1.2857143;  /* 28 + 8 */
    --fs-h2: 21px;  --lh-h2: 1.2857143;  /* 21 + 6 */
    --fs-h3: 17px;  --lh-h3: 1.2352941;  /* 17 + 4 */
    --fs-h4: 17px;  --lh-h4: 1.2352941;  /* 17 + 4 */
  }
}

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

html {
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
}

body {
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  /* clip, not hidden: `hidden` would make body a scroll container and
     break the hero's position:sticky */
  overflow-x: clip;
}

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

button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
a { color: inherit; text-decoration: none; }

/* headings carry no weight by default — the ones that should be heavier opt in
   with --fs-heading-weight, and any class selector beats this on specificity */
h1, h2, h3, h4, h5, h6, strong, b { font-weight: 400; }

::selection { background: rgba(10, 132, 255, 0.3); }

/* ============================================================
   PAGE TRANSITIONS
   ============================================================ */
/* Cross-document, which means it has to be declared on both ends of every
   navigation — main.css is the one stylesheet all six pages load, so it lives
   here rather than beside the nav rules it names.

   Only the bar is named. Everything else crossfades as one root snapshot;
   pulling the nav out of that snapshot is what keeps the wordmark and Say
   hello sitting still on screen while the page under them changes, so
   index → experience reads as one site rather than two documents.

   Browsers without support navigate exactly as they do today — there is no
   fallback to write, the rule is simply ignored. */
@view-transition { navigation: auto; }

/* On .nav__inner, NOT .nav. view-transition-name makes an element a backdrop
   root, and .nav's backdrop is drawn by .nav::before — so naming the bar
   itself left that pseudo-element with nothing behind it to sample and the
   blur silently did nothing. The ground fill still painted, which is why it
   read as "the blur is gone" rather than as an outright broken bar.

   The inner row is what actually has to sit still across a navigation anyway:
   it holds the wordmark and Say hello. .nav::before is its sibling rather
   than its descendant, so it stays outside the backdrop root this creates and
   goes on filtering the page. */
.nav__inner { view-transition-name: site-nav; }

/* the default crossfade with a little travel under it: the outgoing page
   clears first, and the incoming one arrives on the same short rise the
   section reveals use, so a navigation lands like content entering rather
   than a dissolve. Kept brief — this is chrome between two pages */
::view-transition-old(root) { animation: vt-out 0.18s var(--ease-out) both; }
::view-transition-new(root) { animation: vt-in 0.34s var(--ease-out) both; }
@keyframes vt-out { to { opacity: 0; } }
@keyframes vt-in { from { opacity: 0; transform: translateY(10px); } }

.container {
  width: min(100% - 2 * var(--page-margin), var(--container));
  margin-inline: auto;
}
/* kept as an alias so existing markup keeps working — every container
   now shares the same 1440 max width and page margin */
.container--wide { width: min(100% - 2 * var(--page-margin), var(--container)); }

/* the 12-column grid itself; children position with `grid-column: span N` */
.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), minmax(0, 1fr));
  column-gap: var(--gutter);
}

/* ============================================================
   TYPO / SECTION SCAFFOLD
   ============================================================ */
/* 100px a side, so consecutive sections sit 200px apart. Still a clamp so
   short windows tighten instead of spending a third of the viewport on air:
   14vh only reaches the 100px cap above ~715px of height, and floors at 64. */
.section { padding: clamp(var(--s-5), 14vh, 100px) 0; }

/* Half of it on a phone. 200px between sections is generous air on a desktop
   window and most of a viewport on a handset, where it reads as the page
   having simply ended. Same clamp shape, every term halved. */
@media (max-width: 860px) {
  .section { padding: clamp(32px, 7vh, 50px) 0; }
}

/* no rule between sections: the section padding is the separation. This used
   to draw a container-width hairline at each boundary, which the bands were
   written to suppress; with the bands gone the page carried a line at every
   join and read as a stack of panes rather than one continuous page. */

/* A banded section: full-bleed grey behind an otherwise ordinary section.
   Unused as of the divider removal — no page carries the class right now —
   but kept as the utility for giving one section its own ground. */
.band {
  background: var(--panel);
  /* less internal air than a plain section: there, the padding IS the
     separation from the neighbours, but here the band's own edges do that
     job. At the full .section value the band opens with a tall empty strip
     of grey before the heading. Same specificity as .section, wins on order. */
  padding-block: clamp(var(--s-5), 8vh, var(--s-6));
}

.section__eyebrow {
  font-size: var(--fs-small);
  line-height: var(--lh-small);
  font-weight: 400;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-dim);
  margin-bottom: var(--s-2);
}

.section__title {
  font-size: var(--fs-h1);
  line-height: var(--lh-h1);
  letter-spacing: -0.02em;
  font-weight: var(--fs-heading-weight);
  margin-bottom: var(--s-3);
}

.section__note {
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--ink-dim);
  max-width: 52ch;
}

/* ---- links: one treatment everywhere ----
   White at rest, dimming to the body gray on hover. This is the nav's
   "Say hello" behaviour, promoted to the site's only link style, so no link
   is a different colour or carries an underline the others don't. */
.linklike {
  color: var(--ink);
  text-decoration: none;
  cursor: pointer;
  transition: color 0.25s ease;
}
@media (hover: hover) {
  .linklike:hover { color: var(--ink-dim); }
}

/* ============================================================
   BUTTONS
   ============================================================ */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 12px 24px;
  border-radius: var(--radius-pill);
  font-size: var(--fs-small);
  line-height: var(--lh-small);
  letter-spacing: -0.01em;
  font-weight: 400;
  transition: transform 0.35s var(--ease-spring),
              background 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
  will-change: transform;
}
@media (hover: hover) {
  .btn:hover { transform: translateY(-1px) scale(1.03); }
}
.btn:active { transform: scale(0.97); transition-duration: 0.1s; }

.btn--primary {
  background: var(--ink);
  color: #000;
}
@media (hover: hover) {
  .btn--primary:hover { box-shadow: 0 8px 32px rgba(255, 255, 255, 0.15); }
}

.btn--ghost {
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hairline);
  color: var(--ink);
  backdrop-filter: blur(10px);
}
@media (hover: hover) {
  .btn--ghost:hover { background: rgba(255, 255, 255, 0.1); }
}

/* sized off .faq-bubble--sent, the question pills in the FAQ thread: same
   padding and same rung, so the two tappable pills on the page are one
   control at one size rather than two that nearly match */
.btn--pill {
  padding: 12px 24px;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}
.btn--block { width: 100%; }

/* ---- glow: the archive's login-button treatment, on a glass pill ----
   Colour and light only — .btn--pill still owns the size and radius.
   Layer order inside the button: glass slab (element background) →
   ::before cursor glow → .btn__label → ::after cursor rim.
   Glow radii are in em so they track the button's own scale; the ratios
   (0.78 and 0.48 of button width) are lifted from .glow-btn. */
.btn--glow {
  --gx: 50%;
  --gy: 0%;
  --glow-o: 0;
  position: relative;
  isolation: isolate;
  color: #f4f2ff;
  background: transparent; /* flat outline at rest — the glass arrives with the cursor */
  /* the same neutral edge .faq-bubble--sent carries, so the two pills match
     at rest as well as in size. The violet belongs to the light, not to the
     button — the hue arrives with the cursor via the ::after rim */
  border: 1px solid rgba(255, 255, 255, 0.22);
  /* no specular hairlines — the ::after rim is the only stroke, and it sits
     directly on this border so the two read as one edge that brightens.
     What is left is a soft inner wall for the glass body, inset so nothing
     bleeds past the pill, and keyed to --glow-o so it is absent at rest */
  box-shadow: inset 0 0 14px rgba(255, 255, 255, calc(var(--glow-o) * 0.05));
  /* glow.js rewrites the insets every frame — .btn's box-shadow transition
     would smear them, and the lerp already supplies the easing */
  transition: transform 0.35s var(--ease-spring), color 0.3s ease;
}
/* the light does the moving here, so drop the lift .btn applies */
@media (hover: hover) {
  .btn--glow:hover { transform: none; }
}

/* label rides above the glass and the glow, below the rim */
.btn--glow .btn__label {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: inherit;
}

.btn--glow::before,
.btn--glow::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
}

/* cursor-following glow, over the glass sheen. Both live on this one layer
   so a single opacity fades them in together — at rest the pill is just its
   outline, and the slab only materialises as the cursor comes near */
.btn--glow::before {
  z-index: 0;
  background:
    radial-gradient(
      11em circle at var(--gx) var(--gy),
      rgba(var(--glow-rgb, 150, 122, 255), 0.34),
      rgba(var(--glow-rgb, 150, 122, 255), 0.1) 45%,
      transparent 75%
    ),
    linear-gradient(
      180deg,
      rgba(255, 255, 255, 0.13),
      rgba(255, 255, 255, 0.045) 45%,
      rgba(255, 255, 255, 0.02) 64%,
      rgba(255, 255, 255, 0.075)
    );
  opacity: var(--glow-o);
}

/* The one stroke. Sits exactly on the button's own border, so the dim
   resting edge and this lit one are a single line that brightens where the
   cursor is. Carries the specular job too now that the lip is gone, hence
   the higher alpha and the wider mask — a longer arc catches the light.
   Above everything so nothing mutes it; it is only a ring, so it never
   covers the label */
.btn--glow::after {
  z-index: 2;
  border: 1px solid rgba(var(--glow-rgb, 196, 176, 255), 0.9);
  -webkit-mask-image: radial-gradient(9em circle at var(--gx) var(--gy), #000 20%, transparent 70%);
  mask-image: radial-gradient(9em circle at var(--gx) var(--gy), #000 20%, transparent 70%);
  opacity: var(--glow-o);
}

/* keyboard users get the lit state centred, since there is no pointer */
.btn--glow:focus-visible {
  --gx: 50%;
  --gy: 50%;
  --glow-o: 1;
}

/* Disabled: an inert outline that never lights. The glow driver tracks the
   whole modal, not the button, so it keeps writing --glow-o to the element's
   style attribute even while the button is unusable — !important is what
   outranks that inline value. Both glow layers read --glow-o for their
   opacity, so zeroing it here is enough to put the pill fully at rest. */
.btn--glow:disabled {
  --glow-o: 0 !important;
  cursor: default;
  color: var(--ink-faint);
  border-color: rgba(255, 255, 255, 0.12);
  box-shadow: none;
}

/* ---- edge light: the same cursor glow, rim only ----
   The glow button minus its ::before — no glass slab, no interior wash, just
   the stroke gaining colour where the cursor is. The large cards carry
   artwork, and the fill version over a case study cover reads as a torch
   being shone on the work; the edge alone says the card is live without
   touching what is printed on it.

   The lit stroke sits just inside the card's own hover border rather than
   exactly on it. Both .case-card and .tile clip to overflow: hidden, so the
   inset: -1px that would make the two boxes coincide is simply cut away. At
   1px each the pair still reads as one edge that warms up.

   --edge-reach sizes the pool of light, and it is a length rather than a
   proportion on purpose: the lit arc belongs to the cursor, not to the
   element, so it stays the same physical size on a 340px archive tile as on
   a case card three times the width. The cards do take a larger one — at the
   tile's value something that big never lights more than a corner, and the
   effect stops reading as an edge that responds to you.

   Desktop only. js/glow.js makes the same check before it attaches anything,
   which is the half that actually matters — on touch, pointermove fires once
   per tap and would leave the rim lit under a finger that has already gone. */
@media (hover: hover) and (pointer: fine) {
  .is-edge-lit {
    --gx: 50%;
    --gy: 50%;
    --glow-o: 0;
    --edge-reach: 200px;
    position: relative;
  }
  .case-card.is-edge-lit { --edge-reach: 320px; }
  /* the question pills were what .btn--glow was sized against — same padding,
     same rung, one control at one size — so they get the light at the button's
     size too. .btn--glow masks at 9em, which against the 17px body rung this
     is only ever seen at comes to just about this. */
  .faq-bubble--sent.is-edge-lit { --edge-reach: 150px; }

  .is-edge-lit::after {
    content: "";
    position: absolute;
    inset: 0;
    /* above the cover art, the scrim and the lock — it is the topmost thing
       on the card, and being a ring it never covers any of them */
    z-index: 2;
    border-radius: inherit;
    border: 1px solid rgba(var(--glow-rgb, 196, 176, 255), 0.9);
    -webkit-mask-image: radial-gradient(var(--edge-reach) circle at var(--gx) var(--gy), #000 20%, transparent 70%);
    mask-image: radial-gradient(var(--edge-reach) circle at var(--gx) var(--gy), #000 20%, transparent 70%);
    opacity: var(--glow-o);
    /* the case cards are buttons and the tiles open a lightbox: the rim must
       never be what a click lands on */
    pointer-events: none;
  }
}

/* ============================================================
   NAV
   ============================================================ */
/* a full-width bar, transparent over the hero and blurred once the page
   moves under it — the wordmark and Say hello sit on the page's own margins
   rather than inside a floating container. Inner pages have no hero, so
   their content starts under the bar and they ship .is-stuck in the markup. */
.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  height: var(--nav-h);
  transition: transform 0.45s var(--ease-out);
}
/* rides up out of the way on a downward scroll and comes straight back on an
   upward one, so the bar is there whenever you're heading for it and gone
   while you're reading. JS drives the class; see js/nav.js. */
.nav.is-hidden { transform: translateY(-100%); }
/* a keyboard user tabbing into the bar must never lose it mid-tab, whatever
   the last scroll direction was */
.nav:focus-within { transform: none; }

/* The backdrop, on a pseudo-element rather than the bar itself. It has to be
   its own layer for two reasons: it runs taller than --nav-h so the fade has
   somewhere to happen without moving the bar's own height (which half the
   layout measures against), and the mask that does the fading would
   otherwise fade the logo and the hello link along with it.

   Blur, not a dark scrim: no background fill at all, just a backdrop-filter
   faded out by a mask. The mask is what makes the blur itself fall off, so
   the page goes from fully blurred under the logo to untouched below with no
   line where one stops.

   The mask stops trace a smoothstep rather than a straight ramp — a linear
   fade has a visible start and finish and the eye reads both as edges. There
   are sixteen, which is also what keeps a fade this long from banding.

   backdrop-filter is declared in the .is-stuck rule below, NOT here: every
   engine keeps filtering the backdrop at opacity 0, which showed up as a
   permanent smudge under the bar over the hero. */
.nav::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  z-index: -1; /* behind .nav__inner; .nav's own z-index contains it */
  height: calc(var(--nav-h) * 2.4);
  /* a whisper of ground under the blur, flat rather than a gradient: the
     mask already handles the falloff, and it fades this and the blur
     together. Enough to hold the wordmark when something bright passes
     underneath, not enough to read as a bar. */
  background: rgba(5, 5, 5, 0.3);
  -webkit-mask-image: linear-gradient(to bottom,
    #000 0%,
    #000 34%,
    rgba(0, 0, 0, 0.985) 39%,
    rgba(0, 0, 0, 0.945) 43%,
    rgba(0, 0, 0, 0.882) 48%,
    rgba(0, 0, 0, 0.802) 53%,
    rgba(0, 0, 0, 0.708) 58%,
    rgba(0, 0, 0, 0.606) 62%,
    rgba(0, 0, 0, 0.500) 67%,
    rgba(0, 0, 0, 0.394) 72%,
    rgba(0, 0, 0, 0.292) 76%,
    rgba(0, 0, 0, 0.198) 81%,
    rgba(0, 0, 0, 0.118) 86%,
    rgba(0, 0, 0, 0.055) 91%,
    rgba(0, 0, 0, 0.015) 95%,
    rgba(0, 0, 0, 0) 100%);
  mask-image: linear-gradient(to bottom,
    #000 0%,
    #000 34%,
    rgba(0, 0, 0, 0.985) 39%,
    rgba(0, 0, 0, 0.945) 43%,
    rgba(0, 0, 0, 0.882) 48%,
    rgba(0, 0, 0, 0.802) 53%,
    rgba(0, 0, 0, 0.708) 58%,
    rgba(0, 0, 0, 0.606) 62%,
    rgba(0, 0, 0, 0.500) 67%,
    rgba(0, 0, 0, 0.394) 72%,
    rgba(0, 0, 0, 0.292) 76%,
    rgba(0, 0, 0, 0.198) 81%,
    rgba(0, 0, 0, 0.118) 86%,
    rgba(0, 0, 0, 0.055) 91%,
    rgba(0, 0, 0, 0.015) 95%,
    rgba(0, 0, 0, 0) 100%);
  /* it overhangs the bar, so it must never swallow a click on the page */
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.4s ease;
}
/* fades in after ~80px of scroll, so content never runs under a bare logo */
.nav.is-stuck::before {
  opacity: 1;
  backdrop-filter: blur(18px) saturate(180%);
  -webkit-backdrop-filter: blur(18px) saturate(180%);
}
/* ...and back out when the bar retires. Required, not decorative: the
   backdrop is 2.4x the bar's height, so translateY(-100%) moves it by only
   64 of its 154px and leaves 90px of gradient stranded on screen with no bar
   attached to it. Must stay after the .is-stuck rule — same specificity, and
   a hidden bar is always a stuck one. */
.nav.is-hidden::before { opacity: 0; }
.nav__inner {
  height: 100%;
  /* full-bleed with its own edge padding, not capped to --container — a
     centered bar looks stranded on wide screens */
  width: 100%;
  padding-inline: var(--edge-margin);
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.nav__logo {
  display: inline-flex;
  align-items: center;
  transition: opacity 0.3s ease;
}
@media (hover: hover) {
  .nav__logo:hover { opacity: 0.7; }
}
/* flex-none so the wordmark never gets squeezed before the gaps give */
.nav__logo-mark { height: 22px; width: auto; flex: none; }
/* the wordmark is wide for its height — at 22px it ran 149px, better than a
   third of a 390px screen, and crowded "Say hello" at the other end */
@media (max-width: 860px) {
  .nav__logo-mark { height: 18px; }
}
/* "Say hello 👋" — text right-aligned against the pinned emoji, so the
   label can swap width without the emoji drifting; the wave is pure
   transform on its own layer, so it never nudges the text either. */
.nav__hello {
  display: inline-flex;
  align-items: center;
  justify-content: flex-end;
  gap: 9px;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  font-weight: 400;
  letter-spacing: -0.01em;
  color: var(--ink);
  transition: opacity 0.3s ease;
}
@media (hover: hover) {
  .nav__hello:hover { opacity: 0.7; }
}

.nav__hello-text { display: inline-block; }

.nav__hello-emoji {
  display: inline-block;
  font-size: 1.35em; /* stays proportionally larger than the label */
  line-height: 1;
  will-change: transform;
  transform-origin: 72% 78%; /* pivot at the wrist */
}
@media (prefers-reduced-motion: no-preference) {
  .nav__hello-emoji.is-waving { animation: hello-wave 0.9s ease-in-out; }
}
@keyframes hello-wave {
  0%, 100% { transform: rotate(0deg); }
  15% { transform: rotate(20deg); }
  35% { transform: rotate(-12deg); }
  55% { transform: rotate(16deg); }
  75% { transform: rotate(-6deg); }
}

/* ============================================================
   HERO
   ============================================================ */
.hero {
  position: relative;
  min-height: 100svh;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  /* reproduces .container's left edge, so the intro starts on the same line
     as every section heading below it instead of floating in the middle */
  padding-inline: max(var(--page-margin), (100% - var(--container)) / 2);
  overflow: hidden;
}
.hero__content {
  position: relative;
  z-index: 1;
  /* the root --col is no use in here. Its --content-w is built on
     `min(100% - 2 * --page-margin, --container)`, and that percentage
     resolves against the containing block where it is finally used — which
     is .hero's content box, already narrowed by padding-inline. So the page
     margin came off twice and the hero's columns ran narrower than every
     other section's, by a different amount at each viewport.
     That padding-inline is max(--page-margin, (100% - --container) / 2):
     the same two cases the --content-w min() picks between, so the hero's
     content box already IS the page content width. 100% is the whole story
     here, and --col has to be restated to pick that up — the root one
     substituted its --content-w back at :root and inherits down resolved. */
  --content-w: 100%;
  --col: calc((100% - (var(--grid-cols) - 1) * var(--gutter)) / var(--grid-cols));

  /* 11 grid columns, one column of air left at the right edge, but never a
     longer measure than the copy can carry.
     font-size is set here so `ch` resolves against the headline's own type:
     it has to track --fs-h1, or the cap is measured in the wrong em. The
     count drops with the size — 52ch was a body measure, and at this rung
     it would run wider than the container.
     The 40ch cap stays a backstop rather than the thing doing the work:
     eleven columns come to 1054px against the 1152 container, and 40ch is
     ~1292px at 48px type, so the columns still win on wide screens. It only
     bites if the container grows or the span goes wider again. */
  --span: 11;
  font-size: var(--fs-h1);
  line-height: var(--lh-h1);
  width: min(
    calc(var(--span) * var(--col) + (var(--span) - 1) * var(--gutter)),
    40ch
  );
}

/* one flowing sentence, set as a section header rather than body copy: the
   intro is the page's opening statement, so it takes the same rung, leading,
   tracking and weight as every .section__title below it */
.hero__headline {
  font-size: var(--fs-h1);
  line-height: var(--lh-h1);
  letter-spacing: -0.02em;
  font-weight: var(--fs-heading-weight);
  color: var(--ink-dim);
  margin-bottom: var(--s-3);
  text-wrap: pretty;
}
/* the name carries full contrast against the dimmed sentence — and only the
   name. Meta sits in the sentence at --ink-dim like everything else, so the
   one bright thing in the hero stays who this is. */
.hero__name { color: var(--ink); }

/* below the grid's breakpoint the gutters dominate a column-based width,
   so the intro falls back to the full content measure */
@media (max-width: 860px) {
  .hero__content { width: var(--content-w); }
}

/* ---------- exit ----------
   The intro used to simply scroll off. This lifts it slightly faster than the
   page moves and dims it on the way out, so "Where I can add value" arrives on
   clean ground instead of sliding up past a full-contrast headline.

   Scroll-driven, so there is no listener and no rAF anywhere in it: the
   animation's progress IS the scroll offset, which is why it cannot fall a
   frame behind the page the way a JS parallax does.

   Held flat through the first third of the range, which is the setting that
   matters most here. The intro is the most important copy on the site, and
   dimming it while it still sits square in the middle of the window reads as
   the text failing rather than as the page moving on — the fade has to start
   late enough that it is clearly an exit. At the 20% this shipped with, the
   headline was down to 0.6 while still fully on screen. It also has to
   absorb a trackpad settle or a few pixels of rubber-band.

   55svh is about where the centred copy has cleared the top of the window —
   the hero is min-height: 100svh and the intro sits on its middle. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .hero__content {
      animation: hero-exit linear both;
      animation-timeline: scroll(root block);
      animation-range: 0 55svh;
    }
  }
}
@keyframes hero-exit {
  0%, 32% { opacity: 1; transform: none; }
  100%    { opacity: 0; transform: translateY(-64px); }
}

/* word-by-word entrance (applied by JS via [data-split]): each word rises
   through a mask, so it's revealed rather than flown in — no rotation,
   no fade, nothing that reads as an effect */
.hero__headline .w {
  display: inline-block;
  overflow: hidden;
  vertical-align: top;
  /* the mask would otherwise crop descenders */
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}
.hero__headline .w__i {
  display: inline-block;
  transform: translateY(110%);
}
/* The rise waits for Geist. The face is font-display: swap, so the first
   paint sets the headline in the fallback — starting at 80ms meant the words
   rose in Helvetica and re-laid-out to Geist partway through, every word
   changing width mid-flight and the line breaks moving with them.
   js/main.js adds .is-set on document.fonts.ready, with a 900ms cap so a font
   that never arrives can't leave the headline masked. */
.hero__headline.is-set .w__i {
  animation: word-rise 0.85s cubic-bezier(0.16, 1, 0.3, 1) forwards;
  /* tight stagger — the headline is a full sentence now, not three words */
  animation-delay: calc(var(--i) * 18ms + 80ms);
}
@keyframes word-rise {
  to { transform: none; }
}

/* the pills grew to the FAQ's size, so the 8px that separated them at the
   old scale now reads as a seam between two halves of one control */
.hero__actions { display: flex; gap: var(--s-2); flex-wrap: wrap; }
.btn__icon { height: 1.05em; width: auto; fill: currentColor; }

.hero__action {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  letter-spacing: -0.01em;
  font-weight: 400;
  color: var(--ink);
  cursor: pointer;
}
.hero__action-icon {
  height: 1.15em;
  width: auto;
  fill: currentColor;
}
/* linklike underline, under the text only */
.hero__action-label { position: relative; }
.hero__action-label::after {
  content: "";
  position: absolute;
  left: 0; bottom: -3px;
  width: 100%; height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.35s var(--ease-out);
}
@media (hover: hover) {
  .hero__action:hover .hero__action-label::after {
    transform: scaleX(1);
    transform-origin: left;
  }
}

/* ============================================================
   SCROLL REVEAL (applied by JS)
   ============================================================ */
/* The baseline: an observer flips .is-visible once, and the transition plays
   it out in time. Still the whole story in Firefox and older Safari. */
[data-reveal] {
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 0.5s var(--ease-out), transform 0.5s var(--ease-out);
  transition-delay: var(--reveal-delay, 0s);
}
[data-reveal].is-visible { opacity: 1; transform: none; }

/* No JS, so nothing will ever add .is-visible and the rule above would leave
   the page permanently blank. The reveal is an enhancement; without it the
   content simply starts where it would have ended up.

   This matters now that scripts/prerender.mjs stamps the sections into the
   served HTML: the copy is there in the markup, and hiding it at opacity 0
   for anyone whose JS failed would waste that. Browsers with view()
   timelines still animate — see below, that path needs no script.

   The class is set by an inline script in <head>, before first paint, so
   there is no flash of content that then hides itself. */
html:not(.js) [data-reveal] { opacity: 1; transform: none; }

/* ---------- scroll-driven, where the browser can ----------
   The same short rise, tied to scroll position instead of fired once by an
   observer crossing a fixed threshold. Two things fall out of that: a reveal
   can never land mid-flick, because progress is position rather than elapsed
   time; and scrolling back up plays it in reverse instead of leaving a
   section permanently spent. The animation origin outranks .is-visible, so
   the observer above keeps running and simply stops being what you see.

   The JS stagger is a delay, and there is no time here to delay — so the step
   moves into the range instead. Each element in a group finishes one
   --reveal-step further up the page than the one before it: same order, same
   shape, expressed in scroll distance.

   Two animations on the one timeline, because a section arrives and a
   section leaves, and those are different events at different ends of the
   page. reveal-rise brings it in off the bottom; reveal-leave takes it out
   past the top on the hero's treatment — dim, plus the small lift set by
   --reveal-lift. The pair is why the page reads as content passing a window
   rather than a column sliding under a mask.

   The fill modes are what keep them from fighting. reveal-rise is `both`, so
   it holds the element hidden below the fold and visible once it has played.
   reveal-leave is `forwards`, NOT `both`: a backwards fill would apply its
   opacity: 1 from-frame during the entry range, and being second in the list
   it would outrank reveal-rise and cancel the fade-in entirely. With
   `forwards` it contributes nothing until its own range starts.

   The leave range is anchored to the element's bottom edge, not its top:
   `exit 100%` is the moment the last of it clears the top of the window, and
   the dim runs over the 240px before that. Anchoring it to the top edge
   instead would start fading a tall panel — the questions thread, the value
   grid — while its middle was still on screen being read. This way an
   element is always nearly gone before it starts to go.

   The card rows opt out. .cases__row sets overflow-x, which computes
   overflow-y to auto and makes the row itself the nearest block-axis scroll
   container — a view() timeline inside it would resolve against a scroller
   that never scrolls and sit at 0% forever, leaving every card invisible.
   They keep the observer, which is what js/card-row.js wants regardless:
   the row is meant to reveal as one object, not card by card. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-reveal] {
      --reveal-step: 56px;
      animation:
        reveal-rise linear both,
        reveal-leave linear forwards;
      animation-timeline: view(), view();
      animation-range:
        entry calc(var(--reveal-i, 0) * var(--reveal-step))
          entry calc(240px + var(--reveal-i, 0) * var(--reveal-step)),
        exit calc(100% - 240px) exit 100%;
    }
    .cases__row [data-reveal] { animation: none; }

    /* The archive tiles take the leave and nothing else: their entrance is
       js/gallery.js's business, tied to lazy loading and the infinite scroll
       rather than to a range. Masonry columns end at different heights, so
       the tiles reach `exit 100%` at slightly different moments and the top
       of the grid dissolves unevenly instead of as one bar. */
    .tile {
      animation: reveal-leave linear forwards;
      animation-timeline: view();
      animation-range: exit calc(100% - 240px) exit 100%;
    }
  }
}
@keyframes reveal-rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}
/* explicit from-frame rather than leaning on the implicit one: it has to be
   the value reveal-rise settles at, and stating it is cheaper to read than
   the underlying-value rule that would otherwise supply it */
@keyframes reveal-leave {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(calc(-1 * var(--reveal-lift))); }
}

/* ============================================================
   FOOTER
   ============================================================ */
/* a solid band rather than a hairline — same fill as the FAQ thread panel,
   so the two read as the same surface */
.footer {
  background: rgba(255, 255, 255, 0.03);
  padding: var(--s-3) 0;
  overflow: hidden;
}
/* full-bleed like .nav__inner, with the same fixed edge padding instead of
   being capped to --container — see --edge-margin */
.footer__inner {
  width: 100%;
  padding-inline: var(--edge-margin);
}

/* email on the left content edge, Social centred on the page, copyright on
   the right content edge. Equal 1fr tracks either side of an auto middle is
   what actually centres Social — space-between would only centre it if the
   two outer items happened to be the same width */
.footer__row {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  /* centred, not baseline: the social group is a row of 40px icon targets
     rather than a line of text, and baseline would hang the other two
     columns off whatever the icons' synthesised baseline happens to be */
  align-items: center;
  gap: var(--s-2) var(--s-5);
  font-size: var(--fs-small);
  line-height: var(--lh-small);
}
.footer__row > :first-child { justify-self: start; }
.footer__row > :last-child { justify-self: end; }

/* not a link, so it reads at the same weight as the group labels */
.footer__copy { color: var(--ink-faint); }

.footer__group {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--s-2);
}

.footer__label {
  font-size: inherit;
  font-weight: 400;
  color: var(--ink-faint);
}

/* unlabelled, so the marks are the whole group: centred in the row's middle
   column, on a tighter gap than the text groups — brand marks read as a set
   when they are close together, and as a list when spaced apart like words */
.footer__group--social {
  align-items: center;
  justify-content: center;
  gap: 6px;
}
/* A fixed square around a 20px glyph: the box is the tap target, the space
   around the mark is the spacing. Padding plus a negative margin would give
   the same target while letting the boxes overlap their neighbours. */
.footer__icon {
  display: inline-grid;
  place-items: center;
  width: 40px;
  height: 40px;
}
.footer__icon svg {
  width: 20px;
  height: 20px;
  fill: currentColor;
  display: block;
}

/* same link treatment as everywhere else: white, dimming on hover */
.footer__link {
  color: var(--ink);
  padding: 0;
  text-align: left;
  transition: color 0.25s ease;
}
@media (hover: hover) {
  .footer__link:hover { color: var(--ink-dim); }
}

/* Play is a <button> so it isn't a fake link, but it has to sit on the same
   baseline as the real ones around it */
.footer__play { font: inherit; }
.footer__play[disabled] { color: var(--ink-faint); cursor: default; }
.footer__play[disabled]::after { display: none; }

/* the round's result appears next to Play rather than in a card, so the
   footer never changes height when a game ends */
.footer__result {
  color: var(--ink-faint);
  opacity: 0;
  transition: opacity 0.4s ease;
}
.footer__result.is-shown { opacity: 1; }

/* the game is keyboard-only — below this there's nothing to press */
@media (max-width: 860px) {
  .footer__play, .footer__result { display: none; }
}

/* the row stacks into one centred column once it can't stay on one line:
   marks, then the copyright directly under them */
@media (max-width: 860px) {
  .footer__row {
    grid-template-columns: 1fr;
    align-items: center;
    justify-items: center;
    gap: var(--s-2);
  }
  .footer__row > :first-child,
  .footer__row > :last-child { justify-self: center; }

  /* The address goes. The nav's "Say hello" is the same mailto and is on
     screen the whole way down, so this is a long line of text repeating a
     link the page already has — and it was the one thing keeping the stack
     from centring cleanly. Scoped off .footer__icon so the social marks,
     which share .footer__link, are untouched. */
  .footer__row > .footer__link:not(.footer__icon) { display: none; }
}

/* ============================================================
   MOTION SAFETY
   ============================================================ */
@media (prefers-reduced-motion: reduce) {
  /* the scroll-driven rules above are already behind no-preference, so this
     block only has to deal with the time-based ones — plus the navigation
     transition, which is turned off at the source rather than by neutering
     its pseudo-elements. Nesting @view-transition in a conditional group is
     allowed, and a browser that doesn't understand it skips the rule without
     disturbing the rest of the block. */
  @view-transition { navigation: none; }

  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  [data-reveal] { opacity: 1; transform: none; }
  /* the headline is masked, so it has to be released explicitly. Both
     selectors, because the gated rule that carries the animation outranks the
     plain one — releasing only `.hero__headline .w__i` would leave .is-set
     still animating, and the hammer above zeroes the duration but not the
     per-word delay, so the words would flicker in one at a time. */
  .hero__headline .w__i,
  .hero__headline.is-set .w__i { transform: none; animation: none; }
}
