/* ═══════════════════════════════════════════════════════════════
   Amogh Meshram — Website 2
   Visual language follows the Cohesion template: white canvas,
   very round cards, floating pill nav, glossy 3D orbs, giant
   display type, indigo/orange/mint accents.
   ═══════════════════════════════════════════════════════════════ */

/* ─── TOKENS ──────────────────────────────────────────────────── */
:root {
  --white:      #ffffff;
  --ink:        #000000;
  --ink-soft:   #1a1a1a;
  --grey:       #5b5b66;
  --grey-light: #6e6e79;   /* 5.04:1 on white, 4.65:1 on tint — AA at small sizes */
  --hair:       #ececf2;

  --indigo:      #6670ff;  /* fills, borders, decoration */
  --indigo-text: #5560f0;  /* 4.86:1 — use whenever indigo carries text */
  /* Anything that holds WHITE text on indigo: the solid buttons, the active nav pill,
     and the portrait card. #6670ff only reaches 3.94:1 against white, so this shade is
     a touch deeper. All three share it so the button and the photo card match exactly. */
  --indigo-solid: #5560f0;
  --indigo-05:   rgba(102,112,255,.05);
  --indigo-10:   rgba(102,112,255,.10);
  --indigo-20:   rgba(102,112,255,.20);

  --orange:       #f94706; /* decorative only */
  --orange-solid: #d63a04; /* 4.70:1 with white text — filled buttons */
  --orange-text:  #c2380a; /* 5.43:1 — orange text on light */
  --orange-soft:  #ff7a45;
  --mint:        #66ffd9;
  --mint-deep:   #00cc99;

  --font: "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  --serif: "PT Serif", Georgia, serif;

  --r-card: 48px;
  --r-sm:   24px;
  --r-pill: 120px;

  --wrap: 68rem;

  /* How wide the decorative layer is allowed to get: the hero's orbs, the name band
     behind the photo and the stack band under it. All three are anchored to the
     viewport edges rather than to the content, so on a wide monitor they keep drifting
     apart while the text column stays at --wrap — past about 1600px the bubbles are out
     in the far corners with a lake of white between them and everything they annotate.
     Capping them holds the composition together.

     90rem is deliberately wider than --wrap: these belong outside the text column, just
     not in the next postcode. It's also above most laptop widths, so this changes
     nothing below ~1440px — it only stops the spread beyond it. */
  --field-max: 90rem;

  /* Hero photo width — the blue card and the cut-out both take it, so this one value
     sizes the whole portrait. Lives here because the phone layout parks the skill
     bubbles against the photo's edges and needs to know how wide it is.

     It was min(330px, 78vw), where the 78vw term only bound below ~423px — so the photo
     sat at a flat 330px from a 1400px desktop all the way down to the 760px breakpoint,
     while the name band behind it (13vw) and the headline above it (7vw) shrank the whole
     way. By a tablet the photo had gone from a quarter of the screen's width to nearly
     half, and it swamped everything it was meant to sit among.

     Now it tapers with the viewport like they do. The ceiling holds the desktop rendering
     at exactly the 330px it was, down to ~1180px; the floor is in rem so it takes the
     root's step down at 1024px, and it lands close enough to the 760px block's own value
     that the handover doesn't read as a jump. */
  --photo-w: clamp(14rem, 28vw, 20.625rem);

  --ease: cubic-bezier(.22,.61,.36,1);
  --fast: 180ms;
  --med:  320ms;
}

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: 7rem;
}

/* ─── ROOT TYPE SCALE ─────────────────────────────────────────
   Two steps down for small screens: 2px off at tablet and 2px more at phone, taken at
   the root rather than element by element. Nearly everything on this page is sized in
   rem, so moving the root moves the type, the padding and the gaps together and keeps
   every proportion that was tuned at full size — which the alternative, a list of
   per-element overrides, would drift out of the moment anything changed.

   Percentages, not pixels: 87.5% of 16 is 14 and 75% is 12, but a reader who has set a
   larger default in their browser still gets it scaled from their number rather than
   overridden by ours.

   Two things deliberately don't follow. Sizes written in px stay put — the gallery's
   row height, the CV logos, the hero orbs — because those are sized to images and
   layout, not to text. And the vw term inside a clamp() is unaffected; what moves is
   the rem floor and ceiling around it, which is where the headings get their reduction.

   The 1024px rule has to come first: both match on a phone, so the narrower one only
   wins by being later. */
@media (max-width: 1024px) { html { font-size: 87.5%; } }   /* ≈14px */
@media (max-width: 640px)  { html { font-size: 75%; } }     /* ≈12px */

body {
  margin: 0;
  background: var(--white);
  color: var(--ink);
  font-family: var(--font);
  font-size: 1.0625rem;
  line-height: 1.62;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

h1, h2, h3 { margin: 0; font-weight: 800; line-height: 1.1; letter-spacing: -.02em; }
p { margin: 0 0 1rem; }
ul, ol { margin: 0; padding: 0; list-style: none; }
img { display: block; max-width: 100%; height: auto; }
sub { font-size: .68em; line-height: 0; }
b, strong { font-weight: 700; }

a { color: var(--indigo-text); text-underline-offset: .18em; transition: color var(--fast) var(--ease); }
a:hover { color: var(--orange-text); }

:focus-visible { outline: 3px solid var(--indigo); outline-offset: 3px; border-radius: 6px; }

.wrap { width: 100%; max-width: var(--wrap); margin-inline: auto; padding-inline: 1.5rem; }
.center { text-align: center; }
/* A call to action closing a list. The last .service already carries 2.25rem of its own
   bottom padding, which is the list's internal rhythm rather than a gap before something
   new — without this the button reads as a seventh row. In rem so it steps down with the
   root the same way the button does. */
.center--cta { margin-top: 1.75rem; }

.skip-link {
  position: absolute; top: -100px; left: 1rem; z-index: 999;
  padding: .75rem 1.25rem; border-radius: var(--r-pill);
  background: var(--ink); color: var(--white); text-decoration: none;
}
.skip-link:focus { top: 1rem; color: var(--white); }

/* ─── PILLS / BUTTONS ─────────────────────────────────────────── */
/* min-height in rem so the box comes down with the type at each breakpoint — a button
   whose padding shrinks but whose height doesn't just grows slack around the label.
   max() puts a hard floor under it: 44px is the smallest comfortable touch target, and
   that matters most on exactly the screens the scale is shrinking things for. */
.pill {
  display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
  min-height: max(44px, 2.875rem);
  padding: .7rem 1.5rem;
  border: 2px solid transparent;
  border-radius: var(--r-pill);
  font-family: var(--font);
  font-size: .95rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: transform var(--fast) var(--ease), background var(--fast) var(--ease),
              border-color var(--fast) var(--ease), color var(--fast) var(--ease),
              box-shadow var(--fast) var(--ease);
}
.pill:active { transform: scale(.97); }

.pill--solid { background: var(--indigo-solid); color: var(--white); }
.pill--solid:hover { background: var(--ink); color: var(--white); }

.pill--outline {
  background: var(--white); color: var(--ink);
  border-color: var(--indigo-20);
  box-shadow: 0 8px 24px var(--indigo-10);
}
.pill--outline:hover {
  color: var(--ink);
  border-color: var(--indigo);
  transform: translateY(-2px);
  box-shadow: 0 14px 34px var(--indigo-20);
}

/* The two big CTAs. 1rem rather than 1.15 so the label steps 16 / 14 / 12 — exactly 2px
   at each breakpoint, the same rule the rest of the page follows — and the box tracks it
   at 62 / 54 / 47px. It stays clear of the 44px floor at every size, so the button gets
   smaller without ever getting harder to hit. */
.pill--lg { min-height: max(44px, 3.875rem); padding: 1rem 2.5rem; font-size: 1rem; font-weight: 700; }

/* ─── TRAVELLING GLOW ─────────────────────────────────────────
   A light running slowly round the edge of one button. Registering the angle is what makes
   it possible at all: a plain custom property is just a string to the animation engine, so
   --glow-turn interpolates from 0 to 360 only because @property has told the browser it is
   an <angle>. Without that the gradient would sit frozen wherever it started.

   All three CTAs wear it. They are the same invitation asked three times on the way down
   the page, and they are far enough apart that no two are ever on screen together, so the
   mark reads as a convention rather than as three things competing. */
@property --glow-turn {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

.pill--glow { position: relative; isolation: isolate; }

/* Both layers are the same ring: a comet with a bright head and a tail fading over about
   a third of the way round, hollowed out by the mask so only the rim is painted. The trick
   is the two masks composited to exclude — one covering the whole box, one covering the
   box inside the padding — which leaves exactly the 3px band between them. */
.pill--glow::before,
.pill--glow::after {
  content: "";
  position: absolute;
  inset: -3px;
  padding: 3px;
  border-radius: inherit;
  background: conic-gradient(from var(--glow-turn),
    rgba(102,112,255,0)   0deg,
    rgba(102,112,255,.95) 42deg,
    rgba(102,112,255,.28) 78deg,
    rgba(102,112,255,0)   120deg,
    rgba(102,112,255,0)   360deg);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask-composite: exclude;
  /* Behind the label but in front of the button's own white fill — which is what isolation
     on the parent buys: the pseudo-elements paint after its background, before its text. */
  z-index: -1;
  pointer-events: none;
  animation: pill-glow 5.5s linear infinite;
}
/* The second copy, blurred and dimmed, is what makes it read as light rather than as a
   stroke drawn round the button. */
.pill--glow::after { filter: blur(7px); opacity: .8; }

@keyframes pill-glow { to { --glow-turn: 360deg; } }
.pill--sm { min-height: 40px; padding: .45rem 1.1rem; font-size: .85rem; border-color: var(--hair); background: var(--white); color: var(--grey); }
.pill--sm:hover { border-color: var(--indigo); color: var(--ink); }
.pill--sm.is-active { background: var(--ink); border-color: var(--ink); color: var(--white); }

/* ─── NAV ─────────────────────────────────────────────────────── */
.navbar {
  position: fixed; top: 1rem; left: 0; right: 0; z-index: 100;
  display: flex; justify-content: center; align-items: center; gap: .6rem;
  padding-inline: 1rem;
  pointer-events: none;
}
.navbar > * { pointer-events: auto; }

.pillnav {
  background: rgba(255,255,255,.86);
  backdrop-filter: blur(14px);
  border: 1px solid var(--hair);
  border-radius: var(--r-pill);
  box-shadow: 0 8px 30px rgba(0,0,0,.07);
  padding: .35rem;
}
.pillnav__list { display: flex; align-items: center; gap: .15rem; }
.pillnav__link {
  display: block;
  padding: .5rem 1rem;
  border-radius: var(--r-pill);
  color: var(--grey);
  font-size: .88rem;
  font-weight: 600;
  text-decoration: none;
  white-space: nowrap;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.pillnav__link:hover { background: var(--indigo-05); color: var(--ink); }
.pillnav__link.is-active { background: var(--indigo-solid); color: var(--white); }

.navbar__cta { min-height: 44px; padding: .6rem 1.4rem; font-size: .88rem; }

.burger {
  display: none;
  width: 48px; height: 48px;
  padding: 0 12px;
  background: rgba(255,255,255,.9);
  backdrop-filter: blur(14px);
  border: 1px solid var(--hair);
  border-radius: var(--r-pill);
  box-shadow: 0 8px 30px rgba(0,0,0,.07);
  cursor: pointer;
}
.burger span { display: block; height: 2px; margin: 5px 0; background: var(--ink); border-radius: 2px; transition: transform var(--med) var(--ease); }
.burger[aria-expanded="true"] span:first-child { transform: translateY(3.5px) rotate(45deg); }
.burger[aria-expanded="true"] span:last-child  { transform: translateY(-3.5px) rotate(-45deg); }

.drawer {
  position: fixed; top: 5rem; left: 1rem; right: 1rem; z-index: 99;
  padding: 1rem;
  background: var(--white);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  box-shadow: 0 20px 50px rgba(0,0,0,.12);
}
.drawer[hidden] { display: none; }
.drawer a {
  display: block; padding: .85rem 1rem;
  border-radius: 14px;
  color: var(--ink); font-weight: 600; text-decoration: none;
}
.drawer a:hover { background: var(--indigo-05); color: var(--ink); }

/* ─── ORBS (glossy 3D spheres, pure CSS) ──────────────────────── */
/* Capped and centred rather than stretched to the section. The orbs are placed in
   percentages, so the box they're measured against is what decides how far apart they
   drift — narrow the box and they close up on their own, no per-orb overrides needed.
   left and right are both 0 and the width is set, which over-constrains the box; auto
   inline margins are what absolute positioning resolves that with, and they resolve it
   by splitting the slack evenly. min() means the cap is inert below 90rem, so the phone
   layout — which anchors the orbs off 50% of this box — is untouched. */
.orbs {
  position: absolute;
  inset: 0;
  width: min(100%, var(--field-max));
  margin-inline: auto;
  z-index: 1;
  overflow: hidden;
  pointer-events: none;
}
/* Soap-bubble treatment: mostly clear in the middle, colour gathering at the rim,
   one sharp specular highlight plus a soft bounce light underneath. Each orb sets
   --rgb; every layer is an alpha of that one colour so the hues stay as they were. */
.orb {
  position: absolute;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background:
    /* sharp specular highlight, upper left */
    radial-gradient(circle at 28% 23%,
      rgba(255,255,255,.98) 0%,
      rgba(255,255,255,.75) 4%,
      rgba(255,255,255,0)  15%),
    /* soft bounce light, lower right */
    radial-gradient(circle at 71% 79%,
      rgba(255,255,255,.55) 0%,
      rgba(255,255,255,0)  18%),
    /* the film itself — clear centre, colour pooling at the edge */
    radial-gradient(circle at 50% 50%,
      rgba(var(--rgb), .06) 0%,
      rgba(var(--rgb), .12) 50%,
      rgba(var(--rgb), .40) 80%,
      rgba(var(--rgb), .70) 93%,
      rgba(var(--rgb), .38) 100%);
  box-shadow:
    inset 0 -8px 20px -6px rgba(var(--rgb), .40),
    inset 4px 6px 18px -6px rgba(255,255,255,.95),
    0 12px 28px -14px rgba(var(--rgb), .55);
  -webkit-backdrop-filter: blur(1.5px);
          backdrop-filter: blur(1.5px);
  animation: float 9s ease-in-out infinite;
}
/* No rotation — these carry text now and it has to stay upright.
   --float is the drift distance; the phone layout dials it down so the bubbles stay
   inside their bands instead of wandering into the headline or the CTA. */
@keyframes float {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(var(--float, -20px)); }
}

/* Dark ink on the bright spheres, not white. White measured only 2.4-3.0:1 against
   these gradients; near-black lands at 7.4-13:1, so the spheres keep the reference's
   bright playful colour instead of being darkened to carry white text. */
.orb__label {
  padding: 0 .45em;             /* em, so the inset shrinks with the type */
  color: #17171c;
  font-family: var(--serif);
  font-style: italic;
  font-weight: 700;
  /* Scales the whole way down instead of switching off. Capped at .95rem so the
     desktop size is exactly what it was; the .5rem floor is what a 320px phone gets. */
  font-size: clamp(.5rem, 1.6vw, .95rem);
  line-height: 1.15;
  letter-spacing: 0;
  text-align: center;
  text-shadow: 0 1px 2px rgba(255,255,255,.55);
}

/* Hues kept from the original ironmaking palette (orange = hematite, steel = reduced
   iron, teal = hydrogen) even though the labels are now capability terms — the colours
   were chosen for contrast and balance, not meaning. Longest labels sit on the biggest
   spheres: "Multiphase Flow" on --2 (130px), "Heat Transfer" on --1 (124px). */
.orb--1 { --rgb: 249,115,22;  width: 124px; height: 124px; top: 11%; left: 3%;   animation-delay: -1s; }
.orb--2 { --rgb: 16,185,152;  width: 130px; height: 130px; top: 5%;  right: 4%;  animation-delay: -3s; }
.orb--3 { --rgb: 102,112,255; width: 122px; height: 122px; top: 40%; left: 6%;   animation-delay: -5s; }
.orb--4 { --rgb: 71,105,160;  width: 116px; height: 116px; top: 45%; right: 4%;  animation-delay: -2s; }
.orb--5 { --rgb: 245,165,36;  width: 114px; height: 114px; top: 73%; left: 11%;  animation-delay: -6s; }
.orb--6 { --rgb: 139,147,255; width: 118px; height: 118px; top: 72%; right: 11%; animation-delay: -4s; }

/* ─── GLASS OBJECTS (contact section) ─────────────────────────── */
/* The same blown-glass idea as the orbs, but with a silhouette: a phone and an envelope,
   which say "get in touch" where a bubble only decorates. SVG rather than a clip-path on
   a .orb, because these carry interior detail — a screen, a flap — that a clipped box
   can't hold. Everything still keys off one --rgb, so the section's two accents are
   unchanged; only the outline is new. */
.glass {
  position: absolute;
  animation: float 9s ease-in-out infinite;
  filter: drop-shadow(0 14px 24px rgba(var(--rgb), .32));
}
.glass svg { display: block; width: 100%; height: auto; }

/* Gradient stops as CSS so both shapes share one definition and inherit --rgb.
   Clear and bright at the top-left, colour pooling into the lower-right — the same
   read as the spheres' radial fill, flattened onto a diagonal. */
.glass .g0 { stop-color: #fff;             stop-opacity: .92; }
.glass .g1 { stop-color: rgb(var(--rgb));  stop-opacity: .10; }
.glass .g2 { stop-color: rgb(var(--rgb));  stop-opacity: .50; }

.glass__rim   { fill: none; stroke: rgba(var(--rgb), .55); stroke-width: 2; }
.glass__pane  { fill: rgba(255,255,255,.30); stroke: rgba(var(--rgb), .34); stroke-width: 1.6; }
.glass__fold  { stroke: rgba(var(--rgb), .52); stroke-width: 2.4; fill: none;
                stroke-linecap: round; stroke-linejoin: round; }
.glass__fold--soft { stroke: rgba(var(--rgb), .3); stroke-width: 2; }
.glass__tick  { fill: rgba(var(--rgb), .42); }
/* Two parallel diagonal bands, wide then narrow, clipped to the silhouette — the flat
   equivalent of the spheres' specular highlight. */
.glass__sheen { fill: rgba(255,255,255,.5); }
.glass__sheen--thin { fill: rgba(255,255,255,.72); }

/* Heights come from the viewBox, so only width is set. Orange = the hero's hematite,
   mint = its hydrogen; the section's palette is what it was. */
.glass--phone { --rgb: 249,115,22;  width: 62px;  top: 13%;    left: 7%;  animation-delay: -2s; }
.glass--mail  { --rgb: 16,185,152;  width: 104px; bottom: 16%; right: 6%; animation-delay: -5s; }

/* ─── HERO ────────────────────────────────────────────────────── */
.hero { position: relative; padding: 9rem 0 4rem; overflow: hidden; }
.hero__inner { position: relative; z-index: 2; max-width: 44rem; margin-inline: auto; padding-inline: 1.5rem; text-align: center; }

/* One line at every width, which is why this is not a clamp.
   The headline is deliberately not held to .hero__inner's 44rem measure — that measure
   is for prose, and 44rem leaves only 656px of text room while "Hi, I'm Amogh — CFD
   Engineer!" needs 992px at full size. The negative inline margins let the box spill
   both ways so the line has room; .hero already clips overflow, so nothing reaches the
   page. Margins are symmetric, so the text stays centred on the page centre and the
   scrollbar cannot pull it off-axis the way a 100vw breakout would.

   The divisor is how wide the string is in ems — measured at 14.1 with Public Sans and
   PT Serif loaded — plus about 8% headroom, so a fallback font with wider metrics still
   fits before the webfont arrives. Below ~1120px the size follows the viewport; above
   it the 4.4rem ceiling holds, which is exactly the size this line had before.
   EDIT THE TEXT AND THIS DIVISOR MUST BE RE-MEASURED. */
.hero__greet {
  margin-inline: -50vw;
  font-size: min(calc((100vw - 3rem) / 15.2), 4.4rem);
  white-space: nowrap;
  font-weight: 800;
  letter-spacing: -.035em;
}

/* From the tablet layout up, the orbs sit in the top corners at this line's height, so
   the real limit is the gap BETWEEN them, not the viewport — widening the headline to
   full width ran "Engineer!" straight through the Multiphase Flow bubble.

   Each bound is the clear span divided by the same 14.1em string width. The span is
   (viewport - both inset percentages - both orb widths), less 24px of breathing room:

     761-1100px   orbs inset 2%, 104px and 100px wide   ->  96vw - 276px
     1101px+      orbs inset 3%/4%, 124px and 130px     ->  93vw - 326px

   The allowance is 48px, not the 24px the geometry needs, because vw counts the
   scrollbar and the orbs' percentage insets do not. On a 1267px window with a 15px
   scrollbar that mismatch alone spent 15px of clearance and left the right-hand orb
   8px from the text. The extra 24px covers the widest scrollbar likely to turn up.

   Below 761px the orbs drop into columns beside the portrait, well under the headline,
   so the base rule above governs and neither of these applies. Move an orb and these
   numbers move with it. */
@media (min-width: 761px) and (max-width: 1100px) {
  .hero__greet { font-size: min(calc((96vw - 276px) / 14.1), 4.4rem); }
}
@media (min-width: 1101px) {
  .hero__greet { font-size: min(calc((93vw - 326px) / 14.1), 4.4rem); }
}
.hero__greet em { font-family: var(--serif); font-style: italic; font-weight: 700; letter-spacing: -.01em; }

.hero__role {
  margin: .75rem 0 2rem;
  min-height: 1.7em;          /* reserves the line so swaps don't nudge the layout */
  font-size: clamp(.9rem, 2.2vw, 1.2rem);
  font-weight: 500;
  color: var(--grey);
}

/* Rotating skill line. JS sets the slot to the current phrase's exact width so the
   whole "I do <phrase>" line stays centred; transitioning min-width makes the line
   glide to its new centre rather than the prefix snapping sideways. Whether that set
   happens before or after the text swaps depends on which way the box is going — see
   main.js, and don't change min-width to width without reading that note. */
.rotator {
  display: inline-block;
  text-align: center;
  vertical-align: bottom;
  transition: min-width 300ms var(--ease);
  will-change: opacity, transform;
}
.rotator.is-out { animation: roleOut 300ms var(--ease) forwards; }
.rotator.is-in  { animation: roleIn  420ms var(--ease); }
@keyframes roleOut { to   { opacity: 0; transform: translateY(-10px); } }
@keyframes roleIn  { from { opacity: 0; transform: translateY(10px); } }

/* Visible to screen readers only */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.portrait {
  width: var(--photo-w);
  margin: 0 auto 1.75rem;
  /* Shallow on purpose. A longer perspective would need something like 20deg of turn to
     read as tilted at all, which starts to look like a mistake; at this distance 13deg
     gives the reference's foreshortening — near edge about 8% taller than the far one.

     Tied to the card's own width rather than fixed at 900px, because how strong a
     perspective looks depends on the viewing distance relative to the object. At a
     fixed 900px the phone's 120px card came out at 3% foreshortening instead of 8% and
     the tilt all but vanished. 2.73 is 900/330, the desktop card's ratio. */
  perspective: calc(var(--photo-w) * 2.73);
}
/* The two faces are stacked in 3D, so the radius, fill and shadow live on each face
   rather than here — a clip on this element would flatten the 3D context and the card
   would just swap instead of turning.

   The card sits turned a little to begin with, left edge toward you, the way the
   reference template's hero photo does. Positive rotateY sends the right edge back:
   a point at +x maps to -z, and -z is away from the viewer. */
.portrait__flip {
  position: relative;
  transform: rotateY(13deg);
  transform-style: preserve-3d;
  transition: transform .85s cubic-bezier(.3,.7,.25,1);
}
.portrait__face {
  border-radius: 40px;
  overflow: hidden;
  box-shadow: 0 26px 60px -18px rgba(102,112,255,.55);
  /* Without this the far side shows through mirror-imaged. */
  -webkit-backface-visibility: hidden;
          backface-visibility: hidden;
}
.portrait__face--photo { background: var(--indigo-solid); }
/* Pre-turned, so it faces front once the card is at 180deg. */
.portrait__face--badge {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: var(--white);
  transform: rotateY(180deg);
}

/* Hover only where hovering is a real thing. On a touchscreen :hover latches on tap
   and then stays until you touch something else, so the photo would flip away and
   sit there showing a "scroll down" prompt over the person you came to see.
   :focus-within is here for keyboards, but nothing inside the card is focusable
   today — it costs nothing and starts working the moment something is. */
@media (hover: hover) and (pointer: fine) {
  /* 13 + 180, not 180: a real card turned over keeps the tilt it started with, so the
     badge ends up sitting at the same 13deg the photo was at. Going to 180 instead
     would straighten the card out on the way round, which reads as two separate
     movements rather than one turn. */
  .portrait:hover .portrait__flip,
  .portrait:focus-within .portrait__flip { transform: rotateY(193deg); }
}

/* The cut-out is a 3:2 frame with the figure centred, so cover-fitting a
   square crops the empty sides and seats him nicely on the blue. */
.portrait img {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  object-position: 50% 50%;
}

/* ─── SCROLL BADGE (the back of the portrait card) ────────────── */
/* Sized as a share of the card rather than in px, so the ring text keeps the same
   proportions on the phone as on the desktop. */
.scrollbadge {
  width: 74%;
  height: auto;
  overflow: visible;
  color: var(--ink);
}
.scrollbadge__spin {
  transform-box: view-box;
  transform-origin: 100px 100px;
  animation: badgespin 24s linear infinite;
}
@keyframes badgespin { to { transform: rotate(360deg); } }

/* Dotted, not dashed: a near-zero dash with round caps gives evenly spaced dots
   without having to work out a dash pattern that divides into the circumference. */
.scrollbadge__ring {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-dasharray: 0.1 7;
  opacity: .32;
}
.scrollbadge__star { fill: currentColor; opacity: .55; }
/* 30 characters have to travel 452 units of arc. The tracking is what closes the gap —
   see the note in the HTML if the wording ever changes, because a longer phrase at this
   spacing will run past the start of the ring and overlap itself. */
.scrollbadge__text {
  fill: currentColor;
  font-family: var(--font);
  font-size: 18.5px;
  font-weight: 600;
  letter-spacing: 2.6px;
}
/* The arrow bobs so it reads as an instruction rather than a decoration. Slower on the
   way down than on the way back up — the downstroke is the half that carries the
   meaning, so it gets the time. Lengths here are SVG user units, not screen px, so the
   travel stays proportional to the badge at any size. */
.scrollbadge__arrow {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  animation: arrownudge 2.1s ease-in-out infinite;
}
@keyframes arrownudge {
  0%       { transform: translateY(-6px); }
  55%      { transform: translateY(7px); }
  100%     { transform: translateY(-6px); }
}

/* ─── AFFILIATION CARDS ───────────────────────────────────────── */
/* Three cards under the portrait: current post, PhD, and the dual degree. */
.creds {
  display: flex; flex-wrap: wrap; justify-content: center;
  gap: .75rem; margin-bottom: 2rem;
}
.cred {
  display: flex; align-items: center;
  /* ONE fluid size drives the whole card: the logo, the padding and both text lines
     are all sized in em below, so they shrink together as the screen narrows.
     Tuned against the longest line — "BTech & MTech, Chemical Engineering" — so it
     still fits on a single line down to a 320px screen. Capped at .9rem, which is
     the original size, so nothing changes from roughly 420px upward. */
  font-size: clamp(.65rem, calc(4vw - 2.4px), .9rem);
  gap: .78em;
  padding: .55em 1.22em .55em .61em;
  border: 1px solid var(--hair);
  border-radius: var(--r-pill);
  background: var(--white);
  box-shadow: 0 4px 14px rgba(0,0,0,.04);
  text-align: left;
}
.cred img {
  flex: none;
  width: 2.92em; height: 2.92em;
  border-radius: .83em;         /* rounded square, not a circle: the ASU lockup
                                   is type-set to its edges and a circle clips it */
  object-fit: contain;          /* never crop a logo */
  background: var(--white);
}
/* Row break before the third card. Zero height so it costs nothing but the row gap,
   and switched off once the cards stack on their own. */
.creds__break { flex: 0 0 100%; height: 0; }
@media (max-width: 760px) { .creds__break { display: none; } }

/* em, not rem, so both lines ride the fluid size set on .cred above. */
.cred__text { display: flex; flex-direction: column; line-height: 1.3; }
.cred__text b    { font-size: 1em;    font-weight: 800; color: var(--ink); }
.cred__text span { font-size: .889em; color: var(--grey); }

/* ─── MARQUEE ─────────────────────────────────────────────────── */
/* Background layer of the hero: runs behind the portrait and the orbs.
   --photo-cy is the portrait's vertical centre, measured and kept up to date by
   main.js so the name stays banded across the photo at every screen size (the phone
   layout hangs the skill bubbles off it too). The 52% fallback is what shows if JS
   never runs — close, but not exact. */
.marquee {
  position: absolute;
  top: var(--photo-cy, 52%); left: 0; right: 0;
  /* Same cap and the same auto-margin centring as .orbs. The mask is a percentage of
     this box too, so the ends still fade over a tenth of the band — they just start
     fading sooner. */
  width: min(100%, var(--field-max));
  margin-inline: auto;
  transform: translateY(-50%);
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent);
}
.marquee__track {
  display: flex; align-items: center;
  gap: clamp(1rem, 2.5vw, 2.5rem);
  width: max-content;
  /* The name is the biggest thing moving on the page and it sits right behind the face,
     so it sets the hero's pace. 115s puts it at roughly 35px/sec on a desktop, against
     the 55px/sec the stack band is pinned to in main.js — comfortably the slower of the
     two, which is the point: this one is meant to drift, that one to scroll.

     Duration rather than a speed constant, deliberately. The type is sized in vw, so a
     narrower screen scrolls a proportionally shorter loop in the same time and the
     band always takes 115s to cross itself whatever the screen. The stack band's fixed
     px/sec means the gap between them only widens as the screen gets smaller, never
     closes. */
  animation: slide 115s linear infinite;
}
.marquee__track span {
  /* 13vw all the way down — the old 3.5rem floor stopped it shrinking below
     ~430px, which is exactly where it needed to keep shrinking. */
  font-size: clamp(2rem, 13vw, 13rem);
  font-weight: 900;
  line-height: 1;
  letter-spacing: -.045em;
  color: transparent;
  -webkit-text-stroke-width: clamp(1.2px, .35vw, 2px);   /* thinner outline as the type shrinks */
  -webkit-text-stroke-color: rgba(102,112,255,.34);
  white-space: nowrap;
}
/* Safari/Firefox without text-stroke still get a readable ghost */
@supports not (-webkit-text-stroke: 1px red) {
  .marquee__track span { color: var(--indigo-10); }
}
.marquee__dot {
  -webkit-text-stroke: 0 !important;
  color: var(--orange) !important;
  font-size: clamp(1.1rem, 3.4vw, 2.5rem) !important;
}
@keyframes slide { from { transform: translateX(0); } to { transform: translateX(-50%); } }

/* Label over the stack band. Sized in rem and nothing else — the root steps down 2px at
   tablet and 2px again at phone, so 1rem lands on exactly 16 / 14 / 12px without a
   breakpoint of its own. Centred on the viewport rather than on the band: the band is
   capped at --field-max and the label is full width, so text-align does it at every size.

   The margin is the band's own top padding again, which keeps the label reading as part
   of the band rather than as the tail of the hero above it. */
.bandlabel {
  margin: 0 0 -1.4rem;
  text-align: center;
  /* The same size as the "I do" line above it, clamp and all, so the two greys either side
     of the band sit at one scale instead of stepping down. */
  font-size: clamp(.9rem, 2.2vw, 1.2rem);
  /* Weight rather than colour does the work here. Going darker would make it compete with
     the logos it's introducing; a bold, light grey reads as a label — the eye lands on it,
     registers what the band is, and moves on. Both are a deliberate step away from the
     role line: heavier than its 500, lighter than its --grey. */
  font-weight: 700;
  color: var(--grey-light);
}

/* ─── STACK BAND ──────────────────────────────────────────────
   A strip of the tools scrolling past, in the gap between the hero and About.
   The mask fades both ends into the page so items appear and leave rather than
   getting chopped at a hard edge. */
.logoband {
  overflow: hidden;
  /* Held to the same width as the hero's decorative layer, so the strip stops under the
     orbs instead of running off to both edges of a wide monitor. This one is in normal
     flow, so it's an ordinary max-width rather than the absolute-position trick above.
     The tint fades out horizontally with the mask below, so the capped band still ends
     in a gradient rather than a cut. */
  max-width: var(--field-max);
  margin-inline: auto;
  /* Generous vertical padding because the tint needs room to fade out in — with a
     tight band there's nowhere for the gradient to happen and the edge reads hard
     again. */
  padding: 2.4rem 0;
  /* No rule top and bottom, and no flat fill: the tint dissolves into the page at
     both edges, so the band reads as a soft field rather than a stripe with lines
     drawn across the layout. Faded to a zero-alpha indigo rather than `transparent`
     so nothing can interpolate through grey on the way out. */
  background: linear-gradient(180deg,
              rgba(102,112,255,0) 0%,
              var(--indigo-05) 34%,
              var(--indigo-05) 66%,
              rgba(102,112,255,0) 100%);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
}

/* --shift is one copy's share of the whole track and --dur the time to travel it,
   both written by main.js once it knows how many copies it had to make. Paused
   until the same script says the track is long enough to loop without a gap —
   which also means no-JS visitors get a still row instead of a broken one. */
.logoband__track {
  display: flex;
  align-items: center;
  width: max-content;
  animation: bandslide var(--dur, 40s) linear infinite;
  animation-play-state: paused;
}
.logoband__track.is-live { animation-play-state: running; }
.logoband:hover .logoband__track { animation-play-state: paused; }
@keyframes bandslide { to { transform: translateX(calc(var(--shift, 50%) * -1)); } }

/* The spacing has to be margin on the item, not gap on the track: gap would put
   n-1 gaps between n items, so half the track wouldn't be exactly one copy and
   the loop would jump every time it came round. */
.logoband__item {
  display: flex;
  align-items: center;
  gap: .65rem;
  margin-inline-end: clamp(2rem, 4.5vw, 4rem);
  white-space: nowrap;
}
.logoband__item b {
  font-weight: 600;
  font-size: .95rem;
  letter-spacing: -.01em;
  color: var(--ink-soft);
}
.logoband__item img { display: block; }
/* Same split as the stack tiles: square marks are capped by height, wordmarks
   by width, so a 10:1 wordmark can't run away with the strip. */
.logoband__item--mark img { height: 26px; width: auto; }
.logoband__item--word img { width: auto; height: auto; max-width: 118px; max-height: 24px; }

@media (max-width: 640px) {
  .logoband { padding: 1.9rem 0; }
  .logoband__item b { font-size: .84rem; }
  .logoband__item--mark img { height: 22px; }
  .logoband__item--word img { max-width: 100px; max-height: 20px; }
}

/* ─── LIGHTBOX ────────────────────────────────────────────────
   Full-screen viewer for the Photos & Videos section. z-index clears the navbar (100)
   and the drawer (99); only the skip link at 999 outranks it, which is correct — that
   one has to be reachable from anywhere. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: grid;
  place-items: center;
  /* Two rows now — the figure and the thumbnail rail. align-content has to be set: the
     default stretches auto rows to fill the container, which would push them apart to
     the top and bottom edges instead of keeping them together in the middle. */
  align-content: center;
  gap: clamp(.55rem, 1.5vw, .95rem);
  padding: clamp(3.5rem, 8vw, 5rem) clamp(1rem, 6vw, 5rem);
}
/* [hidden] is display:none in the UA sheet, but the display:grid above would beat it on
   specificity. Restoring it here is what keeps the attribute meaningful. */
.lightbox[hidden] { display: none; }

.lightbox__scrim {
  position: absolute; inset: 0;
  /* Opaque enough to stand on its own. The blur below does most of the work of killing
     the page behind, but it's the one part of this that a browser can decline to render,
     and at .88 the headings underneath were still legible without it. */
  background: rgba(10, 11, 20, .94);
  -webkit-backdrop-filter: blur(7px);
          backdrop-filter: blur(7px);
}

.lightbox__figure {
  position: relative;
  z-index: 1;
  margin: 0;
  display: grid;
  gap: .95rem;
  justify-items: center;
  min-width: 0;
}
.lightbox__stage { display: grid; place-items: center; min-width: 0; }
/* One cap for all three kinds of content, so nothing can overflow the viewport whatever
   its shape — a 0.69 portrait is bounded by the height, a 16:9 video by the width. */
.lightbox__stage img,
.lightbox__stage video {
  display: block;
  max-width: min(1180px, 88vw);
  max-height: 74vh;
  width: auto; height: auto;
  border-radius: 18px;
  background: #000;
}
/* An iframe has no intrinsic size to be capped by, so it gets the ratio instead. */
.lightbox__stage iframe {
  display: block;
  width: min(1180px, 88vw);
  aspect-ratio: 16 / 9;
  max-height: 74vh;
  border: 0;
  border-radius: 18px;
  background: #000;
}
/* The rail is a third band under the stage and the caption, so the picture can no longer
   be given 74% of a short window — there would be nothing left for it. The vh term is what
   a tall window still gets; the calc is what a short one has left after the padding, the
   caption and the rail. svh repeats it for mobile, where 100vh is the height with the
   browser chrome hidden and so overstates the room by the height of the address bar. */
.lightbox__stage img,
.lightbox__stage video,
.lightbox__stage iframe {
  max-height: min(74vh, calc(100vh - 20rem));
  max-height: min(74svh, calc(100svh - 20rem));
}

.lightbox__caption {
  max-width: 46rem;
  font-size: .92rem;
  line-height: 1.5;
  text-align: center;
  color: rgba(255,255,255,.78);
}

/* Sized to its contents and centred, until the contents outrun the cap and it scrolls. */
.lightbox__rail {
  position: relative;
  z-index: 1;
  display: flex;
  gap: .5rem;
  max-width: min(1180px, 88vw);
  padding-bottom: .3rem;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
  scrollbar-color: rgba(255,255,255,.3) transparent;
}
.lightbox__rail::-webkit-scrollbar { height: 6px; }
.lightbox__rail::-webkit-scrollbar-thumb { background: rgba(255,255,255,.3); border-radius: 3px; }

/* Nothing is cropped here either — each thumbnail keeps its own shape at one shared
   height, the same rule the strips on the page follow. min-width is the tap target: the
   0.69 portraits come out 36px wide at this height, which is too narrow to hit. */
.lightbox__thumb {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  height: 52px;
  min-width: 44px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  overflow: hidden;
  background: rgba(255,255,255,.09);
  opacity: .48;
  cursor: pointer;
  outline: 2px solid transparent;
  outline-offset: 2px;
  transition: opacity var(--fast) var(--ease), outline-color var(--fast) var(--ease);
}
/* "auto 16/9" means: use the file's own shape once it's known, and this until then. It
   matters for the two <video> thumbnails — a video whose metadata hasn't arrived has no
   shape at all, and a replaced element without one falls back to 300px wide, which threw
   the rail twice its proper width for as long as the header took to load. */
.lightbox__thumb img,
.lightbox__thumb video {
  display: block;
  height: 52px;
  width: auto;
  max-width: 148px;
  aspect-ratio: auto 16 / 9;
  object-fit: contain;
}
.lightbox__thumb:hover,
.lightbox__thumb:focus-visible { opacity: .85; }
/* The one on the stage. Dimming the rest is what makes this readable at a glance. */
.lightbox__thumb[aria-current="true"] { opacity: 1; outline-color: var(--white); }

.lightbox__btn {
  position: absolute;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 46px; height: 46px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(255,255,255,.13);
  color: var(--white);
  cursor: pointer;
  transition: background var(--fast) var(--ease);
}
.lightbox__btn:hover,
.lightbox__btn:focus-visible { background: rgba(255,255,255,.3); }
.lightbox__btn svg {
  width: 22px; height: 22px;
  fill: none; stroke: currentColor;
  stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}

.lightbox__close { top: clamp(.9rem, 2.5vw, 1.6rem); right: clamp(.9rem, 2.5vw, 1.6rem); }
/* Vertically centred, so they never collide with the close button in the corner. */
.lightbox__step { top: 50%; transform: translateY(-50%); }
.lightbox__step--prev { left:  clamp(.5rem, 2vw, 1.6rem); }
.lightbox__step--next { right: clamp(.5rem, 2vw, 1.6rem); }

.lightbox__count {
  position: absolute;
  top: clamp(.9rem, 2.5vw, 1.6rem); left: clamp(.9rem, 2.5vw, 1.6rem);
  margin: 0;
  font-size: .82rem;
  font-variant-numeric: tabular-nums;
  color: rgba(255,255,255,.6);
}

/* A group of one. Nowhere to step to and nothing to count, so both controls go — leaving
   a dead arrow either side would suggest there's more behind it than there is. */
.lightbox--single .lightbox__step,
.lightbox--single .lightbox__rail,
.lightbox--single .lightbox__count { display: none; }

/* Scroll lock. The padding replaces the scrollbar's width so the page underneath doesn't
   jump sideways when it's taken away — js writes --sbw after measuring it. */
.is-locked { overflow: hidden; padding-right: var(--sbw, 0px); }

@media (max-width: 640px) {
  .lightbox { padding: 4rem .75rem 3rem; }
  .lightbox__btn { width: 40px; height: 40px; }
  .lightbox__btn svg { width: 19px; height: 19px; }
  /* Below the picture rather than beside it — at this width side arrows sit on top of
     the content and there isn't room to move them off it. */
  .lightbox__step { top: auto; bottom: .5rem; transform: none; }
  .lightbox__step--prev { left: calc(50% - 52px); }
  .lightbox__step--next { right: calc(50% - 52px); }
  /* The arrows live in the 3rem of bottom padding, so the rail — which is in the flow —
     stacks above them rather than under them. Nothing to move. */
  .lightbox__stage img,
  .lightbox__stage video,
  .lightbox__stage iframe {
    max-height: min(62vh, calc(100vh - 17rem));
    max-height: min(62svh, calc(100svh - 17rem));
  }
  .lightbox__caption { font-size: .84rem; }
  .lightbox__rail { max-width: 94vw; gap: .4rem; }
  .lightbox__thumb { height: 42px; }
  .lightbox__thumb img,
  .lightbox__thumb video { height: 42px; max-width: 120px; }
}

/* ─── SECTIONS ────────────────────────────────────────────────── */
.section { position: relative; padding: clamp(4rem, 9vw, 7rem) 0; }
.section--tint { background: var(--indigo-05); }

.h2 {
  margin-bottom: 1rem;
  font-size: clamp(1.72rem, 5.5vw, 3.4rem);
  text-align: center;
  letter-spacing: -.035em;
}
.lede {
  max-width: 42rem;
  margin: 0 auto 3rem;
  text-align: center;
  font-size: 1.08rem;
  color: var(--grey);
}
.lede--center { margin-bottom: 2.5rem; }
/* One line, not two. 42rem is 672px and this particular lede needs 680 — it wraps by
   eight pixels, which puts one orphaned clause on a second line and reads as a mistake
   rather than a paragraph. 46rem leaves ~56px of headroom, enough that a fallback font
   or a slightly wider rendering doesn't drop it back onto two lines. Scoped to the one
   lede that needs it rather than raised globally: the other section intros are shorter
   and a wider cap would just let them run longer than the measure they were set to. */
.lede--wide { max-width: 46rem; }

/* Justified body copy in the three reading sections. Only the running paragraphs — the
   section intros stay centred, and captions, meta lines and list items stay ragged-right,
   because justifying two or three lines just stretches one of them.

   hyphens is not optional here. Justification works by opening the word spaces until the
   line fills, so a long word that will not fit forces the rest of the line apart into
   visible rivers of white. Allowing breaks inside words gives the browser somewhere else
   to give, and <html lang="en"> is what lets it know where the breaks may fall. */
#about .glowcard > p,
#expertise .service__body > p,
#research .project__text > p {
  text-align: justify;
  -webkit-hyphens: auto;
          hyphens: auto;
}

.subhead {
  margin: 3rem 0 1.25rem;
  font-size: .8rem;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--grey-light);
}
.subhead:first-of-type { margin-top: 0; }

/* ─── GLOW CARD ───────────────────────────────────────────────── */
.glowcard {
  position: relative;
  padding: clamp(1.75rem, 4vw, 2.75rem);
  background: var(--white);
  border: 1px solid var(--hair);
  border-radius: var(--r-card);
  box-shadow: 0 18px 50px -22px rgba(102,112,255,.45);
}
.glowcard::before {
  content: "";
  position: absolute; inset: -14px 8% auto 8%; height: 60px;
  border-radius: 50%;
  background: linear-gradient(90deg, var(--orange-soft), var(--indigo), var(--mint-deep));
  filter: blur(30px);
  opacity: .32;
  z-index: -1;
}

/* ─── ABOUT — stacking cards ──────────────────────────────────── */
/* 65rem is the wrap's content box — 68rem less its 1.5rem of padding each side — which is
   exactly what the My Stack grid fills. Setting the same limit here lines the cards up with
   the tool tiles: same left edge, same right edge, all the way down the page. */
.stack-cards {
  max-width: 65rem;
  margin: 0 auto 3rem;
}

/* Phones only. Left folded into the max-width above it would also shave the gutter off the
   desktop width and break the alignment with My Stack, so it lives in its own rule. The
   gutter is fluid because a flat 1.5rem costs a 320px screen too much of a narrow column. */
@media (max-width: 700px) {
  .stack-cards { max-width: calc(100% - clamp(.5rem, 3vw, 1.5rem)); }
}
.stack-cards__item {
  position: sticky;
  top: calc(7rem + var(--i) * 14px);
  margin-bottom: 1.5rem;
}
/* Drop the glowcard's coloured halo here. It's a blurred orange-to-mint bar sitting
   just above the card, which works where a card stands alone but reads as a pink
   stain behind these — they're pinned one on top of another, so the halo of the card
   underneath keeps showing through the one in front. The Experience cards drop it too,
   for a different reason — see .cv::before. */
.stack-cards__item::before { content: none; }
/* Spread into the width the wider card opened up, but not all the way to its padding.
   Centred text has no fixed left edge to return to, so a very long line makes the eye hunt
   for the start of the next one; 52rem keeps that in hand while using most of the room. */
.stack-cards__item p {
  margin: 0 auto;
  max-width: 52rem;
  font-size: 1.08rem;
  text-align: center;
  color: var(--ink-soft);
}
/* The rule above zeroes the global paragraph margin so a single-paragraph card sits flush
   in its padding; cards that run to two paragraphs put the gap back between them. */
.stack-cards__item p + p { margin-top: 1rem; }

/* Titles for the About cards. Set in the serif italic rather than the page's sans — the
   same face that sets "Amogh" in the hero headline and the labels on the skill bubbles, so
   it reads as the site's own accent voice rather than a font imported for this one job.
   Against a card of sans body copy the contrast is the whole effect; no colour or rule
   needed. Pitched below the section's h2 so these stay labels inside the stack rather than
   competing section headings. */
.stack-cards__title {
  margin: 0 0 1.25rem;
  font-family: var(--serif);
  font-style: italic;
  font-weight: 700;
  font-size: clamp(1.4rem, 3.4vw, 2.05rem);
  line-height: 1.15;
  text-align: center;
  letter-spacing: -.015em;
  color: var(--ink);
}

/* Two figures side by side inside an About card, sitting between its paragraphs. Capped
   short of the card width and centred: both images are near-square, so at the card's full
   width they would each be over 400px tall and push the card past the height the sticky
   stack can pin.

   The cap is in rem, not px, so it rides the root scale the type does — a px cap held the
   frames at ~303px on every screen, and on a phone the single column then handed each one
   the full card width, so they came out fractionally *larger* there than on a desktop. */
.about-figs {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .85rem;
  max-width: 38.75rem;
  margin: 1.5rem auto;
}
/* One column, and its own cap: with two frames stacked, the width no longer falls out of
   halving the container, so without this the phone would take back everything the rem cap
   just gave. 19rem is a step down from the tablet's two-up width, keeping the run of sizes
   going in one direction the whole way down. */
@media (max-width: 640px) {
  .about-figs { grid-template-columns: 1fr; max-width: 19rem; }
}

/* ─── PHOTOS & VIDEOS ─────────────────────────────────────────── */
/* Both rows are moving strips, the same device as the stack band below the hero — see
   .logoband, whose --shift/--dur/is-live contract and bandslide keyframe these share.
   One class for both because everything except what's in them is identical; only the
   item sizing and the speed differ, and the speed is main.js's business.

   A single line is what lets the photographs be one height with none of them cropped:
   they no longer have to wrap, so the row can't end ragged and --gh no longer has to be
   small enough for the widest photo to fit a fixed box on its own. The width of each is
   still whatever its own ratio makes it.

   The still state is the fallback, and it's the old wrapped block: main.js adds is-live
   to both the band and its track once it has cloned enough copies to loop. Written this
   way round because a single max-content line that never moves is the one layout that
   fails outright — it would clip to the first item or two and hide the rest, and that is
   exactly what a visitor with JavaScript off or "reduce motion" on would get. */
.mediaband {
  /* Same cap as the hero's decorative layer, so on a wide monitor the strips stop with
     everything else rather than running to both edges. */
  max-width: var(--field-max);
  margin-inline: auto;
}
.mediaband + .mediaband { margin-top: 2.5rem; }
.mediaband.is-live {
  overflow: hidden;
  /* Both are opaque rectangles, so without this they'd be guillotined at the container
     edge. The fade makes them arrive and leave instead. Only while it moves — over a
     still block it would just dim whatever happened to sit at the ends. */
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
.mediaband__track {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  /* Tops aligned, not centres: the captions are different lengths, so centring would
     leave the pictures themselves sitting at different heights. */
  align-items: flex-start;
  gap: 1.5rem 1rem;
  max-width: 55rem;
  margin-inline: auto;
}
.mediaband__track > * { flex: 0 0 auto; }
.mediaband__track.is-live {
  flex-wrap: nowrap;
  justify-content: flex-start;
  /* Spacing moves from the track's gap to the item's margin once it's a loop: gap puts
     n-1 gaps between n items, so half the track wouldn't be exactly one copy and the
     cycle would jump every time it came round. Wrapped, gap is right and a trailing
     margin would throw the centring out — hence the swap rather than one or the other. */
  gap: 0;
  max-width: none;
  margin-inline: 0;
  width: max-content;
  animation: bandslide var(--dur, 60s) linear infinite;
}
.mediaband__track.is-live > * { margin-inline-end: 1rem; }
/* Stops under the pointer so an item can be read and clicked rather than chased.
   focus-within does the same for the keyboard, where a moving target is worse. */
.mediaband:hover .mediaband__track,
.mediaband:focus-within .mediaband__track { animation-play-state: paused; }
.mediaband figcaption { text-align: center; cursor: default; }

/* Both sizes taper with the viewport rather than only taking the root's step down at each
   breakpoint. In rem alone they shrank 12.5% a step while the screen itself lost far more
   than that — a video went from 41% of the strip on a desktop to 89% of it on a phone, so
   the items looked bigger the smaller the screen got, which is the opposite of what a
   step down is meant to do. The vw term is what holds their share of the strip roughly
   steady; the rem ceiling keeps the desktop rendering exactly as it was, and the rem
   floor stops a phone shrinking them past the point of being worth tapping.

   --gh is the photos' shared height, and each photo's width still falls out of its own
   ratio: 300px tall, then 215, then 180. */
.mediaband--photos { --gh: clamp(15rem, 28vw, 18.75rem); }

/* Each player is fixed-width and gets its 16/9 box from .video, so the height follows:
   432x243, then 300x169, then 240x135. The 27rem ceiling is what it was, and two of them
   plus a gap is still the 55rem the strips wrap to when neither is moving. */
.mediaband--videos .shot { width: clamp(20rem, 39vw, 27rem); max-width: 100%; }

/* Click-to-load facade. Fills the .video box, which already supplies the 16/9 ratio,
   the rounded corners and the overflow clip. The <video> case is the self-hosted pair,
   which stand in as their own poster frame rather than shipping a JPEG of themselves. */
.video__facade { position: absolute; inset: 0; display: block; cursor: pointer; }
.video__facade img,
.video__facade video { width: 100%; height: 100%; object-fit: cover; display: block; }
.video__play {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  display: grid; place-items: center;
  width: clamp(38px, 15%, 58px); aspect-ratio: 1;
  border-radius: 50%;
  background: rgba(17, 17, 22, .62);
  transition: background var(--fast) var(--ease), transform var(--fast) var(--ease);
}
/* Triangle as a clipped square rather than a border trick, so it scales with the
   button instead of needing its own font-size. */
.video__play::before {
  content: "";
  width: 34%; aspect-ratio: 1;
  background: var(--white);
  clip-path: polygon(20% 6%, 20% 94%, 90% 50%);
}
.video__facade:hover .video__play,
.video__facade:focus-visible .video__play {
  background: var(--indigo-solid);
  transform: translate(-50%, -50%) scale(1.08);
}

/* Figure inside an About card. The two share one frame so their tops and bottoms line up.
   Its ratio is the photo's own 640:655, which lets the photo fill it exactly — the frame
   was sized to the image rather than the image cropped to the frame. The schematic is
   700x667, barely wider, so it contains with a sliver of white top and bottom that reads
   as part of the drawing's own margin. */
.about-fig { margin: 0; }
.about-fig__frame {
  display: block;
  aspect-ratio: 640 / 655;
  border: 1px solid var(--hair);
  border-radius: 18px;
  overflow: hidden;
  background: var(--white);
  transition: border-color var(--fast) var(--ease), box-shadow var(--fast) var(--ease);
}
/* The gallery's frames are buttons — they open the lightbox — so they need the browser's
   button defaults stripped back to what the span and the link already look like. */
button.about-fig__frame { padding: 0; font: inherit; color: inherit; cursor: pointer; }
a.about-fig__frame:hover,
button.about-fig__frame:hover,
button.about-fig__frame:focus-visible { border-color: var(--indigo-20); box-shadow: 0 14px 34px -18px rgba(102,112,255,.6); }
/* contain, never cover: nothing in either image gets cropped away. */
.about-fig img { width: 100%; height: 100%; object-fit: contain; }
/* A single wide figure inside an About card. 720px sits between the 620px the two-up
   .about-figs use and the 52rem text column — inset from the paragraphs on both sides,
   so it still reads as an illustration rather than a banner, but with enough width that
   the three panels are distinguishable before you open the full-size copy. Ratio
   matches the served JPEG exactly, so `contain` letterboxes by nothing. */
.about-fig--wide { max-width: 720px; margin: 1.5rem auto; }

/* Photographs, not diagrams — used by the Photos & Videos section, which borrows .about-fig
   as a shared figure primitive rather than duplicating the frame, hover and caption rules.
   The one thing they don't share is the frame's ratio: these five run from a 0.69 portrait
   to a 1.48 landscape, and no fixed ratio crops all of them without cutting people out of
   the group shots. So the frame takes each photo's own ratio from --ar and its height from
   the row, and the width falls out of the two. contain is then the same as cover, since
   frame and photo agree exactly — it's there so a wrong --ar would letterbox visibly
   rather than quietly crop someone out of a group. */
.about-fig--photo { width: calc(var(--gh) * (var(--ar))); }
.about-fig--photo .about-fig__frame { aspect-ratio: var(--ar); height: var(--gh); width: 100%; }
.about-fig--photo img { object-fit: contain; }
.about-fig--wide .about-fig__frame { aspect-ratio: 1500 / 837; }

.about-fig figcaption {
  margin-top: .6rem;
  font-size: .78rem;
  line-height: 1.45;
  text-align: center;
  color: var(--grey-light);
}

/* ─── STACK GRID ──────────────────────────────────────────────── */
.stackgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.1rem; }
.tool {
  padding: 1.75rem 1.25rem;
  background: var(--white);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  text-align: center;
  box-shadow: 0 10px 28px -18px rgba(0,0,0,.3);
  transition: transform var(--med) var(--ease), box-shadow var(--med) var(--ease);
}
.tool:hover { transform: translateY(-6px); box-shadow: 0 22px 44px -20px rgba(102,112,255,.5); }
/* Every size in this block is in rem rather than px, so the chips and logos step down
   with the root scale the way the type does. In px they rendered identically on a desktop
   and a tablet — the tile shrank around them but the mark inside it didn't, so the logos
   crowded the tile as the screen narrowed. The phone block near the end of the file still
   overrides these outright: at 3 tiles across, the sizes there are set by the tile width,
   not by the scale. */
.tool__icon {
  display: grid; place-items: center;
  width: 3.75rem; height: 3.75rem;
  margin: 0 auto 1rem;
  border-radius: 1.125rem;
  background: color-mix(in srgb, var(--tone) 12%, var(--white));
  color: var(--tone);
}
.tool__icon svg { width: 1.875rem; height: 1.875rem; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* Lucide's icons are drawn on a 24-unit grid; the hand-drawn ones here use 32. At the
   same nominal stroke width they'd render a third heavier, so thin them to match. */
.tool__icon svg.icon24 { stroke-width: 1.5; }

/* Real product logos, for the tiles where one exists. They keep the same vertical band as
   the tinted .tool__icon chips beside them, so every tile's title still starts on the same
   line — but no chip behind them, because a coloured plate under a full-colour logo
   muddies it. Same rem treatment as the chips, and for the same reason. */
.tool__logo { display: grid; place-items: center; height: 3.75rem; margin: 0 auto 1rem; }
.tool__logo img { display: block; }

/* Square-ish marks (AMReX, C++, Python) are limited by height, so they all end up
   optically the same size regardless of how wide each one happens to be. */
.tool__logo--mark img { height: 2.875rem; width: auto; max-width: 100%; }

/* Wordmarks (COMSOL, Ansys, OpenFOAM) are limited by width instead — height would
   make a 10:1 wordmark wider than the tile. --cap pulls the limit in for the short
   ones so "Ansys" isn't set to the same physical width as "COMSOL Multiphysics";
   max-height is the backstop that stops a squarer logo overshooting the band.
   The width limit has to scale too, not just the height: these are wide enough that
   the height backstop never binds, so a px --cap would have held them at full size
   however far the rest of the tile shrank. --cap is therefore set in rem in the markup. */
.tool__logo--word img { width: 100%; max-width: var(--cap, 10.75rem); height: auto; max-height: 2.875rem; }
.tool h3 { font-size: 1.02rem; font-weight: 700; margin-bottom: .35rem; letter-spacing: -.01em; }
.tool p { margin: 0; font-size: .84rem; color: var(--grey); line-height: 1.45; }

/* ─── SERVICES / EXPERTISE ────────────────────────────────────── */
/* Wider than the 56rem it used to be, because a third column now has to fit. At the old
   width the illustration would have taken its 11rem out of the text, dropping the measure
   to about 55 characters — narrow enough that a three-line paragraph starts breaking into
   five. 64rem gives the image its column and leaves the text roughly the measure it had. */
.services { max-width: 64rem; margin-inline: auto; }

.service {
  --service-img: 11rem;
  display: grid;
  grid-template-columns: minmax(0, 8rem) minmax(0, 1fr) var(--service-img);
  gap: 1.5rem;
  align-items: start;
  padding: 2.25rem 0;
  border-bottom: 1px solid var(--hair);
}
.service:last-child { border-bottom: 0; }

/* In rem, so it steps down with the root the same way the type does — 176 / 154 / 132px
   across desktop, tablet and phone — rather than staying a fixed block that grows heavier
   relative to the text on every smaller screen.

   Centred against the text rather than top-aligned: the illustration is taller than a
   three-line paragraph, so aligning tops would leave it hanging past the bottom of the
   text it belongs to. The number stays at the top, where it sits level with the heading. */
.service__img {
  width: var(--service-img);
  height: auto;
  align-self: center;
  border-radius: 20px;
}

/* ─── EXPERTISE ILLUSTRATIONS, IN MOTION ──────────────────────
   Each one acts out what its item says, once, as the row arrives. They are flat renders,
   so nothing inside them can move independently — no dot travelling the chain, no needle
   swinging on its own. What is available is the whole frame: how it scales, how it tilts,
   how its colour comes up. That turns out to be enough, because in every one of these
   pictures the subject IS the frame.

   They run continuously. Every one is a closed cycle — it ends on the frame it starts on,
   so the loop has no seam to see. That is the constraint behind the shapes below: a motion
   that travels one way and stops cannot repeat without snapping back, so the ones that
   want a direction shuttle instead, and at these amplitudes a shuttle reads as activity
   rather than as reversal.

   Six things moving beside six paragraphs is a lot, so the amplitudes are small — three or
   four pixels, two degrees — and the periods are long and deliberately unequal. Equal
   periods would have all six pulsing in unison, which is far more noticeable than any one
   of them; at 6 to 10.5 seconds and no common factor they drift permanently out of phase.

   It all hangs off .is-in, which the reveal observer already sets, so nothing animates
   below the fold until it has been reached.

   The names are the meanings, and they live on the <img> rather than on an :nth-child, so
   reordering the list carries each motion with its own item — which matters, this list has
   already been reordered once. */
.service.is-in .service__img {
  animation-iteration-count: infinite;
  /* Symmetric out-and-back, so a curve that decelerates into the end — the site's --ease —
     would stall at both extremes. These want to be slowest at the turns. */
  animation-timing-function: ease-in-out;
}

/* 01 — a solver iterating: overshoot, undershoot, close in, and go round again. */
.service.is-in .service__img--converge { animation-name: exp-converge; animation-duration: 7.5s; }
@keyframes exp-converge {
  0%   { transform: scale(1); }
  26%  { transform: scale(1.038); }
  52%  { transform: scale(.986); }
  78%  { transform: scale(1.012); }
  100% { transform: scale(1); }
}

/* 02 — work moving along the chain, one node at a time. steps() is the whole point: agents
   hand off in discrete moves, they don't glide. Declared inside the keyframes because a
   timing function set there governs the segment that starts at it, which is what gets four
   hops out and four hops back instead of four across the pair. */
.service.is-in .service__img--flow { animation-name: exp-flow; animation-duration: 6.5s; }
@keyframes exp-flow {
  0%   { transform: translate(0, 0);        animation-timing-function: steps(4, end); }
  50%  { transform: translate(-6%, -4.2%);  animation-timing-function: steps(4, end); }
  100% { transform: translate(0, 0); }
}

/* 03 — lab, pilot, industrial: up in three steps because there are three scales and the
   picture is three vessels, then released smoothly rather than stepped back down. */
.service.is-in .service__img--scaleup { animation-name: exp-scaleup; animation-duration: 9s; }
@keyframes exp-scaleup {
  0%   { transform: scale(.968); animation-timing-function: steps(3, end); }
  58%  { transform: scale(1.032); animation-timing-function: ease-in-out; }
  100% { transform: scale(.968); }
}

/* 04 — the magnifier sweeping the rig, looking for the discrepancy. */
.service.is-in .service__img--scan { animation-name: exp-scan; animation-duration: 8.5s; }
@keyframes exp-scan {
  0%, 100% { transform: translateX(-3.4%); }
  50%      { transform: translateX(3.4%); }
}

/* 05 — the balance rocking. The one that needed no invention: the picture is a pair of
   scales, so the motion is the thing the object does. */
.service.is-in .service__img--weigh { animation-name: exp-weigh; animation-duration: 6s; }
@keyframes exp-weigh {
  0%, 100% { transform: rotate(-2.1deg); }
  50%      { transform: rotate(2.1deg); }
}

/* 06 — the furnace holding temperature. Colour rather than movement, because this one is a
   bench that sits still and a furnace that doesn't. */
.service.is-in .service__img--heat { animation-name: exp-heat; animation-duration: 10.5s; }
@keyframes exp-heat {
  0%, 100% { filter: saturate(.84) brightness(1.028); }
  50%      { filter: saturate(1.16) brightness(.986); }
}
.service__num {
  font-size: clamp(3rem, 7vw, 4.6rem);
  font-weight: 900;
  line-height: .85;
  letter-spacing: -.05em;
  color: var(--indigo-10);
}
.service__body h3 { font-size: clamp(1.08rem, 2.6vw, 1.6rem); margin-bottom: .6rem; }
.service__body p { margin: 0; color: var(--grey); }

/* ─── PROJECTS ────────────────────────────────────────────────── */
.project {
  padding: clamp(1.5rem, 4vw, 2.5rem);
  margin-bottom: 2rem;
  background: var(--white);
  border: 1px solid var(--hair);
  border-radius: var(--r-card);
  box-shadow: 0 18px 50px -26px rgba(0,0,0,.35);
}
.project__head { margin-bottom: 2rem; text-align: center; }
.project__head h3 { font-size: clamp(1.28rem, 3.4vw, 2.1rem); margin: .75rem 0 .5rem; }
.project__meta { margin: 0; font-size: .88rem; color: var(--grey-light); }

.tagpill {
  display: inline-block;
  padding: .3rem .9rem;
  border-radius: var(--r-pill);
  background: var(--indigo-10);
  color: var(--indigo-text);
  font-size: .76rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.tagpill--orange { background: rgba(249,71,6,.10); color: #c2380a; }
.tagpill--mint   { background: rgba(0,204,153,.14); color: #00795b; }

/* Jump links standing where the Research intro used to. They echo the tagpills deliberately
   — each carries the colour of the tag on the card it lands on, so the colour says where you
   are going before the label is read. What they do not echo is the size: a tagpill is a
   label and can be small, whereas these are tapped, so they take a 44px target. */
.jumpnav {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: .6rem;
  margin: 0 auto 3rem;
}
.jumpnav__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: .5rem 1.35rem;
  border-radius: var(--r-pill);
  background: var(--indigo-10);
  color: var(--indigo-text);
  font-size: .76rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  /* These read as buttons, not as links in a sentence — the global underline on <a> would
     make them look like body copy that happens to be tinted. The pill is the affordance. */
  text-decoration: none;
  transition: transform var(--fast) var(--ease), box-shadow var(--fast) var(--ease);
}
.jumpnav__link:hover { transform: translateY(-2px); box-shadow: 0 12px 26px -14px rgba(0,0,0,.45); }
.jumpnav__link:active { transform: none; }
.jumpnav__link:focus-visible { outline: 2px solid var(--indigo-solid); outline-offset: 3px; }

/* Each colour is repeated on :hover on purpose. The global `a:hover` turns links orange, and
   it outranks a single class — without these the indigo and mint buttons would both flip to
   orange under the cursor and stop matching the card they lead to. */
.jumpnav__link,
.jumpnav__link:hover          { color: var(--indigo-text); }
.jumpnav__link--orange,
.jumpnav__link--orange:hover  { background: rgba(249,71,6,.10); color: #c2380a; }
.jumpnav__link--mint,
.jumpnav__link--mint:hover    { background: rgba(0,204,153,.14); color: #00795b; }

/* Kept as the base for the modifier below, which is what every project actually uses.
   Nothing sets two columns any more. */
.project__grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 2.5rem); align-items: start; }

/* A project whose pictures sit between its paragraphs instead of in a column beside them.
   One column, and the measure is put on the paragraphs rather than on .project__text so
   that the two can differ: the words stay at a readable ~46rem while a figure band gets
   the whole width of the card. That difference is the layout — a band interrupts the
   prose and is visibly wider than it, which is what stops it reading as a gallery. */
.project__grid--flow { grid-template-columns: minmax(0, 1fr); }
.project__grid--flow .project__text > p { max-width: 46rem; margin-inline: auto; }
.project__grid--flow .wins { grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); }
.project__text p { color: var(--grey); }

.wins { margin-top: 1.25rem; display: grid; gap: .5rem; }
/* Leading a project rather than closing it. The heading block above already carries 2rem
   of its own, so the list's usual top margin would push it adrift; it needs the gap on the
   other side instead, to separate it from the first paragraph. */
.project__text > .wins:first-child { margin-top: 0; margin-bottom: 1.9rem; }
.wins li {
  padding: .7rem 1rem;
  border-radius: 14px;
  background: var(--indigo-05);
  font-size: .9rem;
  color: var(--ink-soft);
  text-align: center;
}
/* Tabular figures so every digit is the same width. The ones marked data-countup animate
   from zero, and with proportional figures a 1 is narrower than an 8 — the number would
   twitch on nearly every frame as it counted. */
.wins b { color: var(--indigo-text); font-weight: 800; font-variant-numeric: tabular-nums; }

/* Figures */
.shot { margin: 0; }
.shot img { width: 100%; height: auto; border-radius: 18px; border: 1px solid var(--hair); background: var(--white); }
/* Centred, matching .about-fig figcaption. A band's figures are different widths, so
   flush-left captions started at four different places across one row. */
.shot figcaption { margin-top: .5rem; font-size: .78rem; line-height: 1.45; color: var(--grey-light); text-align: center; }

/* The label sitting on a simulation GIF. Absolute against .shot__frame, which is the
   positioned ancestor. The two GIFs are both 640x480 and declare the same --ar, so the
   band gives them the same width and they stay comparable — which is the only reason to
   show them side by side. No frame is forced on them: their own dimensions do that job,
   so replacing one at a different size cannot silently crop it. */
.shot__tag {
  position: absolute; left: .5rem; bottom: .5rem;
  padding: .15rem .55rem;
  border-radius: var(--r-pill);
  background: rgba(255,255,255,.92);
  border: 1px solid var(--hair);
  font-size: .8rem; font-weight: 700;
}
.shot__tag--oxide { color: #c2380a; }
.shot__tag--metal { color: #4b5563; }

/* The hatch that holds a deferred GIF's place until it loads. No aspect-ratio here on
   purpose: the width and height attributes on the img already reserve the right box, and a
   hard 4:3 written here would silently letterbox or crop any GIF that wasn't 4:3. */
.lazy {
  background: repeating-linear-gradient(45deg, var(--indigo-05) 0 10px, var(--white) 10px 20px);
}

/* Video — see README for the YouTube / MP4 snippets that use these. */
.video {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: 18px;
  border: 1px solid var(--hair);
  background: #000;
}
.video iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

/* Nothing here is cropped. These are diagrams, a mechanism, a reaction set, a rig — the
   edges of each one carry meaning, and a shared frame was cutting into them: the pellet
   mechanism is nearly square and lost its top and bottom to a 4:3 box, the manifold is a
   tall portrait and lost most of itself to a landscape one. Each image keeps its own
   proportions instead.

   A band of figures sitting between two paragraphs rather than in a column beside them.

   Flex rather than grid, because the figures in one band are not the same shape and a
   column layout would leave a portrait photo three times taller than the landscape one
   next to it. Here each figure's flex-grow is its own aspect ratio and its flex-basis is
   zero, so the widths come out proportional to the ratios — which is precisely the
   condition for the whole row to land on one height with nothing cropped. It needs no
   per-band arithmetic and works for two figures or three; each figure declares its --ar.

   Height is not set anywhere. It falls out of the width:

       height  =  (band width - gaps) / sum of the band's --ar values

   so max-width is the size control, and that is why the bands carry different ones.

   The default is the same 46rem the paragraphs are held to, so a band starts and ends
   exactly where the words above it do. At that width three broadly landscape pictures come
   out about 190px tall. Bands that would still be too tall there carry a narrower value
   inline: two portraits side by side would be 587px at 46rem, so theirs is held to 22rem. */
.shotband {
  display: flex;
  flex-wrap: wrap;
  gap: .85rem;
  align-items: flex-start;
  margin: 1.9rem auto;
  max-width: 46rem;
}
.shotband .shot { flex: var(--ar, 1) 1 0%; min-width: 0; }

/* A research figure that opens in the lightbox. Same job as .about-fig__frame in an About
   card — main.js treats both as triggers — but without that class's fixed aspect-ratio
   frame, because nothing in a project is cropped.

   It is a real link to the file, which earns its keep twice: the simulation GIFs have no
   src attribute until they scroll into view, so the href is the only thing the viewer can
   reliably read at the moment it builds its ring, and with JS off the picture still opens
   on its own. The click handler calls preventDefault, so the link is never followed while
   the viewer is working. */
.shot__frame {
  display: block;
  position: relative;
  border-radius: 18px;
  cursor: pointer;
  transition: box-shadow var(--fast) var(--ease);
}
.shot__frame img,
.shot__frame video { display: block; }
.shot__frame:hover { box-shadow: 0 14px 34px -18px rgba(102,112,255,.6); }
.shot__frame:hover img,
.shot__frame:hover video { border-color: var(--indigo-20); }
.shot__frame:focus-visible { outline: 2px solid var(--indigo-solid); outline-offset: 3px; }
.shot__frame:hover .video__play { background: var(--indigo-solid); transform: translate(-50%, -50%) scale(1.08); }

/* A film that is one of the figures rather than a lightbox item. The research figures are
   not a [data-lightbox] group, so the facade markup the photo strip uses would have
   nothing to open — this one carries its own controls instead. It also keeps its own
   shape: the .video frame above forces 16/9, which would slice a portrait phone film down
   to a letterbox slot. width/height on the element give it the right box before it loads. */
.shot__film {
  display: block;
  width: 100%; height: auto;
  border-radius: 18px;
  border: 1px solid var(--hair);
  background: #000;
}

/* ─── PAPERS ──────────────────────────────────────────────────── */
.filters { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: .5rem; margin-bottom: 2.5rem; }
/* The filters keep their own box so they stay one labelled group with the expander
   beside them rather than inside. It's a flex row of its own so nesting costs no layout. */
.filters__set { display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem; }

/* Set apart from the filters by a wider gap — it answers "how many", not "which". */
.pill--toggle { margin-left: .6rem; }
.pill--toggle[hidden] { display: none; }
.pill__chev {
  width: 14px; height: 14px;
  fill: none; stroke: currentColor;
  stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round;
  transition: transform var(--fast) var(--ease);
}
.pill--toggle[aria-expanded="true"] .pill__chev { transform: rotate(180deg); }
/* On a phone the toggle joins the filters' own wrapping instead of sitting on a line of
   its own. .filters__set is a flex item of .filters, so its buttons wrap inside it and the
   toggle can only ever start a new row after the whole set — three rows for five buttons,
   the last holding one small pill. display:contents dissolves the set's box so all five
   land in one flex formatting context and wrap as a single run; the row gaps match already
   (.5rem on both) so nothing else moves. The group keeps its role and label — an element
   with display:contents and a non-generic role stays in the accessibility tree. */
@media (max-width: 560px) {
  .pill--toggle { margin-left: 0; }
  .filters__set { display: contents; }
}

/* ─── ORG MARK ────────────────────────────────────────────────
   Who published it or who hosted it, on the left of a paper or a talk, with the date
   underneath. One component for both lists: they answer the same question in the same
   place, and a reader scanning down the page shouldn't have to learn two conventions.

   The tile is a fixed square with the mark contained inside it, never cropped. These marks
   come in every proportion there is — AIST is 2:1, the MDPI journal logos are nearly 5:1,
   Elsevier's is a square — so a square that cover-crops would behead half of them. The
   border is what stops the white-background marks dissolving into the white card, exactly
   as in the Experience list. */
.orgmark {
  display: grid;
  justify-items: center;
  gap: .4rem;
  text-align: center;
}
.orgmark__tile {
  display: grid;
  place-items: center;
  width: 3.5rem; height: 3.5rem;
  padding: .32rem;
  border: 1px solid var(--hair);
  border-radius: 14px;
  background: var(--white);
}
.orgmark__tile img {
  display: block;
  max-width: 100%; max-height: 100%;
  width: auto; height: auto;
  object-fit: contain;
}
/* A logo that carries its own background — an avatar-style square, dark or coloured — has
   no business floating inside a white box with a white margin around it. It fills the tile
   instead and takes the tile's own corners, so it reads as the tile rather than as a
   picture sitting in one. */
.orgmark__tile--full { padding: 0; overflow: hidden; }
.orgmark__tile--full img { width: 100%; height: 100%; max-width: none; max-height: none; object-fit: cover; }

/* No mark to be had. Initials in the same box rather than an empty one or a missing one,
   so the column keeps its rhythm down the list. */
.orgmark__tile--text {
  padding: .3rem;
  font-size: .58rem;
  font-weight: 800;
  letter-spacing: .04em;
  line-height: 1.2;
  color: var(--grey-light);
}
.orgmark__when {
  font-size: .78rem;
  font-weight: 700;
  color: var(--grey-light);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.papers { max-width: 56rem; margin-inline: auto; display: grid; gap: .85rem; }
.paper {
  display: grid;
  grid-template-columns: 3.5rem minmax(0, 1fr) 4rem;
  gap: 1rem;
  align-items: start;
  padding: 1.35rem 1.5rem;
  background: var(--white);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  transition: transform var(--fast) var(--ease), box-shadow var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.paper:hover { transform: translateY(-3px); border-color: var(--indigo-20); box-shadow: 0 16px 34px -20px rgba(102,112,255,.6); }
.paper[hidden] { display: none; }

/* Was a bare year in this column; it now holds the publisher's mark with the year under
   it, so the width is set by the tile rather than by four digits. */
.paper__mark { padding-top: .1rem; }
.paper__body h3 { font-size: 1.02rem; font-weight: 700; line-height: 1.35; margin-bottom: .35rem; }
.paper__body h3 a { color: var(--ink); text-decoration: none; }
.paper__body h3 a:hover { color: var(--indigo-text); text-decoration: underline; }
.paper__venue   { margin: 0 0 .2rem; font-size: .85rem; color: var(--grey); }
.paper__authors { margin: 0; font-size: .8rem; color: var(--grey-light); }
.paper__authors b { color: var(--ink); }

.cites {
  justify-self: end;
  padding: .25rem .7rem;
  border-radius: var(--r-pill);
  background: var(--indigo-05);
  font-size: .8rem; font-weight: 700;
  color: var(--grey);
  font-variant-numeric: tabular-nums;
}
.cites--top { background: rgba(249,71,6,.10); color: #c2380a; }

/* ─── EXPERIENCE & EDUCATION ──────────────────────────────────
   A profile-style history: institution mark, then role, employer, dates and place, then
   prose. Both lists — jobs and degrees — are the same component, because an entry in
   either reads the same way and there's no reason to build it twice. */

/* No halo. The bar over a .glowcard works on a card that stands alone; here there are two
   of them a heading apart, and the second one's halo lands close enough to the first card
   to read as a smudge rather than a flourish. The shadow alone gives enough lift. */
.cv::before { content: none; }

/* Sized to the text beside it, so the mark starts on the role line and finishes on the
   dates line rather than floating against the first line the way a small tile does.
   4.25rem is the measured height of the three-line block: 22 role + 3 + 22 employer + 2 +
   19 dates = 68px at a 16px root. Written in rem, not px, so it follows the root scale
   down at each breakpoint exactly as the three lines inside it do — they are rem too, and
   a fixed square would come adrift as soon as the text stepped and it didn't. It's one
   number rather than a stretch because a stretched square would change size from row to
   row whenever a role title wrapped, and five logos at four different sizes reads as a
   mistake. The tie holds while the role sits at its clamp ceiling, which is down to about
   776px; below that the role shrinks with the viewport and the mark runs a few px proud,
   and below 640 the phone block takes over with its own size and wrapped titles. */
.cv { --cv-logo: 4.25rem; }

.cv__item {
  display: grid;
  grid-template-columns: var(--cv-logo) minmax(0, 1fr);
  gap: 1.25rem;
  /* Hairlines between entries, not around each one. Boxing every job would say the three
     are separate things; they're one history read top to bottom. */
  padding: 1.75rem 0;
  border-top: 1px solid var(--hair);
}
.cv__item:first-child { padding-top: 0; border-top: 0; }
.cv__item:last-child  { padding-bottom: 0; }

/* Every mark is a 100x100 square that fills its own tile edge to edge — some on white,
   one solid black — so there's no padding here and contain has nothing to letterbox.
   The border is what keeps the white-background marks from dissolving into the card. */
.cv__logo {
  width: var(--cv-logo); height: var(--cv-logo);
  border: 1px solid var(--hair);
  border-radius: 18px;
  overflow: hidden;
  background: var(--white);
}
.cv__logo img { width: 100%; height: 100%; object-fit: contain; display: block; }

/* Three lines and nothing else, so the hierarchy has to come from weight and colour
   rather than from length: the role in full ink, the employer a step back, the dates and
   place a step further.

   All three are one step of the root scale (x0.875) below where they started, so the
   history reads as a list of facts rather than as a run of headings competing with the
   section title above it. The step is applied as a ratio, not as fixed px, so the role
   keeps tracking the viewport through its clamp. */
.cv__role { font-size: clamp(.875rem, 1.925vw, 1.0675rem); line-height: 1.3; margin: 0 0 .2rem; }
.cv__org  { margin: 0; font-size: .831rem; color: var(--grey); }
.cv__meta { margin: .15rem 0 0; font-size: .735rem; color: var(--grey-light); }

/* Education runs two across rather than stacked: each degree is three short lines, so a
   full-width row leaves most of the card empty, and side by side the pair reads as one
   fact about schooling instead of two separate events. Here the card is the list item,
   not the list — which flips where the padding and the hairline belong, hence the two
   overrides. Both need the descendant selector to outrank .cv__item and .glowcard, which
   are single-class rules and would otherwise win on source order. */
.cv--pair {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
}
.cv--pair .cv__item {
  padding: clamp(1.6rem, 3.2vw, 2.1rem);
  border-top: 0;
}
.cv--pair .cv__item::before { content: none; }
/* A notch down from the jobs above. Half the width means the longer of the two degree
   titles wraps to a second line at the full size, and a wrapped title is exactly what
   pushes the dates below the mark and breaks the alignment the square is sized for.
   Buys about 30px, which is enough for both. Stepped down with the jobs list above. */
.cv--pair .cv__role { font-size: clamp(.875rem, 1.75vw, .9625rem); }
/* One above the other from tablet down. Two across needs enough width for the mark plus a
   readable line beside it, and half a tablet no longer gives it: the degree titles wrap to
   three lines and the dates drop below the square, which is the alignment the whole item is
   built around. Full width restores it, and a stacked pair still reads as one block. */
@media (max-width: 1024px) { .cv--pair { grid-template-columns: 1fr; } }

/* The tablet step this used to carry now lives in the base rules above, where it applies
   to Education as well as to the jobs — so there is nothing left to do at 1024px. What is
   still needed is the opposite, below 640px: the root is already at 12px there, and a
   12.5% trim on top of that would put the dates line under 9px. That is the compounding
   that has bitten this file before — a per-element step stacked on the root scale — so the
   phone gets the untrimmed sizes back and takes its reduction from the root alone — see
   the 640px block at the foot of the file, with the rest of the phone overrides. */

/* Doubled deliberately. The rule this has to beat is .subhead:first-of-type, and the
   pseudo-class counts, so that one is (0,2,0) — a lone .subhead--gap at (0,1,0) loses
   however far down the file it sits. Matching its specificity hands the decision back to
   source order, which this wins. */
.subhead.subhead--gap { margin-top: 3.25rem; }

/* ─── TALKS ───────────────────────────────────────────────────── */
.talks { max-width: 56rem; margin-inline: auto; display: grid; gap: .6rem; }
.talk {
  display: grid;
  /* Wider than the 3.5rem tile, and wider than the papers list, because a talk is dated to
     the month: "Oct 2024" measures 59px and would hang out of a column cut to the tile. */
  grid-template-columns: 4.5rem minmax(0, 1fr);
  gap: 1rem;
  align-items: start;
  padding: 1.1rem 1.4rem;
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--white);
  transition: border-color var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.talk:hover { border-color: var(--indigo-20); transform: translateY(-2px); }
.talk__mark  { padding-top: .1rem; }
.talk__title { margin: 0 0 .2rem; font-size: 1rem; font-weight: 700; line-height: 1.35; }
/* Same treatment as a paper title: reads as the heading it is until you point at it. Not
   every talk has somewhere to go, so the styling has to sit on the anchor and not on the
   line — an unlinked title must look identical to a linked one at rest. */
.talk__title a { color: var(--ink); text-decoration: none; }
.talk__title a:hover { color: var(--indigo-text); text-decoration: underline; }
.talk__venue { margin: 0; font-size: .85rem; color: var(--grey); }

/* ─── CONTACT ─────────────────────────────────────────────────── */
.contact { overflow: hidden; }
.orbs--contact { z-index: 0; }
.contact .wrap { position: relative; z-index: 1; }

.mailto {
  display: inline-block;
  margin-bottom: 2rem;
  font-size: clamp(1.12rem, 4.2vw, 2.4rem);
  font-weight: 800;
  letter-spacing: -.03em;
  color: var(--ink);
  text-decoration: none;
  word-break: break-word;
  background-image: linear-gradient(var(--orange-text), var(--orange-text));
  background-size: 0% 3px;
  background-repeat: no-repeat;
  background-position: 0 100%;
  transition: background-size var(--med) var(--ease), color var(--fast) var(--ease);
}
.mailto:hover { color: var(--orange-text); background-size: 100% 3px; }

.links { display: flex; flex-wrap: wrap; justify-content: center; gap: .75rem; }
.ico { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }

/* ─── FOOTER ──────────────────────────────────────────────────── */
.foot { padding: 2.5rem 0; border-top: 1px solid var(--hair); }
.foot__inner { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; }
.foot__name { margin: 0; font-weight: 800; }
.foot__links { display: flex; flex-wrap: wrap; gap: 1.25rem; }
.foot__links a { color: var(--grey); font-size: .9rem; text-decoration: none; }
.foot__links a:hover { color: var(--orange-text); }
.foot__copy { margin: 0; font-size: .85rem; color: var(--grey-light); }

/* ─── REVEAL ──────────────────────────────────────────────────── */
.js .reveal { opacity: 0; transform: translateY(20px); }
.js .reveal.is-in {
  opacity: 1; transform: none;
  transition: opacity 620ms var(--ease), transform 620ms var(--ease);
  transition-delay: var(--d, 0ms);
}

/* ─── RESPONSIVE ──────────────────────────────────────────────── */
@media (max-width: 1040px) {
  .pillnav__link { padding: .5rem .8rem; font-size: .83rem; }
}

/* TABLET — labels stay on. The affiliation cards are the widest thing in the hero and
   they sit low, so the spheres all move up into the band beside the headline and the
   photo, where the content column is only ~330px wide and the margins are generous. */
/* No `and (min-width: 761px)` here on purpose: a viewport of, say, 760.4px — which
   happens with browser zoom and fractional device pixel ratios — would satisfy
   neither bound and fall back to the desktop layout. This block covers tablet AND
   phone, and the max-width:760px block below overrides every property it sets. */
/* Spread down the full height of the hero rather than bunched into its top two thirds.
   The margins beside the affiliation chips and the CTA are as wide as the ones beside the
   headline — wider, since those blocks are narrower than the photo — so there was nothing
   to be gained by stopping at 64% and leaving the bottom third of the band empty while
   the top was crowded. The lowest pair now finishes level with the bottom of the chips.
   Percentages are of the hero's height, so this holds as the hero grows or shrinks. */
@media (max-width: 1100px) {
  .orb--1 { width: 104px; height: 104px; top: 5%;  left: 2%;  }
  /* Sits lower than its neighbour: below 820px the burger takes this corner. */
  .orb--2 { width: 100px; height: 100px; top: 13%; right: 2%; }
  .orb--3 { width: 96px;  height: 96px;  top: 36%; left: 1%;  }
  .orb--4 { width: 96px;  height: 96px;  top: 40%; right: 1%; }
  /* Out to the same 2% inset as the top pair, not the 4% they had. That inset was set
     when they sat beside the photo, which is the narrowest thing in the hero; down here
     they're level with the affiliation chips, which reach much further out, and at 4%
     the gap came to 6px. */
  .orb--5 { width: 92px;  height: 92px;  top: 69%; left: 2%;  }
  .orb--6 { width: 92px;  height: 92px;  top: 67%; right: 2%; }
}

/* PHONE — three spheres down each side of the photo. There isn't room for that at
   the photo's normal size, so the photo shrinks here to open the margins up.
   Everything is anchored off --photo-w and --photo-cy (the photo's own width and its
   vertical centre, the same one the marquee uses), so the two columns stay locked to
   the photo instead of to the viewport edge. */
@media (max-width: 760px) {
  :root { --photo-w: clamp(120px, 27vw, 200px); }

  /* Scoped to the hero so the contact section's unlabelled orbs keep their own sizes. */
  .hero .orb {
    --float: -6px;                          /* short drift: the columns are a snug fit */
    /* Roughly a third of the photo's 27vw, so a stack of three tracks the photo's
       height instead of outgrowing it as the screen widens. */
    --orb-w: clamp(54px, 10vw, 78px);
    width: var(--orb-w); height: var(--orb-w);
  }
  /* Regular weight here, not bold: at this size the italic reads as a quiet caption
     rather than shouting off a 54px bubble. Sized off --orb-w rather than the
     viewport, so the type/bubble ratio is fixed and the longest single WORD
     ("Multiphase", about 43px at this ratio) still fits a 54px bubble. The two-word
     labels are expected to wrap to two centred lines — the label has no nowrap, and
     two lines at 1.15 line-height sit comfortably in the circle. What must never
     happen is one word wider than the bubble; check that before adding a longer one. */
  .orb__label { font-weight: 400; font-size: calc(var(--orb-w, 54px) * .148); }

  /* The columns are a little taller than the photo; these open the gaps they spill into. */
  .hero__role { margin-bottom: 3rem; }
  .portrait   { margin-bottom: 3rem; }

  /* Each column sits out in the margin between the photo and the screen edge, rather than
     parked 8px off the photo with the whole of the slack left over on the outside. The
     photo is only 120px on a phone, so that slack is real — 65px of it at 375px wide — and
     spending none of it crowded three bubbles against the picture while the edges of the
     screen stayed empty.

     Four tenths of the way out, not half. The rows below now run down past the affiliation
     chips, and those are the widest thing in the hero: two of them sit side by side up at
     755px, reaching 210px further out than the photo does. Centred in the slack, the bottom
     row cut 11px into the left-hand chip there and 2px into it at 320px. The extra tenth
     buys about 10px of clearance at both ends of the range and is invisible as a change of
     balance.

     Deriving it: the margin runs from 0 to (50% - photo/2), and an orb of --orb-w placed
     four tenths along it has its outer edge at 0.4(50% - photo/2 - orb) + orb, which is
     80% + photo/5 - 3*orb/5 measured from the far side. Both columns take the same
     expression — the layout is symmetric, so the mirrored offset is identical. Still
     anchored to the photo's width, so the columns track it, not the viewport edge. */
  .orb--1, .orb--3, .orb--5 { left: auto; right: calc(80% + var(--photo-w) * .2 - var(--orb-w) * .6); }
  /* Right column, mirrored. */
  .orb--2, .orb--4, .orb--6 { right: auto; left: calc(80% + var(--photo-w) * .2 - var(--orb-w) * .6); }

  /* Rows a clear 0.6 of a bubble apart instead of 6px, and the column dropped by half a
     bubble so the extra height is spent downwards. Six px between three spheres read as
     one clotted mass; at 0.6 they're separate objects.

     The step between rows is 1.6x --orb-w — one bubble plus the gap — so the spacing grows
     with them and the arrangement is the same shape on a 320px screen as on a 760px one.
     The drop is what buys the room: spreading around --photo-cy would have pushed the top
     row up into the rotating role line, and there's only about 9px there. Downwards there
     is the whole run to the affiliation chips, and the column now finishes level with the
     top of the second one. That is as far as it goes — the third chip is the widest of
     them and reaches back under the columns, so a row level with it would collide. */
  .orb--1, .orb--2 { top: calc(var(--photo-cy, 52%) - 1.6 * var(--orb-w)); }
  .orb--3, .orb--4 { top: var(--photo-cy, 52%); }
  .orb--5, .orb--6 { top: calc(var(--photo-cy, 52%) + 1.6 * var(--orb-w)); }
  .orb--6 { display: grid; }

  /* Affiliation cards dialled back too — at the earlier size they were the loudest
     thing in the hero on a phone. Same one-line-per-row guarantee, smaller type. */
  .cred     { font-size: clamp(.58rem, calc(3.1vw - 1.2px), .78rem); }
  .cred img { width: 2.5em; height: 2.5em; border-radius: .7em; }
}


@media (max-width: 820px) {
  .pillnav, .navbar__cta { display: none; }
  .burger { display: block; }
  .navbar { justify-content: flex-end; }
  .hero { padding-top: 7rem; }
  .stack-cards__item { position: static; }
}

@media (max-width: 640px) {
  /* The hero spheres are laid out by the max-width:760px block above. These two are the
     contact section's. The bubbles they replaced were pushed half off-canvas here; a
     recognisable object can't be — a half-clipped envelope reads as a broken image, not
     as decoration — so they shrink and stay whole instead.

     At this width every block of copy runs the full column and the section's padding is
     only 48px, so there is no band the old percentages can land in without something
     sitting on top of them. The email address is the exception — it's an inline-block
     roughly half the screen wide, leaving ~90px clear on each side — so both shapes are
     hung off its centre line, which js/main.js measures and republishes as --mail-cy.
     Falls back to 55% of the section if the script never runs. The drift is shortened
     to match the tighter space. */
  .glass { --float: -8px; }
  .glass--phone {
    width: 40px; left: 2%;
    top: calc(var(--mail-cy, 55%) - 31px); bottom: auto;
  }
  .glass--mail {
    width: 74px; right: 2%;
    top: calc(var(--mail-cy, 55%) - 26px); bottom: auto;
  }

  .wrap { padding-inline: 1.15rem; }

  /* One line, not two. At 375px this sentence needs 353px and the column offers 347 — it
     wraps by six pixels, which drops "day to day." onto a line of its own and reads as a
     mistake rather than a paragraph. There's no width to borrow here the way .lede--wide
     borrows it on a desktop, so the type gives up the six pixels instead: the vw term
     tracks the column, keeping the line whole from 320px up, and min() hands back to the
     authored size above ~405px where it already fits. Scoped to this one lede — the rest
     are either shorter or deliberately several lines long, and shrinking those would give
     up size for nothing. */
  #stack .lede { font-size: min(1.08rem, 3.2vw); }
  /* One column, so the illustration falls under the paragraph it belongs to rather than
     beside it — there is no width here for a third column that wouldn't starve the text.
     It's centred and given its own space, which is what stops it reading as a stray
     thumbnail hanging off the end of the copy. */
  .service { grid-template-columns: 1fr; gap: .5rem; padding: 1.75rem 0; }
  .service__num { font-size: 2.3rem; }
  .service__img { justify-self: center; margin-top: 1rem; }
  /* Both lists drop to one column and the mark moves to a row of its own under the text.
     A 3.5rem tile beside a title on a 320px screen would leave the title about 180px, and
     these titles are long enough already. Laid out side by side on that row instead of
     stacked, so the row costs one line rather than two. */
  /* The mark becomes a chip on its own line above the title, in both lists — a talk falls
     into that shape on its own once the grid is one column, and the papers are steered into
     it so the two read the same way. The citation count keeps the top-right corner. */
  .paper { grid-template-columns: minmax(0, 1fr) auto; gap: .5rem .6rem; }
  .paper__mark { grid-row: 1; grid-column: 1; padding-top: 0; }
  .paper__body { grid-row: 2; grid-column: 1 / -1; }
  .paper .cites { grid-row: 1; grid-column: 2; }
  .talk { grid-template-columns: 1fr; gap: .35rem; }
  .talk__mark { padding-top: 0; }
  .orgmark {
    grid-auto-flow: column;
    justify-content: start;
    justify-items: start;
    align-items: center;
    gap: .55rem;
  }
  .orgmark__tile { width: 2.5rem; height: 2.5rem; border-radius: 11px; padding: .25rem; }
  .orgmark__tile--text { font-size: .5rem; padding: .2rem; }
  /* One picture per row on a phone, and the max-widths that hold a band down on a wide
     screen are dropped — at this size the column is already the constraint on width.

     But width is the wrong constraint to leave a picture to on its own. Handing every
     figure the full column made the portraits come out *taller* on a phone than on a
     desktop: the 5-up profile strips went 281px tall on a desktop, 246 on a tablet, then
     641 here, and the 6-up went 224 → 196 → 626. Same fault .about-figs guards against
     with its 19rem step, arriving by the other route — there the single column widened
     each figure, here it stretches them downwards.

     So the phone sizes each figure by HEIGHT and lets width follow its own ratio, which
     is what .about-fig--photo already does with --gh. One cap governs the section:

         width  =  min(column, cap x --ar)

     Every phone figure therefore lands on one height, and the cap is what that height is.
     14rem is not a guess: a band's height on a tablet falls as the number of figures in
     it rises, and the shallowest any band gets there is the 3-up in project 04, at 168px.
     Match that and no figure in the section can come out taller on a phone than it was on
     a tablet — the 3-up ties, everything else steps down. A larger cap breaks exactly
     that band and nothing else, which is what 16rem did before this was measured.

     The result: the 5-up profile strips end at 81x168 and the tallest pair at 94x168,
     against 119x246 and 135x241 on a tablet. A 16\9 figure comes within about ten pixels
     of the full column. In rem, so it rides the root scale with everything else. */
  .shotband { display: block; max-width: none; }
  .shotband .shot {
    width: min(100%, calc(14rem * var(--ar, 1)));
    margin-inline: auto;
  }
  .shotband .shot + .shot { margin-top: .85rem; }

  /* Bands that opt out of the stack and stay a row on a phone, scrolling sideways only if
     the row will not fit. Used where the row *is* the information: the two model-profile
     sets in project 02 are five and six slices of the same reactor read against each other
     column by column, and the pair closing project 04 is a film of the fibres being made
     beside the rig they were then tested on. A column destroys that reading.

     16rem, not the 14rem the stack uses: nothing else shares the row's width any more, so
     the figures can be taller. It is still under the shallowest these bands reach on a
     tablet, which keeps the run of sizes going one way the whole way down. The profile
     rows overflow — about three sit in view and the one cut off at the right edge is what
     tells a reader the row moves, so no arrows or dots are needed. The pairs fit and sit
     centred.

     --row-h overrides that height per band, the same way max-width is overridden inline on
     a band that needs a different width. It is there for a pair of near-square pictures:
     at 16rem two of those come to more than the column and would scroll a few pointless
     pixels, when trimming the height a little makes them fit outright. Scrolling should be
     the answer to a row that genuinely cannot fit, not to one that misses by ten pixels.

     safe center, not plain center: a centred flex row that overflows its scroll container
     puts the leading edge out of reach in every engine, and the profile bands do overflow.
     The safe keyword drops back to flex-start exactly when that would happen.

     overscroll-behavior-x stops a swipe that runs past the end from being taken as the
     browser's back gesture. Tapping still opens the lightbox — a drag does not fire click,
     so the two gestures do not collide.

     No scroll-snap. It was here and came out: with figures this narrow a snap container
     picks its own initial position, and a band loaded already scrolled 170px in, opening
     on the third slice instead of the first. These rows read left to right in reaction
     order, so starting in the middle loses the one thing they are arranged to show. Free
     scrolling has no such surprise, and snapping buys little when three items fit at
     once. */
  .shotband--row {
    display: flex;
    flex-wrap: nowrap;
    justify-content: safe center;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: thin;
    padding-bottom: .35rem;
  }
  .shotband--row .shot {
    flex: 0 0 auto;
    width: calc(var(--row-h, 16rem) * var(--ar, 1));
    margin-inline: 0;
  }
  .shotband--row .shot + .shot { margin-top: 0; }
  .glowcard { border-radius: 32px; }
  .project { border-radius: 32px; }

  /* ── Phone layout tweaks ─────────────────────────────────────
     This block used to hold a font-size trim for every element on the page — body copy,
     card headings, captions, meta lines — because there was no other way to bring the
     type down for a phone. The root scale does that now, and it does it uniformly, so
     those overrides became a second reduction stacked on the first: a caption at .72rem
     landed on 8.6px rather than the 11.5px it was written for, and the hierarchy that
     had been tuned at full size came out compressed at the bottom.

     So they're gone, and each element keeps the rem it has everywhere else. The root
     moves them all together and the proportions survive the trip down.

     What's left is the part a type scale genuinely can't do — sizes in px, which don't
     follow the root, and the one letter-spacing that needed easing as the label shrank. */
  .subhead { letter-spacing: .12em; }

  /* ── Stack tiles, three across ───────────────────────────────
     An explicit three rather than auto-fit: the 200px track the grid asks for elsewhere
     can only ever fit one on a 375px screen, and no minmax floor small enough to fit
     three would still be honest about what a tile needs.

     Everything inside has to come down with the column, which is about 110px wide here
     against roughly 300px before. The padding is the biggest single saving — at the
     desktop 1.75rem/1.25rem it alone would eat a third of the tile — and the logo band
     follows, since a 52px band over a 110px tile is most of what you'd see. */
  .stackgrid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .5rem; }

  /* One step down again — the same 0.875 the root scale takes at each breakpoint, applied
     to all four kinds of mark so they shrink together and the band stays common to them.

     The width limit on the wordmarks is what actually does the work here. They're the only
     ones that were reading as oversized, because width:100% ran them the full content box
     of the tile — COMSOL and OpenFOAM came out 105px across a 117px tile while a square
     mark took 26px — and lowering max-height alone would have left both untouched, since
     neither was anywhere near it. 87.5% brings them in to about 93px. */
  .tool { padding: .8rem .45rem; border-radius: 16px; }
  .tool__logo             { height: 30px; margin-bottom: .5rem; }
  .tool__logo--mark img   { height: 23px; }
  .tool__logo--word img   { width: 87.5%; max-height: 19px; }
  .tool__icon             { width: 30px; height: 30px; margin-bottom: .5rem; border-radius: 10px; }
  .tool__icon svg         { width: 16px; height: 16px; }

  /* Tighter than the root scale alone would give them. A tile title is two or three
     words on one or two lines at this width, so it can take a step down without
     becoming harder to read than the line length already makes it. */
  .tool h3 { font-size: .82rem; line-height: 1.25; margin-bottom: .2rem; }
  .tool p  { font-size: .72rem; line-height: 1.35; }

  /* Smaller than the 76px the mark takes elsewhere, and for a different reason: the role
     titles wrap to two or three lines here, so the body is taller than the square either
     way and there's no alignment left to preserve. It just has to stay clear of the text. */
  .cv       { --cv-logo: 56px; }
  .cv__logo { border-radius: 14px; }

  /* Undoing the step the base rules take, for both lists. These are the sizes the section
     was authored at, not smaller numbers written for a 16px root — the 12px root is what
     does the shrinking here, and anything on top of it compounds. The pair rule comes
     second on purpose: it ties with the one above on specificity, so order decides. */
  .cv .cv__role       { font-size: clamp(1rem, 2.2vw, 1.22rem); }
  .cv--pair .cv__role { font-size: clamp(1rem, 2vw, 1.1rem); }
  .cv .cv__org        { font-size: .95rem; }
  .cv .cv__meta       { font-size: .84rem; }

  .foot__links a { font-size: .82rem; }
  .foot__copy    { font-size: .78rem; }
}

/* ─── MOTION & PRINT ──────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
  .js .reveal { opacity: 1; transform: none; }
  .marquee__track { animation: none; }
  .logoband__track, .mediaband__track { animation: none; }
  /* Removed rather than frozen. The rule above stops the angle animating, which would
     leave the comet parked on one side of the button looking like a rendering fault
     instead of a deliberate mark. The button keeps its own border and shadow. */
  .pill--glow::before, .pill--glow::after { display: none; }
  .orb, .glass { animation: none; }
  .scrollbadge__spin { animation: none; }
  .scrollbadge__arrow { animation: none; }
  /* The card still turns over on hover — that's the answer to a deliberate action, not
     ambient motion — but it swaps rather than sweeping through 3D. */
  .portrait__flip { transition: none; }
}

@media print {
  .navbar, .drawer, .orbs, .marquee, .logoband, .filters, .foot__links { display: none; }
  .pill--glow::before, .pill--glow::after { display: none; }   /* a light that can't move */
  /* Back to the still block: printing the strip as-is would put one photo and two halves
     on the page and hide the rest off the edge of the paper. Class selectors on both,
     because that's what the live rules use and these have to outrank them. */
  .mediaband.is-live { overflow: visible; mask-image: none; -webkit-mask-image: none; }
  .mediaband__track.is-live {
    flex-wrap: wrap; justify-content: center; gap: 1.5rem 1rem;
    width: auto; max-width: 55rem; margin-inline: auto; animation: none;
  }
  .mediaband__track.is-live > * { margin-inline-end: 0; }
  .mediaband__track [data-clone] { display: none; }
  body { background: #fff; color: #000; }
  .js .reveal { opacity: 1; transform: none; }
  .section { padding: 1.25rem 0; }
  .glowcard, .project, .paper, .talk { box-shadow: none; border: 1px solid #ccc; }
  .stack-cards__item { position: static; }
}
