/* Director's webfonts. The ported loader and views are metric-dependent on these: without
   them Archivo falls back to a system sans and the counter's size and spacing land wrong. */
@font-face{font-family:'Archivo';font-style:normal;font-weight:400 700;font-display:swap;src:url('../fonts/Archivo-400700.woff2') format('woff2');}
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:200;font-display:swap;src:url('../fonts/BarlowCondensed-200.woff2') format('woff2');}
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:300;font-display:swap;src:url('../fonts/BarlowCondensed-300.woff2') format('woff2');}
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:400;font-display:swap;src:url('../fonts/BarlowCondensed-400.woff2') format('woff2');}
@font-face{font-family:'Barlow';font-style:normal;font-weight:300;font-display:swap;src:url('../fonts/Barlow-300.woff2') format('woff2');}
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url('../fonts/Barlow-400.woff2') format('woff2');}
@font-face{font-family:'Barlow';font-style:normal;font-weight:500;font-display:swap;src:url('../fonts/Barlow-500.woff2') format('woff2');}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:300;font-display:swap;src:url('../fonts/IBMPlexMono-300.woff2') format('woff2');}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('../fonts/IBMPlexMono-400.woff2') format('woff2');}

/* ════════════════════════════════════════════════════════════════════
   DESCENT — prototype stylesheet
   Alexander Ingare · cinematic scroll narrative
   ════════════════════════════════════════════════════════════════════ */

/* ── tokens ───────────────────────────────────────────────────────── */
/* ── breakpoints ─────────────────────────────────────────────────────────────
   Four, and every media query in this file uses one of them:

     1200px   the altimeter readout goes; nothing else needs this much room
     1100px   two-column layouts become one: the About identity, the entry mark,
              the project tiles
      860px   the HUD compacts, the nav gains its scroll fade, side rails go inline
      700px   definition lists and honour rows stop being two columns

   There were thirteen values before, including a 640 and a 720 twelve lines apart
   doing the same job, and an 861 whose only purpose was to be one more than 860.
   Consolidating moves behaviour between the old threshold and the new one, which is
   the point: four places to reason about instead of thirteen.

   Custom properties cannot be used in a media query, so these are literals by
   necessity. Where a component should respond to ITS OWN width rather than the
   window's, it uses a container query instead and does not appear here at all.
   ────────────────────────────────────────────────────────────────────────── */

/* ── design tokens ───────────────────────────────────────────────────────────
   One block, three tiers. Components reach for the SEMANTIC tier; the palette is
   raw material and the system tier is type, motion and the fixed chrome.

   This was two :root blocks 700 lines apart, which is how #8f7e77 ended up with
   three names (--sand, --taupe, --c-about), and how sixteen tokens came to be
   defined and never referenced. Any literal colour still sitting in a rule below
   is a token that has not been made yet.
   ────────────────────────────────────────────────────────────────────────── */
:root{
  /* palette ─ raw colour, named for what it is */
  --void:      #04060a;
  --void-2:    #070b12;
  --sand:      #8f7e77;
  --sand-hi:   #c8b7ab;
  --sand-dim:  #6c5f5a;
  --ember:     #c4753f;

  /* semantic ─ named for the job it does; the tier a component should use */
  --text:      #dbd6d0;
  --text-hi:   #e9edf3;
  --muted:     #979189;
  --dim:       #6d788a;
  --dimmer:    #4a5364;
  --line:      rgba(219,214,208,.10);
  --line-soft: rgba(219,214,208,.055);
  --line-2:    rgba(160,180,205,.26);

  /* type ─ two stacks, and having measured it, this is a design and not an accident. The
     split falls exactly on the landing/destination boundary: --hud, --disp and --mono are
     used by 29 rules whose classes appear ONLY in the destinations and never once on the
     landing, and --f-display, --f-mono and --f-body by rules that are almost entirely the
     landing's. Two registers, cinematic and documentary, which is what the site is.

     One rule crosses the line: .also h5 (~line 600) sets --f-display, and .also appears
     only in the destinations, where every other heading is --disp. Almost certainly a
     leak rather than an intention, but changing it changes a typeface on a page, so it is
     recorded here rather than quietly fixed. One edit either way. */
  --f-display: "TeX Gyre Heros Cn","Heros Cn","Roboto Condensed","PT Sans Narrow","Arial Narrow",Helvetica,sans-serif;
  --f-mono:    "Go Mono","PT Mono","DejaVu Sans Mono",ui-monospace,monospace;
  --f-body:    "Cabin","Clear Sans","Lato",system-ui,sans-serif;
  --hud:       'Barlow Condensed',ui-sans-serif,sans-serif;
  --disp:      'Archivo',ui-sans-serif,sans-serif;
  --mono:      'IBM Plex Mono',ui-monospace,monospace;

  /* The mono kicker label is one typographic element and it had eighteen tunings: five sizes and
     eight letter-spacings across twenty-three rules, each decided once and never compared with the
     others. Three steps, so the next label is a choice from three rather than another invention.
     Size and tracking travel together because they were never independent decisions -- a label set
     smaller was always given more air to stay legible.

     The middle step is 12.5px and not 12px, which was the most COMMON of the five sizes. Tried both:
     at 12px the venue chips, the entry dates and the contact labels all lost half a pixel, and those
     sit beside large display headings and read thin without it. At 12.5px they come out identical to
     what they were, and the whole experience and contact views move by less than the harness's own
     noise. Most common was not the same as right. */
  --lbl-s:     11px;   --lbl-s-ls: .16em;   /* inside a dossier, inside a tile */
  --lbl-m:     12.5px; --lbl-m-ls: .16em;   /* list labels, dates, chips, section links */
  --lbl-l:     13.5px; --lbl-l-ls: .2em;    /* the destination index, once per view */

  /* system ─ spacing, motion, and the chrome the layout is measured against */
  --pad:       clamp(20px, 5.2vw, 78px);
  --maxw:      1280px;
  --top:       74px;
  --bot:       56px;
  --ease:      cubic-bezier(.16,.84,.28,1);
  --ease-io:   cubic-bezier(.66,0,.24,1);
  --ease-out:  cubic-bezier(.16,1,.3,1);
  --ease-cine: cubic-bezier(.62,.02,.2,1);
  --ease-mask: cubic-bezier(.76,0,.24,1);
}

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

html{
  background:var(--void);
  /* The fallback, for when js/smooth-scroll.js bails because Lenis did not load. Where Lenis IS
     running it drives the real scroll position itself and needs the native behaviour out of the
     way, so the rule below hands it over.

     Lenis ships that override itself, as `.lenis.lenis-smooth { scroll-behavior: auto !important }`,
     but it never applies here: the class actually set on <html> is `lenis` alone, so the computed
     behaviour stayed smooth and the two were animating the same scroll at once. Keyed to the class
     that is really there rather than the one the vendor stylesheet expects. */
  scroll-behavior:smooth;
  scroll-padding-top:96px;
  -webkit-text-size-adjust:100%;
  /* No scrollbar. The rail, the altimeter and the progress line along the top of the HUD already say
     where you are in the descent, and a browser scrollbar down the right edge competes with the rail
     for the same job in a different visual language. Scrolling itself is untouched -- only the widget
     is hidden -- and the two properties cover Firefox and the WebKit/Blink pseudo-element below. */
  scrollbar-width:none;
  -ms-overflow-style:none;
}
html.lenis{scroll-behavior:auto}
html::-webkit-scrollbar{width:0;height:0}

body{
  margin:0;
  background:var(--void);
  color:var(--text);
  font-family:var(--f-body);
  font-size:clamp(16px,1.06vw,18px);
  line-height:1.62;
  letter-spacing:.004em;
  overflow-x:hidden;
  -webkit-font-smoothing:antialiased;
}
/* html.lenis body{height:auto} ships in lenis.css and outweighs a bare body.is-loading, which let
   the page scroll underneath the loader. Naming html here takes the specificity back. */
html body.is-loading{overflow:hidden;height:100vh}
/* Deep link: no plate, and the page is not held closed waiting for one. Set by the inline script in
   the head, so this applies on the very first paint. */
html.no-intro .loader{display:none}
html.no-intro body.is-loading{overflow:visible;height:auto}
/* Loading straight onto a tab used to show the landing for a frame or two before the router opened
   the destination over it. The landing is hidden from the first paint instead, and the class is
   dropped once the router has taken over. */
html.no-intro main,html.no-intro .rail,html.no-intro .stage,html.no-intro .grade{visibility:hidden}

::selection{background:rgba(143,126,119,.35);color:#fff}

a{color:inherit}
/* h4-h6 were left out, so every entry heading on the site carried the browser's own ~1em margin:
   measured, 30.6px under a company name in a job entry and 33px under a project title, which is why
   the role line looked detached from the name above it. Spacing is set deliberately below. */
h1,h2,h3,h4,h5,h6{margin:0;font-weight:400}
p{margin:0}
ul,ol,dl,dd,dt{margin:0;padding:0;list-style:none}

/* ── shared type utilities ────────────────────────────────────────── */
.mono{
  font-family:var(--f-mono);
  font-size:clamp(11.5px,.85vw,13px);
  letter-spacing:.34em;
  text-transform:uppercase;
  color:var(--sand);
  line-height:1.7;
  font-variant-numeric:tabular-nums;
}
.mono b{color:var(--sand-hi);font-weight:400}
.sep{opacity:.42;padding:0 .25em}

.dia{
  display:inline-block;
  width:5px;height:5px;
  background:var(--sand);
  transform:rotate(45deg);
  vertical-align:.08em;
  margin:0 .5em 0 .1em;
}

.wrap{
  width:100%;
  max-width:var(--maxw);
  margin:0 auto;
  padding-inline:var(--pad);
  position:relative;
}

.skip{
  position:fixed;top:-100px;left:16px;z-index:200;
  background:var(--sand-hi);color:#0a0705;
  font-family:var(--f-mono);font-size:13.5px;letter-spacing:.2em;text-transform:uppercase;
  padding:10px 16px;text-decoration:none;
}
.skip:focus{top:14px}

/* ════════════════════  ATMOSPHERE STAGE  ════════════════════ */
.stage{position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden}

.nebula{
  position:absolute;inset:-10%;
  background:
    radial-gradient(40vw 32vw at 20% 24%, rgba(44,76,124,.44), transparent 68%),
    radial-gradient(32vw 28vw at 80% 14%, rgba(64,50,92,.38), transparent 70%),
    radial-gradient(56vw 44vw at 58% 64%, rgba(22,38,66,.42), transparent 74%),
    radial-gradient(24vw 20vw at 38% 78%, rgba(96,74,66,.16), transparent 72%);
  filter:saturate(.9);
  will-change:transform,opacity;
}

.sky{position:absolute;inset:0;width:100%;height:100%;display:block}

/* cloud bands — atmosphere between viewer and surface */
.cloudband{
  position:absolute;left:-30%;width:160%;
  opacity:0;will-change:transform,opacity;
  pointer-events:none;
}
.cloudband--a{
  top:34%;height:36vh;
  background:
    radial-gradient(46% 52% at 18% 60%, rgba(148,168,196,.20), transparent 70%),
    radial-gradient(38% 46% at 52% 46%, rgba(174,158,146,.20), transparent 72%),
    radial-gradient(44% 40% at 84% 62%, rgba(120,142,172,.16), transparent 74%);
}
.cloudband--b{
  top:52%;height:44vh;
  background:
    radial-gradient(52% 54% at 30% 54%, rgba(196,170,150,.22), transparent 70%),
    radial-gradient(40% 48% at 70% 62%, rgba(160,136,120,.20), transparent 72%);
}

/* haze layers — the light change from space to surface */
.haze{position:absolute;inset:0;opacity:0;will-change:opacity}
.haze--cool{
  background:
    linear-gradient(to top, rgba(28,56,96,.62) 0%, rgba(16,32,58,.30) 34%, rgba(8,14,26,0) 68%);
}
.haze--warm{
  background:
    linear-gradient(to top, rgba(154,108,74,.44) 0%, rgba(120,92,76,.26) 24%, rgba(74,66,70,.13) 54%, rgba(20,22,32,0) 84%);
}

/* the ground arrives last */
.ground{
  position:absolute;left:0;right:0;bottom:0;height:50vh;
  transform:translate3d(0,100%,0);
  will-change:transform;
  /* PROTOTYPE: a plain, not a void. It ran #0a0705 to #030202 -- black in everything but name --
     so everything below the ranges read as a hole in the page rather than as ground the site stands
     on. Dust catching the horizon at the far edge and falling away into shadow toward the viewer,
     which is the direction the light actually goes.

     The brown carries further down than it did. The stops fell away early -- half the darkening was
     spent by 22% and it was into single figures by three quarters -- and measured across the middle
     of the frame the bottom rows sat at 6 to 9 luminance, which is not shadow, it is the hole again.
     The falloff now holds brown past halfway and bottoms out around 16, so the foreground still
     reads as ground in shadow rather than as the end of the page. */
  background:linear-gradient(to bottom,
    #4a3a2c 0%, #413024 14%, #372a1f 34%, #2d211a 54%, #241a14 72%, #1c1410 88%, #160f0c 100%);
}
.ground::after{
  /* PROTOTYPE: carried BELOW the horizon and faded out there, instead of stopping dead on it.
     The gradient is `to top`, so its first stop -- the full rgba(214,164,116,.26) -- sat on this
     element's bottom edge, which is exactly the top edge of .ground. Full warmth above the line and
     none below it, in 1,433 of 1,440 columns: measured at seed 44 the colour fell from rgb(114,87,64)
     to rgb(33,23,16) and recovered immediately, which is the hard horizon people were seeing wherever
     a crest happened to dip near it.
     40vh tall against a 30vh offset puts the old boundary at 25% from the bottom, so the wash now
     reaches full strength there and tapers over the 10vh below.

     Weakened from .26/.09 once the range stopped being a flat silhouette. The wash reaches 30vh
     above the line, which is the whole of the band the mountains stand in, and rgba(214,164,116) is
     luminance 172 -- at .26 it was pulling the range's shadow faces from luminance 17 up to 36 and
     the median from 61 to 79, which is most of why the scene looked washed out beside
     lab/silhouettes.html while running identical shading code. At .12 the seam this exists to hide
     is still hidden: the median column step across the horizon goes 2.10 to 2.29. */
  content:"";position:absolute;left:0;right:0;top:-30vh;height:40vh;
  background:linear-gradient(to top,
    rgba(214,164,116,0) 0%, rgba(214,164,116,.12) 25%, rgba(158,116,86,.04) 60%, transparent 100%);
}
/* The band the range lives in. It does not move; the ranks move inside it and it cuts them off at
   the line where the desert begins. Without it the parallax hangs them below that line on the way
   in -- measured at the start of the reveal the far rank reached plain-y 262 against a line at 130 --
   and the mounds wash forward over the desert instead of rising from behind it. It has to be a
   parent rather than a clip on the ranks themselves, because a clip-path travels with the element's
   own transform and would slide down with them. */
.ridge-clip{
  position:absolute;left:0;right:0;top:-28.889%;height:57.778%;
  overflow:hidden;
}
.ridge{
  /* Sized to its band. That band is a fraction of .ground rather than a pixel height, so the base of
     the range lands on the same line of the plain's own coordinates at every window size: the plain
     is 450 units over a 50vh box, so a fixed 260px bottomed out at plain-y 130 in a 900px window and
     at 97 in a 1200px one, and anything placed on that line drifted off it. */
  position:absolute;inset:0;width:100%;height:100%;
  /* one of these per rank, stacked, each moved on its own for the parallax */
  will-change:transform;
}


/* faint HUD grid */
.gridlines{
  position:absolute;inset:0;
  background-image:
    repeating-linear-gradient(to right, rgba(219,214,208,.045) 0 1px, transparent 1px 12.5%),
    repeating-linear-gradient(to bottom, rgba(219,214,208,.028) 0 1px, transparent 1px 25%);
  mask-image:radial-gradient(120% 90% at 50% 40%, #000 22%, transparent 78%);
  opacity:.7;
}

/* ════════════════════  TOP OVERLAYS  ════════════════════ */
.grade{
  position:fixed;inset:0;z-index:90;pointer-events:none;
  background:#c08b5e;mix-blend-mode:soft-light;opacity:0;
  will-change:opacity;
}
.vignette{
  position:fixed;inset:0;z-index:91;pointer-events:none;
  background:
    radial-gradient(128% 100% at 50% 44%, transparent 40%, rgba(0,0,0,.42) 78%, rgba(0,0,0,.72) 100%),
    linear-gradient(to bottom, rgba(0,0,0,.42), transparent 16%);
}
.grain{
  position:fixed;top:-50%;left:-50%;width:200%;height:200%;
  z-index:92;pointer-events:none;opacity:.055;mix-blend-mode:overlay;
  background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23n)'/%3E%3C/svg%3E");
  animation:grainshift 1.1s steps(5) infinite;
}
@keyframes grainshift{
  0%{transform:translate3d(0,0,0)}
  20%{transform:translate3d(-2%,1.5%,0)}
  40%{transform:translate3d(1.6%,-1.2%,0)}
  60%{transform:translate3d(-1.2%,-2%,0)}
  80%{transform:translate3d(2%,1%,0)}
  100%{transform:translate3d(0,0,0)}
}

/* ════════════════════  HUD NAV  ════════════════════ */
.hud{
  position:fixed;top:0;left:0;right:0;z-index:70;
  display:flex;align-items:center;gap:clamp(12px,3vw,48px);
  padding:0 var(--pad);
  height:66px;
  background:linear-gradient(to bottom, rgba(4,6,10,.82), rgba(4,6,10,.34) 70%, transparent);
  backdrop-filter:blur(6px);
  transform:translate3d(0,-100%,0);
  transition:transform 1.15s var(--ease) .12s;
}
body.is-revealed .hud{transform:none}

.hud__mark{
  display:flex;align-items:center;gap:2px;text-decoration:none;
  font-family:var(--f-mono);font-size:13px;letter-spacing:.28em;text-transform:uppercase;
  color:var(--text);white-space:nowrap;
}
.hud__mark .dia{background:var(--sand-hi)}
.hud__mark:hover .dia{background:var(--ember)}

.hud__nav{
  display:flex;gap:clamp(6px,1.4vw,22px);margin-left:auto;
  overflow-x:auto;scrollbar-width:none;
}
/* There was a fade along the right edge below 860px, as an affordance for a nav that had to be
   scrolled sideways. It does not have to be scrolled any more -- the type above is sized so all five
   destinations fit from 320px up -- and a fade over the last 10% of a nav that fits is not an
   affordance, it is the last word dimmed to nothing. It landed squarely on Contact, which is the one
   this whole change exists to make reachable. overflow-x on the nav stays as the safety net. */
.hud__nav::-webkit-scrollbar{display:none}
.hud__nav a{
  position:relative;text-decoration:none;white-space:nowrap;
  /* Tighter than the rest of the mono labels. Six destinations at a legible size need the room,
     and .26em on an uppercase mono face spends most of a nav bar on the spaces between letters.

     At a fixed 12px and .16em the five labels need 387px, and a 360px phone gives the bar 297. So
     Contact sat 90px past the right edge and Projects was cut in half. The nav scrolls sideways and
     is masked to say so, but the contact link is the point of a portfolio and nobody swipes a nav
     bar to look for it.

     Scaled rather than stepped, and in px rather than em, because letter-spacing in em compounds the
     size reduction and collapses far too fast. Both are pinned to 12px/.16em from 860px up, so
     nothing above a phone changes at all. The floors are what 320px can hold. Measured, all five fit
     with the bar to spare: 45px at 360, 68px at 390, 150px at 500.

     Not a fifth breakpoint, deliberately -- this file keeps to four, and a value that has to be
     continuous between two of them is exactly what clamp is for. */
  font-family:var(--f-mono);
  font-size:clamp(9.5px, calc(8.02px + 0.463vw), 12px);
  letter-spacing:clamp(.35px, calc(-0.58px + 0.291vw), 1.92px);
  text-transform:uppercase;
  color:var(--muted);padding:8px 0;
  transition:color .5s var(--ease);
}
.hud__nav a::after{
  content:"";position:absolute;left:0;right:0;bottom:2px;height:1px;
  background:var(--sand-hi);transform:scaleX(0);transform-origin:left;
  transition:transform .6s var(--ease);
}
.hud__nav a:hover,.hud__nav a:focus-visible{color:var(--sand-hi)}
.hud__nav a:hover::after,.hud__nav a:focus-visible::after{transform:scaleX(1)}

.hud__alt{
  display:flex;align-items:baseline;gap:.45em;margin-left:clamp(12px,2.4vw,36px);
  font-family:var(--f-mono);font-size:12px;letter-spacing:.24em;text-transform:uppercase;
  color:var(--sand-dim);
}
.hud__altval{color:var(--sand-hi);font-size:14.5px;letter-spacing:.14em;font-variant-numeric:tabular-nums}
.hud__progress{position:absolute;left:0;right:0;bottom:0;height:1px;background:var(--line-soft)}
.hud__progress i{
  display:block;height:100%;background:linear-gradient(to right,var(--sand-dim),var(--sand-hi));
  transform:scaleX(0);transform-origin:left;
}

/* beat rail */
.rail{
  position:fixed;top:50%;transform:translateY(-50%);
  z-index:65;pointer-events:none;
  opacity:0;transition:opacity 1.4s var(--ease) .6s;
  /* Snapped to a whole pixel. The centers of the line and the diamonds agree exactly in CSS, but
     --pad is a vw clamp, so the rail landed on x.5625 -- and there a 1px line is spread as gray
     across two device pixels while a rotated 5px square stays crisp, which reads as the diamonds
     sitting a pixel off the line. Declared twice on purpose: a browser without round() keeps the
     first and is merely blurry, rather than dropping the property and losing the rail entirely. */
  right:calc(var(--pad) * .48);
  right:round(calc(var(--pad) * .48), 1px);
}
body.is-revealed .rail{opacity:1}
/* The diamonds are 5px wide and sit flush to the rail's right edge, so their centers are
   2.5px in. A 1px line is centered there at right:2px -- it was at 3.5px, i.e. 1.5px off. */
.rail__line{position:absolute;right:2px;top:6px;bottom:6px;width:1px;background:var(--line)}
.rail ul{display:flex;flex-direction:column;gap:clamp(12px,2.2vh,22px);align-items:flex-end}
.rail li{
  /* No reserved width: every row is as narrow as its number and diamond, so the column of numbers
     sits right against the line. The label's own width is what opens, on the active row only. */
  justify-content:flex-end;
  display:flex;align-items:center;gap:0;
  font-family:var(--f-mono);font-size:12.5px;letter-spacing:.24em;text-transform:uppercase;
  color:var(--sand-dim);opacity:.5;
  transition:opacity .7s var(--ease),color .7s var(--ease);
}
/* The row opens by animating the label out from nothing, clipped, rather than by fading a label that
   was holding its space all along.
   max-width and not width, so each label opens to its OWN width: they run from 32px ("Log") to 85px
   ("Approach"), and a single fixed width either clips the long one or pads the short one. The ceiling
   is just clear of the longest, so the shorter labels simply finish opening a little sooner.
   (The 61px this used to be was under the true 85px and only looked right because nothing was
   clipping it -- the text quietly overflowed its box.) */
.rail li em{
  font-style:normal;display:inline-block;max-width:0;text-align:left;
  overflow:hidden;white-space:nowrap;opacity:0;
  transition:max-width .7s var(--ease),opacity .5s var(--ease),margin .7s var(--ease);
}
.rail li b{font-weight:400;margin-right:10px}
.rail li::after{
  content:"";width:5px;height:5px;background:currentColor;transform:rotate(45deg);
  flex:none;
  transition:transform .7s var(--ease),background .7s var(--ease);
}
.rail li.is-active{opacity:1;color:var(--sand-hi)}
/* The ceiling only has to exceed the longest label; max-width is the vehicle the reveal animates
   along, from 0. At 96px it sat 11px above the longest beat name, and the element clips, so
   renaming a beat would have truncated it silently. In ch it scales with the type instead of
   being a pixel measured once against the words that happened to be there. */
.rail li.is-active em{max-width:14ch;opacity:1;margin-right:10px}
.rail li.is-active::after{transform:rotate(45deg) scale(1.7)}

/* ════════════════════  SECTION SHELL  ════════════════════ */
main{
  position:relative;z-index:10;
  /* keeps prose legible where the planet limb brightens behind it */
  text-shadow:0 1px 20px rgba(2,4,8,.6);
}

.beat{
  position:relative;
  padding-block:clamp(120px,17vh,210px);
}
.beat--hero{
  min-height:100svh;
  display:flex;align-items:flex-end;
  padding-block:118px clamp(64px,10vh,110px);
}

[data-rise]{
  opacity:0;
  transform:translate3d(0,30px,0);
  transition:
    opacity 1.15s var(--ease) calc(var(--i,0) * 95ms),
    transform 1.35s var(--ease) calc(var(--i,0) * 95ms);
}
[data-rise].in{opacity:1;transform:none}

/* ── section heads ── */
.shead{max-width:58ch;margin-bottom:clamp(48px,7vh,96px)}
.shead--tight{margin-bottom:clamp(28px,4vh,48px)}
.shead__eyebrow{display:flex;align-items:center}
.shead__eyebrow b{margin-right:.2em}
.shead__title{
  font-family:var(--f-display);
  font-size:clamp(2.6rem,6.4vw,5.4rem);
  line-height:.92;letter-spacing:-.022em;
  text-transform:uppercase;
  margin:clamp(14px,2vh,26px) 0 0;
  color:#efeae4;
}
.shead__title--sm{font-size:clamp(2rem,4.2vw,3.4rem)}
.shead__lede{
  margin-top:clamp(18px,2.6vh,32px);
  font-size:clamp(1.02rem,1.28vw,1.22rem);
  line-height:1.66;color:#c3bdb6;max-width:52ch;
}

/* ════════════════════  01 · HERO  ════════════════════ */
.hero{width:100%}
.hero__kicker{display:flex;align-items:center;margin-bottom:clamp(28px,6vh,64px)}
.hero__name{
  font-family:var(--f-display);
  font-size:clamp(3.6rem,14.6vw,13.6rem);
  line-height:.83;letter-spacing:-.035em;
  text-transform:uppercase;
  color:#f4efe9;
}
.hero__name .line{display:block;overflow:hidden;padding-bottom:.04em}
.hero__name .line>span{
  display:block;
  transform:translate3d(0,110%,0);
  transition:transform 1.5s var(--ease-io);
}
.hero__name .line:nth-child(2)>span{transition-delay:.12s}
body.is-revealed .hero__name .line>span{transform:none}

.hero__formal{margin-top:clamp(16px,2.4vh,26px);color:var(--sand)}
.hero__lede{
  margin-top:clamp(22px,3.6vh,42px);
  max-width:46ch;
  font-size:clamp(1.04rem,1.34vw,1.28rem);
  line-height:1.62;color:#cac4bc;
}
.hero__data{
  margin-top:clamp(34px,6vh,70px);
  display:flex;flex-wrap:wrap;gap:clamp(20px,4vw,64px);
  border-top:1px solid var(--line);padding-top:20px;
}
.hero__data dt{margin-bottom:6px}
.hero__data dd{font-size:1.02rem;color:#c8c2ba;max-width:24ch}
.hero__scroll{margin-top:clamp(26px,4vh,44px);display:flex;align-items:center;gap:.9em;color:var(--sand-dim)}
.arrow{
  display:inline-block;width:1px;height:26px;background:linear-gradient(to bottom,var(--sand),transparent);
  animation:drop 3.4s var(--ease) infinite;
}
@keyframes drop{
  0%,100%{transform:translateY(-4px) scaleY(.7);opacity:.4}
  45%{transform:translateY(6px) scaleY(1);opacity:1}
}

/* ════════════════════  02 · PILLARS  ════════════════════ */
.pillars{
  display:grid;grid-template-columns:repeat(3,1fr);
  gap:clamp(20px,3vw,54px);
}
.pillars li{border-top:1px solid var(--line);padding-top:20px;position:relative}
.pillars li::before{
  content:"";position:absolute;top:-3.5px;left:0;width:6px;height:6px;
  background:var(--sand);transform:rotate(45deg);
}
.pillars__idx{display:block;color:var(--sand-dim);margin-bottom:14px}
.pillars h3{
  font-family:var(--f-display);text-transform:uppercase;
  font-size:clamp(1.05rem,1.5vw,1.35rem);letter-spacing:.01em;color:#eae5df;margin-bottom:12px;
}
.pillars p{font-size:1.04rem;color:#a9a39b;line-height:1.62}

/* Shares .prior's treatment -- a labeled aside rather than a section of its own -- and only adds
   the rhythm .prior does not need, since this one carries two entries instead of one. */
.labs .prior__body h3 + p{margin-bottom:24px}
.labs .prior__body h3 + p:last-child{margin-bottom:0}

.prior{
  margin-top:clamp(52px,8vh,104px);
  display:grid;grid-template-columns:minmax(120px,1fr) minmax(0,3fr);
  gap:clamp(16px,3vw,48px);
  border-top:1px solid var(--line-soft);padding-top:26px;
}
.prior__label{color:var(--sand-dim)}
.prior__body h3{font-family:var(--f-display);font-size:clamp(1.4rem,2.4vw,2rem);text-transform:uppercase;letter-spacing:-.01em;color:#eae5df}
.prior__body .mono{margin:10px 0 14px}
.prior__body p{max-width:60ch;color:#a9a39b;font-size:1.05rem}

/* ════════════════════  03 · TRACK  ════════════════════ */
.beat--log{padding-block:clamp(90px,13vh,170px)}
.track{
  overflow:hidden;margin-top:clamp(24px,4vh,48px);padding-block:clamp(28px,5vh,60px);
  cursor:grab;touch-action:pan-y;user-select:none;-webkit-user-select:none;
}
.track.is-dragging{cursor:grabbing}
.track__inner{
  display:flex;align-items:flex-start;
  gap:clamp(16px,2.6vw,40px);
  width:max-content;padding-inline:var(--pad);
  will-change:transform;
}
.shot{margin:0;position:relative;width:clamp(180px,23vw,320px)}
.shot--a{padding-top:0}
.shot--b{padding-top:clamp(24px,7vh,72px)}
.shot--c{padding-top:clamp(10px,3vh,34px)}
.shot img{
  display:block;width:100%;height:auto;aspect-ratio:3/4;object-fit:cover;
  background:#0b0f15;
  border:1px solid rgba(219,214,208,.14);
  filter:grayscale(.72) sepia(.22) saturate(1.15) contrast(1.08) brightness(.78);
  transition:filter 1.1s var(--ease),transform 1.1s var(--ease);
}
.shot--b img{aspect-ratio:1/1}
.shot--c img{aspect-ratio:4/5}
.shot:hover img{filter:grayscale(.12) sepia(.04) saturate(1.02) contrast(1.02) brightness(.94)}
.shot::before{
  content:"";position:absolute;left:0;right:0;top:0;
  aspect-ratio:3/4;
  background:linear-gradient(200deg, rgba(120,150,190,.20), transparent 42%, rgba(143,126,119,.30));
  mix-blend-mode:soft-light;pointer-events:none;z-index:1;
}
.shot--b::before{aspect-ratio:1/1}
.shot--c::before{aspect-ratio:4/5}
.shot figcaption{
  margin-top:12px;display:flex;align-items:center;gap:.4em;
  color:var(--sand-dim);
}
.shot figcaption b{color:var(--sand)}

/* ════════════════════  04 · PROJECT CARDS  ════════════════════ */
.cards{display:grid;grid-template-columns:repeat(12,1fr);gap:clamp(14px,1.9vw,26px)}
.card{
  position:relative;
  padding:clamp(20px,2.4vw,34px);
  background:linear-gradient(165deg, rgba(232,224,214,.045), rgba(232,224,214,.008) 62%);
  border-top:1px solid rgba(219,214,208,.20);
  transition:transform .8s var(--ease),background .8s var(--ease),border-color .8s var(--ease);
}
.card::before{
  content:"";position:absolute;top:-3.5px;right:clamp(20px,2.4vw,34px);
  width:6px;height:6px;background:var(--sand);transform:rotate(45deg);
  transition:background .8s var(--ease);
}
.card:hover{transform:translateY(-4px);background:linear-gradient(165deg, rgba(232,224,214,.075), rgba(232,224,214,.012) 62%);border-color:var(--sand-hi)}
.card:hover::before{background:var(--ember)}
.card__idx{color:var(--sand-dim);margin-bottom:clamp(20px,4vh,48px)}
.card h3{
  font-family:var(--f-display);text-transform:uppercase;
  font-size:clamp(1.5rem,2.5vw,2.15rem);line-height:1;letter-spacing:-.012em;color:#f0ebe5;
}
.card__body{margin-top:14px;color:#a9a39b;font-size:1.05rem;max-width:46ch}
.tags{display:flex;flex-wrap:wrap;gap:.5em .55em;margin-top:clamp(18px,2.6vh,28px)}
.tags li{
  border:1px solid var(--line);padding:5px 9px 4px;color:var(--sand);
  font-size:12px;letter-spacing:.2em;
}
.card--wide{grid-column:1 / 7;grid-row:1 / 3;display:flex;flex-direction:column}
.card--wide .card__body{margin-top:auto;padding-top:18px}
.cards>.card:nth-child(2){grid-column:7 / 13;grid-row:1}
.cards>.card:nth-child(3){grid-column:7 / 13;grid-row:2}
.cards>.card:nth-child(4){grid-column:7 / 13;grid-row:3}
.cards .note{
  grid-column:1 / 7;grid-row:3;
  display:flex;align-items:flex-end;
  padding:clamp(20px,2.4vw,34px) 0 0;
}
.cards .note a{
  text-decoration:none;color:var(--sand);
  font-family:var(--f-mono);font-size:12.5px;letter-spacing:.26em;text-transform:uppercase;
  border-bottom:1px solid var(--line);padding-bottom:6px;
  transition:color .5s var(--ease),border-color .5s var(--ease);
}
.cards .note a:hover{color:var(--sand-hi);border-color:var(--sand-hi)}

/* An outbound link on its own line, in the destination views. Same treatment as the cards' note,
   without the grid placement that one needs. */
/* The smaller projects: too many for cards, too real to leave off. Two columns of short entries. */
.also-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:clamp(18px,2.4vw,36px)}
.also{border-top:1px solid var(--line);padding-top:16px}
.also h5{margin:0;font-family:var(--disp);font-size:1.06rem;font-weight:500;color:#e4dfd8;
  text-transform:none;letter-spacing:0}
.also h5 a{color:inherit;text-decoration:none;border-bottom:1px solid var(--line);
  transition:border-color .5s var(--ease),color .5s var(--ease)}
.also h5 a:hover{color:var(--sand-hi);border-color:var(--vc)}
.also-meta{margin:6px 0 8px;font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);
  text-transform:uppercase;color:var(--dimmer)}
.also p:last-child{margin:0;font-size:.97rem;color:#9d978f;line-height:1.62;
  text-align:justify;hyphens:auto;-webkit-hyphens:auto}
@media (max-width:700px){.also-grid{grid-template-columns:1fr}}

/* Honors and awards, nested under the university that gave them. A date column, the name, and a
   line of what it was for -- the dates came from LinkedIn and are the one thing the CV never had. */
.honors-list{list-style:none;margin:0;padding:0;display:grid;gap:14px}
/* Which school gave it. All six are Northeastern today, but a fellowship at Harvard would land in
   the same list and the reader should not have to guess. */
.hs{font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);text-transform:uppercase;
  color:var(--dimmer);white-space:nowrap;margin-left:8px}
/* max-content, not a fixed 78px. The first column holds a mono date, and 78px was measured
   against "Sep 2023"; a longer one such as a range would have overflowed it. Sized to its own
   content the column cannot clip, and being a grid column every row still shares one width. */
.honors-list li{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 16px}
.hw{font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);text-transform:uppercase;
  color:var(--dimmer);padding-top:3px}
.hn{color:#e4dfd8;font-size:1rem;line-height:1.5}
.hd{grid-column:2;font-size:.94rem;color:#8f8a83;line-height:1.55}
.hd:empty{display:none}
@media (max-width:700px){.honors-list li{grid-template-columns:1fr}.hd{grid-column:1}}

/* The school's own seal beside its name in the education entries. Both are single-color marks on
   transparency, so they sit on the dark field without treatment -- only dimmed slightly, and brought
   up on hover, so they read as part of the page rather than pasted onto it. Matched on HEIGHT, since
   Harvard's shield is taller than wide and Northeastern's seal is a circle; matching width would make
   the shield look half the size. */
.edu-name{display:flex;align-items:center;gap:15px;min-width:0}
.crest{
  height:clamp(40px,3.6vw,54px);width:auto;flex:none;
  opacity:.86;transition:opacity .6s var(--ease);
}
.entry:hover .crest{opacity:1}

/* Coursework and activities under an education entry: a label column and a run-on list, because
   these are long and reading them as bullets would be worse than reading them as a sentence. */
.crs{margin:14px 0 0;display:grid;gap:10px}
.crs div{display:grid;grid-template-columns:minmax(84px,1fr) minmax(0,5fr);gap:14px}
.crs dt{font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);text-transform:uppercase;color:var(--dimmer)}
.crs dd{margin:0;font-size:.98rem;color:#9d978f;line-height:1.65}
@media (max-width:700px){.crs div{grid-template-columns:1fr;gap:4px}}

/* Author list under a publication, with his own name marked so the position in the order is
   visible at a glance -- which is the thing a reader of a 27-author paper actually wants to know.
   Outside the label scale on purpose: this is a mono-set line of running text, not a kicker. It is
   sentence case and it wraps, and .04em is the tracking that keeps a long list readable rather than
   the tracking that makes three words read as a label. */
.authors{margin:8px 0 0;font-family:var(--mono);font-size:12px;letter-spacing:.04em;color:var(--dimmer);line-height:1.6}
.authors b{color:var(--sand-hi);font-weight:500}
/* A venue chip can be the link to that venue -- the conference DOI or the arXiv record -- so a reader
   can go to the specific one rather than guessing which of them the title points at. */
a.chip{text-decoration:none;transition:border-color .5s var(--ease),color .5s var(--ease)}
a.chip:hover{color:var(--sand-hi);border-color:var(--vc)}

/* The title is the link where there is one to give. Shared with the entry headings, so a lab that
   has a page reads the same way a paper that has a DOI does. */
.pub-a,.entry h4 a{color:inherit;text-decoration:none;border-bottom:1px solid transparent;transition:border-color .5s var(--ease)}
.pub-a:hover,.entry h4 a:hover{border-color:var(--vc)}

/* ════════════════════  05 · PUBLICATIONS  ════════════════════ */
.pubs{border-top:1px solid var(--line)}
.pubs li{
  display:grid;grid-template-columns:clamp(52px,7vw,96px) minmax(0,1fr);
  gap:clamp(10px,2vw,28px);
  padding:clamp(24px,3.4vh,40px) 0;
  border-bottom:1px solid var(--line-soft);
}
.pubs__idx{color:var(--sand-dim);padding-top:.55em}
.pubs h3{
  font-family:var(--f-display);
  font-size:clamp(1.25rem,2.15vw,1.85rem);line-height:1.1;letter-spacing:-.008em;color:#efeae4;
  max-width:34ch;
}
.pubs__venue{margin-top:12px;color:var(--sand)}
.pubs__note{margin-top:12px;color:#9d9790;font-size:1.03rem;max-width:52ch}

/* ════════════════════  06 · TIMELINE  ════════════════════ */
.tl{border-top:1px solid var(--line)}
.tl li{
  display:grid;grid-template-columns:clamp(110px,13vw,190px) minmax(0,1fr);
  gap:clamp(10px,2vw,28px);
  padding:clamp(24px,3.4vh,40px) 0;
  border-bottom:1px solid var(--line-soft);
  position:relative;
}
.tl__when{color:var(--sand);padding-top:.4em}
.tl__what h3{
  font-family:var(--f-display);text-transform:uppercase;
  font-size:clamp(1.2rem,2vw,1.7rem);line-height:1.06;letter-spacing:-.006em;color:#efeae4;
}
.tl__role{
  display:block;font-family:var(--f-body);text-transform:none;
  font-size:1rem;letter-spacing:.01em;color:var(--sand-hi);margin-top:8px;
}
.tl__where{margin:10px 0 12px;color:var(--sand-dim)}
.tl__what p:last-child{color:#a09a92;font-size:1.05rem;max-width:58ch}

/* ════════════════════  07 · CONTACT  ════════════════════ */
.beat--contact{
  padding-block:clamp(140px,20vh,240px) clamp(60px,9vh,110px);
  min-height:88svh;display:flex;align-items:flex-end;
}
.contact__eyebrow{display:flex;align-items:center;color:var(--sand)}
.contact__title{
  font-family:var(--f-display);text-transform:uppercase;
  font-size:clamp(3rem,10vw,9rem);line-height:.86;letter-spacing:-.035em;
  margin:clamp(16px,2.6vh,32px) 0 clamp(26px,4vh,50px);
  color:#f6f1ea;
}
.contact__mail{
  display:inline-block;text-decoration:none;
  font-family:var(--f-display);
  font-size:clamp(1.4rem,3.2vw,2.7rem);letter-spacing:-.01em;color:var(--sand-hi);
  border-bottom:1px solid rgba(200,183,171,.35);padding-bottom:6px;
  transition:color .6s var(--ease),border-color .6s var(--ease);
}
.contact__mail:hover{color:#fff;border-color:#fff}
.contact__links{display:flex;flex-wrap:wrap;gap:clamp(16px,3vw,44px);margin-top:clamp(28px,4vh,48px)}
.contact__links a{text-decoration:none;color:var(--sand);border-bottom:1px solid transparent;padding-bottom:4px;transition:color .5s var(--ease),border-color .5s var(--ease)}
.contact__links a:hover{color:var(--sand-hi);border-color:var(--sand-hi)}
.contact__off{margin-top:clamp(30px,5vh,56px);max-width:44ch;color:#a49e96;font-size:1.06rem}
.contact__colophon{
  margin-top:clamp(40px,7vh,86px);padding-top:18px;
  border-top:1px solid var(--line-soft);color:var(--sand-dim);
  display:flex;flex-wrap:wrap;
}

/* focus */
a:focus-visible,[tabindex]:focus-visible{outline:1px solid var(--sand-hi);outline-offset:5px}

/* ════════════════════  RESPONSIVE  ════════════════════ */
@media (max-width:1100px){
  .rail{display:none}
  .pillars{grid-template-columns:1fr 1fr}
  .cards>.card,.cards>.card--wide,.cards .note{grid-column:1 / 13 !important;grid-row:auto !important}
  .card--wide .card__body{margin-top:14px}
}
/* The altimeter readout is the first thing to go when the bar gets tight: it is decoration, and the
   six destinations beside it are not. */
@media (max-width:1200px){ .hud__alt{display:none} }
@media (max-width:860px){
  .hud{height:58px;gap:10px}
  .hud__mark span{display:none}
  .prior{grid-template-columns:1fr}
  .pubs li,.tl li{grid-template-columns:1fr;gap:12px}
  .pubs__idx,.tl__when{padding-top:0}
}
@media (max-width:700px){
  :root{--pad:20px}
  .pillars{grid-template-columns:1fr;gap:28px}
  .hero__data{gap:20px 28px}
  .hero__data dd{max-width:none}
  .beat--hero{padding-top:96px}
  .shot{width:min(66vw,260px)}
  .gridlines{background-image:repeating-linear-gradient(to right, rgba(219,214,208,.04) 0 1px, transparent 1px 25%),repeating-linear-gradient(to bottom, rgba(219,214,208,.025) 0 1px, transparent 1px 33%)}
}

/* ════════════════════  DIRECTOR-DERIVED TOKENS  ════════════════════
   Only the variables descent does not define, so descent's palette stays authoritative and the
   ported views read as part of THIS page rather than a second design bolted on. */

/* ==========================================================================
   CONTENT VIEWS
   ========================================================================== */
.view{
  position:fixed;inset:0;z-index:40;overflow-y:auto;overflow-x:hidden;
  padding:calc(var(--top) + 42px) 0 calc(var(--bot) + 60px);
  scrollbar-width:none;-ms-overflow-style:none;
}
/* The destinations scroll inside themselves, so they had their own thin scrollbar. Hidden for the
   same reason as the document's -- one of them showing a bar while the landing shows none reads as a
   glitch rather than a choice. */
.view::-webkit-scrollbar{width:0;height:0}
.view[hidden]{display:none}
.view:focus{outline:none}
.view::before{
  content:"";position:fixed;inset:0;z-index:-1;
  background:
    radial-gradient(58% 46% at 14% 4%, color-mix(in srgb, var(--vc) 16%, transparent), transparent 66%),
    linear-gradient(180deg, rgba(4,6,10,.93) 0%, rgba(4,6,10,.88) 40%, rgba(4,6,10,.94) 100%);
  /* No backdrop-filter here. There is nothing behind this layer to frost: .view paints an opaque
     gradient of its own, so the landing is fully covered, and a negative z-index puts this
     pseudo-element on top of that gradient rather than under it. What the filter blurred was
     therefore a smooth gradient, which a blur returns unchanged, and its own background then
     covered 88 to 94% of the result anyway.

     Measured, it cost half the frame rate of every destination while scrolling -- 21 fps against
     49 on About, 20 against 47 on Experience, 24 against 59 on Contact -- for a maximum difference
     of one colour level, which is rounding. The gradients below are what produce the look. */
}
/* Published as a custom property so descendants nested inside the grid can work out how far they
   sit from the viewport edge and break out to full bleed. */
.view-inner{--vi:min(1180px,90vw);--rail:26px;width:var(--vi);margin:0 auto;position:relative}
/* accent rail */
.view-inner::before{
  content:"";position:absolute;left:calc(var(--rail) * -1);top:6px;bottom:0;width:1px;
  background:linear-gradient(180deg,var(--vc),rgba(160,180,205,.1) 30%,transparent);
}

.view-head{margin-bottom:54px;display:grid;grid-template-columns:auto 1fr;gap:0 26px;align-items:start}
.vh-idx{
  font-family:var(--mono);font-size:var(--lbl-l);letter-spacing:var(--lbl-l-ls);color:var(--vc);
  padding-top:14px;
}
.view-head h2{
  font-family:var(--disp);font-weight:600;text-transform:uppercase;
  font-size:clamp(38px,6.4vw,88px);line-height:.86;letter-spacing:-.032em;color:var(--text-hi);
}
/* Full width of the head, not column 2. Sitting in the title's column indented it past the index
   number, so the opening paragraph was the one piece of prose on the page that did not share the
   left edge everything else is aligned to. */
/* 66ch, which is a measure and not the column.
   Uncapped this runs the full 1180 and sets a 93 character line, then drops four words onto a
   154px stub underneath: the widest line of prose on the site introducing a page whose own body
   text is held to 74ch. An introduction should not be set wider than what it introduces.

   66 gives two lines of 790 and 542, and about 35 characters of slack before a third appears.
   That slack is the point. An earlier cap of 62 filled its first line to 99% and left the
   paragraph balanced on the boundary between two lines and three, where the way the font
   happened to rasterise decided which. Tighter caps read better still, and 58 sets two almost
   equal lines, but it leaves ten characters of room and the next edit to this sentence would
   spill it. */
.vh-kicker{
  grid-column:1/-1;margin-top:22px;max-width:var(--measure);font-size:var(--measure-fs);
  line-height:1.72;color:var(--dim);
  /* Justified with hyphenation, as the other prose blocks on the site are. The hyphens are not
     optional: justifying a two-line paragraph without them stretches the word spaces of the first
     line to cover whatever the second does not need, which is where rivers come from. Both
     spellings, because Safari still wants the prefix. */
  text-align:justify;hyphens:auto;-webkit-hyphens:auto;
}
.vh-kicker b{color:var(--text);font-weight:400}

.sec-label{
  display:flex;align-items:center;gap:14px;margin:0 0 30px;
  /* These name the sections of a destination -- "Engineering", "Publications", "Industry" -- and at
     13px with .34em of tracking they read as a caption rather than a heading. Sized as a heading now,
     with the tracking pulled in, since letter-spacing that wide stops helping once the type is big. */
  font-family:var(--hud);font-weight:400;text-transform:uppercase;
  letter-spacing:.2em;font-size:clamp(19px,1.6vw,24px);color:var(--sand);
}
.sec-label .dia{background:var(--vc);width:6px;height:6px;flex:none}
.sec-label::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line),transparent)}
.blk{margin-top:64px}

/* --- reveal --- */
.rv{opacity:0;transform:translateY(26px)}
.view.enter .rv{animation:rise .95s var(--ease-out) forwards;animation-delay:calc(var(--i,0) * 90ms + 120ms)}
@keyframes rise{to{opacity:1;transform:none}}

/* The panel arrives by sliding, not by fading. Fading its opacity from zero made the landing page
   visible straight through it for the whole 420ms, which is the flash you see on opening a tab: the
   backdrop is the only thing hiding the landing, so it cannot be transparent even briefly. */
/* On .view-inner, not on .view. A finished animation with fill-mode:both retains its end state,
   and `transform: none` in a keyframe computes to the identity matrix rather than to none -- which
   still makes the element a containing block for fixed-position descendants. That put .view::before,
   the scrim over each destination, inside the scroll container instead of over the viewport: it
   covered the first screenful of CONTENT and then scrolled away, leaving a hard horizontal edge one
   viewport height down where the dimming stopped. Animating the inner wrapper keeps the rise and
   leaves .view untransformed.

   backwards, not both, for the same reason read the other way. The retained identity matrix also
   promotes the wrapper to a compositor layer and keeps it there for as long as the tab is open, which
   on About is a 1180x3576 layer held for a transform that is doing nothing. backwards still holds the
   from-state before the animation runs, so the rise is unchanged; it just stops holding the end state,
   which is identical to the element's own style anyway. */
.view.enter .view-inner{animation:view-in .5s var(--ease-out) backwards}
@keyframes view-in{from{transform:translateY(14px)}to{transform:none}}

/* Items cascade WITHIN their block, not just block by block.
   Before this only the .rv wrappers animated, so a block of six project cards or ten publications
   arrived as a single lump -- four or five lumps for a whole page, which reads as things appearing
   rather than a page assembling. --i is inherited from the block's inline style and --j is the item's
   own position, so each item lands after both its block and its neighbors above it. */
.view.enter .timeline > *,.view.enter .pubs > *,.view.enter .tiles > *,.view.enter .also-grid > *,.view.enter .links > *,.view.enter .honors-list > *,.view.enter .crs > *{
  opacity:0;transform:translateY(16px);
  animation:rise .8s var(--ease-out) forwards;
  animation-delay:calc(var(--i,0) * 90ms + var(--j,0) * 52ms + 210ms);
}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(1){--j:0}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(2){--j:1}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(3){--j:2}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(4){--j:3}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(5){--j:4}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(6){--j:5}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(7){--j:6}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(8){--j:7}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(9){--j:8}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(10){--j:9}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(11){--j:10}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(12){--j:11}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(13){--j:12}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(14){--j:13}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(15){--j:14}
.view.enter :is(.timeline,.pubs,.tiles,.also-grid,.links,.honors-list,.crs) > *:nth-child(16){--j:15}

/* --- ABOUT --- */
/* Prose first, portrait in the right-hand column, so every block below the intro shares one
   left edge instead of being indented past the photo. */
/* Only the opening prose shares this row with the portrait. Everything below it, the photographs and
   the education and honours blocks, is a sibling, because holding them in the left column made the
   row as tall as the whole page and pinned the portrait to the top of it with three thousand pixels
   of empty gutter beside it. With the row the height of the prose the two centre on each other. */
.identity{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,clamp(330px,29.5vw,420px));
  gap:clamp(32px,3.5vw,50px);align-items:center}
/* Runs the full width of the view, deliberately, and is not held to the prose column.
   Only the opening prose shares a row with the portrait; everything below is a sibling of that row,
   so these blocks are outside the two-column arithmetic and free to use the whole measure. That is
   what they want: the education and experience blocks are entry rows with a date on the right, not
   prose, and holding them to the prose column squeezed them from 1146px to 710px. */
.about-body > .blk:first-child{margin-top:clamp(40px,5vh,64px)}
.portrait{position:relative}
.portrait-frame{
  position:relative;overflow:hidden;aspect-ratio:4/5;
  clip-path:polygon(0 0,100% 0,100% calc(100% - 34px),calc(100% - 34px) 100%,0 100%);
  background:#0a0d13;border:1px solid var(--line-2);
}
.portrait-frame img{width:100%;height:100%;object-fit:cover;transition:transform 1.4s var(--ease-cine)}
.portrait-frame:hover img{transform:scale(1.03)}
.portrait-frame::after{
  content:"";position:absolute;inset:0;pointer-events:none;
  background:linear-gradient(200deg,transparent 40%,rgba(143,126,119,.22) 100%),
             radial-gradient(120% 90% at 50% 0%,transparent 55%,rgba(4,6,10,.6) 100%);
}
/* One face, one size, one colour, for all four. The block used to run a display face at 19px in sand
   for the second paragraph and drop the rest to a dimmer colour, which read as three different kinds
   of text stacked rather than one introduction.
   Hyphenation is on because justified text without it opens rivers between words at this measure. */
/* The column, not this max-width, is what sets the measure: 60ch computed to 685px against a 684px
   column. It is raised so it stays out of the way, and the size goes up with the width so the line
   does not get any longer in characters than it already is. */
.bio p{
  font-size:17.5px;line-height:1.72;color:var(--text);max-width:76ch;
  /* Both spellings. This was the one justified block in the file without the prefixed property,
     so on Safari it justified with no hyphenation at all -- which produces exactly the rivers of
     white space the justification is here to avoid. */
  text-align:justify;text-wrap:pretty;hyphens:auto;-webkit-hyphens:auto;
}
.bio p + p{margin-top:18px}
/* A tint at the same weight, not a bold. The site does this everywhere else (.mono b, the HUD), and a
   heavier weight inside justified text disturbs the word spacing it is already stretching. */
.bio p b{color:var(--sand-hi);font-weight:400}
/* Drag-scrubbed photo track. Frames are a uniform portrait so the strip reads as one object, and
   each photo's crop travels the frame's full width against the drag, which is where the depth
   comes from.

   The strip lives in the prose column but spans the view's whole width: it clips on the left at the
   accent rail, so photos vanish as they cross it, and on the right at the portrait's outer edge, so
   none ever appears beyond the page's own right margin. Photo 01 still starts on the prose's left
   edge, which is why the inner is padded back in by the rail offset. */
.pslide{
  /* The band stops 3px short of the accent rail. Reaching it meant the rail's own 1px line sat at
     exactly the x where the mask is fully transparent, so it showed through at the point the
     photographs vanish: measured over 130 rows, x=0 came out 16 levels brighter than it should be and
     brighter than x=1, which reads as a bright thread at the head of the fade followed by a dip. The
     rail is a pseudo-element on an ancestor and is not masked, so the only fix is to not overlap it. */
  --gutter:calc(var(--rail) - 3px);
  width:calc(var(--vi) + var(--gutter));margin:20px 0 0 calc(var(--gutter) * -1);
  padding:12px 0 40px;overflow:hidden;
  cursor:grab;touch-action:pan-y;user-select:none;-webkit-user-select:none;
  /* A photograph reaching either boundary used to end on a hard vertical cut. It dissolves across
     the margin instead, and the margin is exactly the gutter: --rail is the distance from the accent
     rail to the column's left edge, which measures 26px.
     Both sides use it, so they are symmetrical.

     The stops trace a smootherstep, 6t^5 - 15t^4 + 10t^3, for a perceptual reason rather than a
     numerical one. A straight ramp reaches opaque with its slope still at full value and then flattens
     within one pixel. Nothing actually overshoots: column means over 130 rows confirm the luminance
     never exceeds the photograph's own. But that corner in the profile gets read as a bright thread
     sitting just inside the fade, because the slope went +3.79 per pixel to -0.60 across a single
     boundary. It is Mach banding, and it shows at the inner end of both sides.

     Smootherstep has zero first and second derivative at both ends, so it arrives at opaque with no
     corner to enhance and still leaves gradually at the rail. Corner measured at the knee: 4.12
     straight, 1.36 smoothstepped, 0.64 here. An ease-out reaches 0.56 but gives up the gradual leave,
     which is the half worth keeping.

     The mask is static, so it costs one render surface and nothing per frame. */
  --fade:var(--gutter);
  --mask-fade:linear-gradient(90deg,
     rgba(0,0,0,0)     0,
     rgba(0,0,0,.0161) calc(var(--fade) * .125),
     rgba(0,0,0,.1035) calc(var(--fade) * .25),
     rgba(0,0,0,.2752) calc(var(--fade) * .375),
     rgba(0,0,0,.5)    calc(var(--fade) * .5),
     rgba(0,0,0,.7248) calc(var(--fade) * .625),
     rgba(0,0,0,.8965) calc(var(--fade) * .75),
     rgba(0,0,0,.9840) calc(var(--fade) * .875),
     #000              var(--fade),
     #000              calc(100% - var(--fade)),
     rgba(0,0,0,.9840) calc(100% - var(--fade) * .875),
     rgba(0,0,0,.8965) calc(100% - var(--fade) * .75),
     rgba(0,0,0,.7248) calc(100% - var(--fade) * .625),
     rgba(0,0,0,.5)    calc(100% - var(--fade) * .5),
     rgba(0,0,0,.2752) calc(100% - var(--fade) * .375),
     rgba(0,0,0,.1035) calc(100% - var(--fade) * .25),
     rgba(0,0,0,.0161) calc(100% - var(--fade) * .125),
     rgba(0,0,0,0)     100%);
  -webkit-mask-image:var(--mask-fade);
          mask-image:var(--mask-fade);
}
.pslide.is-dragging{cursor:grabbing}
.pslide__inner{display:flex;gap:clamp(14px,2vw,32px);width:max-content;
  padding-left:var(--gutter);will-change:transform}
/* Every frame shares one height and takes whatever width its orientation asks for, so the landscape
   shots are not squeezed into a portrait frame.

   .pf is the frame and clips; the image inside it is --over wider than the frame and is translated
   across that surplus. The surplus is a fixed fraction of the frame, so every photograph gets the
   same amount of travel whatever its own proportions are, which is not true of a crop. It also means
   the parallax is a transform, which the compositor can do on its own, rather than object-position,
   which repaints the image every frame. */
.pslide{--ph:clamp(274px,36vmin,420px);--over:1.36}
.pslide figure{margin:0;position:relative}
.pslide .pf{
  display:block;overflow:hidden;height:var(--ph);aspect-ratio:5/7;
  background:#0b0f15;border:1px solid rgba(219,214,208,.14);
}
.pslide figure.wide .pf{aspect-ratio:7/6}
.pslide img{
  display:block;height:100%;width:calc(100% * var(--over));
  object-fit:cover;will-change:transform;
}
.pslide figcaption{
  margin-top:11px;font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);
  text-transform:uppercase;color:var(--dimmer);
}
.pslide figcaption b{color:var(--sand);font-weight:400;margin-right:9px}
.interests{display:flex;flex-wrap:wrap;gap:0 20px;align-items:center;margin-top:26px}
.interests span{display:flex;align-items:center;gap:12px;font-family:var(--hud);text-transform:uppercase;letter-spacing:.22em;font-size:14px;color:var(--dim)}
.interests .dia{width:3px;height:3px;background:var(--sand)}

/* --- RESEARCH --- */
.pub{
  /* Two columns, not three: the status column on the right used to read "Published" / "Preprint" /
     "Under review", which said nothing a venue chip does not already imply. Where a paper is not out
     yet, the chip itself says so. */
  display:grid;grid-template-columns:auto minmax(0,1fr);gap:0 26px;
  padding:26px 0;border-top:1px solid var(--line);position:relative;
}
.pub:last-child{border-bottom:1px solid var(--line)}
.pub::after{
  content:"";position:absolute;left:0;top:0;height:1px;width:0;background:var(--vc);
  transition:width .9s var(--ease-out);
}
.pub:hover::after{width:100%}
.pub-n{font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);color:var(--dimmer);padding-top:6px}
.pub h4{
  font-family:var(--disp);font-weight:500;font-size:clamp(19px,1.9vw,26px);line-height:1.16;
  letter-spacing:-.014em;color:var(--text-hi);
}
.pub .sub{margin-top:9px;font-size:16.5px;line-height:1.66;color:var(--dim);max-width:66ch}
.pub .venue{
  margin-top:13px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;
}
.chip{
  font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);text-transform:uppercase;
  color:var(--sand);border:1px solid rgba(143,126,119,.34);padding:4px 8px;
}
.chip.dim{color:var(--dimmer);border-color:var(--line)}

/* --- EXPERIENCE --- */
.timeline{
  position:relative;padding-left:34px;
  /* the query context the entries below size themselves against */
  container-type:inline-size;container-name:entries;
}
.timeline::before{content:"";position:absolute;left:4px;top:8px;bottom:8px;width:1px;background:linear-gradient(180deg,var(--vc),var(--line) 30%,transparent)}
.entry{position:relative;padding:0 0 44px}
.entry::before{
  content:"";position:absolute;left:-34px;top:7px;width:9px;height:9px;background:var(--void);
  border:1px solid var(--vc);transform:rotate(45deg);transition:background .5s,box-shadow .5s;
}
.entry:hover::before{background:var(--vc);box-shadow:0 0 12px var(--vc)}
/* Title left, date right, and the date stays there.

   This was a wrapping flex row, which meant the date dropped below the title exactly when the
   two together no longer fitted. Since that depends on the length of each individual title,
   entries in the same list disagreed with each other: measured at 1150px, five of eight sat
   beside their title and three had fallen underneath, in no order a reader could account for.

   A two column grid decides it once. The title column may shrink to nothing and wraps onto as
   many lines as it needs; the date column is sized to its own content and cannot be pushed off
   the row. Below the container's breakpoint they stack, deliberately and for every entry at
   once, because at that width the date is wider than the space a title would have left.

   The date column is capped rather than given whatever it asks for, so that one very long date
   cannot take its full width out of the title beside it and leave the heading in three lines.
   Past the cap the date wraps inside its own column, which keeps it on the right where it
   belongs and costs the title nothing.

   48%, because a cap is only worth having where it does not catch the ordinary case. Measured,
   every date on the site wants between 20.9% and 39.9% of its row and is therefore untouched,
   except the Harvard degree at 44.5%, which is a real date and should read as one line rather
   than being folded to prove a rule. 48 clears it by about five characters. An earlier 40 cut
   exactly that entry in half, and 34 before it wrapped seven entries that had been correct. */
.entry__top{
  display:grid;grid-template-columns:minmax(0,1fr) fit-content(48%);
  column-gap:20px;align-items:baseline;
}
.entry h4{font-family:var(--disp);font-weight:500;font-size:clamp(18px,1.7vw,23px);letter-spacing:-.012em;color:var(--text-hi)}
/* Wraps rather than refusing to, so the column cap above can actually take effect, and set
   flush right so a wrapped second line stacks under the first instead of straggling. */
.entry .when{font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);text-transform:uppercase;color:var(--dimmer);text-align:right}
/* Company marks get a column of the entry, not a position inside it.

   They used to be absolutely positioned at left:74ch, with font-size restated so that ch would
   resolve to the same width as the prose it was meant to clear. That is three coupled numbers
   standing in for a layout: change the prose cap, the prose font or the entry width and the mark
   silently lands somewhere else. It is a real column now, so the browser does the arithmetic and
   align-items does the centring.

   The text is wrapped in .entry__body so the mark has exactly one sibling to be centred against. The
   first attempt skipped the wrapper and spanned the mark with grid-row:1/-1, which does not work:
   -1 counts to the end of the EXPLICIT grid, and with no grid-template-rows that is the first line,
   so the mark sat in row one near the top of the entry rather than spanning it.

   :has, so only entries that carry a mark become two columns. .entry is shared with the degrees on
   About and the laboratories on Research, and neither has a mark; giving those a second column
   would indent their prose to leave room for something that never arrives. The modifier on each
   entry now says which kind it is, so the shared part and the specific part are separable. */
.entry:has(.entry__mark){
  --mark-col:clamp(150px,17vw,250px);
  display:grid;grid-template-columns:minmax(0,1fr) var(--mark-col);
  column-gap:clamp(24px,3.4vw,60px);align-items:center;
}
/* A container query, not a media query, and the distinction is the point: what decides
   whether a mark can sit beside the prose is how wide THE LIST IS, not how wide the window
   is. The same entry sits in a 1146px column on Experience and a 676px one on About; keyed
   to the viewport it keeps trying for two columns in the narrow case until the whole window
   shrinks. Keyed to its container it is right wherever it is put, which is what makes the
   component portable.

   The container is .timeline, the parent, not .entry. A container query styles the
   DESCENDANTS of a container and can never style the element that declares it, so putting
   container-type on .entry meant the query could not match and silently never fired. */
@container entries (max-width:620px){
  .entry:has(.entry__mark){grid-template-columns:minmax(0,1fr)}
  /* Narrow enough that the date no longer leaves a usable title column, so every entry stacks
     together. Keyed to the container rather than the viewport for the same reason as the rest
     of this block: the entry is right wherever it is placed. */
  .entry__top{grid-template-columns:minmax(0,1fr);row-gap:6px}
  .entry .when{white-space:normal}
  .entry__mark{
    width:auto;max-width:190px;height:clamp(56px,9vw,84px);
    margin-top:16px;justify-self:start;
  }
}
.entry__mark{
  /* One box, and contain fits whatever is put in it: a 9.7:1 wordmark takes the width and a portrait
     crest takes the height, neither needing a rule of its own. That is what the two per-file
     overrides that used to live here were doing by hand. */
  width:100%;height:clamp(96px,11vw,168px);
  object-fit:contain;object-position:center;
}
/* One box does not serve both shapes equally. contain fits a portrait crest by its HEIGHT, so in
   a box this wide it paints about 98px across while a wordmark gets the full 245, and it reads as
   the small one even though the boxes are identical. A taller box for the tall artwork.

   Keyed to the SHAPE of the image, not to a filename, which is what separates this from the
   per-file max-widths it replaced: portrait is a property of the artwork, so the rule stays true
   if the file is swapped for another portrait crest. */
.entry__mark--portrait{height:clamp(126px,13.5vw,200px)}
/* Artwork that carries its own opaque background, rather than sitting on the page like the
   transparent wordmarks do. Every pixel of its box is painted, so at a height that suits a
   wordmark it reads as considerably the heaviest thing in the column. A slightly smaller box
   evens them out.

   Keyed to that property of the artwork, as --portrait is keyed to its shape, so it stays true
   if the file is swapped for another logo with a background of its own. */
.entry__mark--solid{height:clamp(80px,9.2vw,140px)}
/* No tone modifier here on purpose. The Harvard Edge wordmark is black beside a crimson figure,
   so on this page the words disappeared and only the figure arrived, but a CSS filter cannot
   treat the two parts differently: inverting lifts the black to white and drags the crimson to
   pink with it. The artwork is recoloured instead, as harvard-edge-ondark.png, where the
   wordmark is white and the crimson is untouched. Alpha was left alone so the letter edges stay
   soft. */
.entry .role{margin-top:5px;font-family:var(--hud);text-transform:uppercase;letter-spacing:.24em;font-size:14px;color:var(--sand)}
/* Collapsible laboratory prose. Cut to about four lines, with the last of them fading out rather
   than ending on a hard edge, so it reads as continuing rather than as finished.
   The fade is a mask whose stop is a registered custom property, which is what makes it animatable:
   mask-image itself cannot be transitioned, but a --percentage can, so opening slides the fade off
   the bottom instead of snapping. max-height carries the height, since height:auto is not
   animatable either; the open value only has to exceed the tallest entry. */
@property --lab-fade{syntax:"<percentage>";inherits:false;initial-value:52%}
.lab-prose{
  --lab-fade:52%;
  max-height:118px;overflow:hidden;
  -webkit-mask-image:linear-gradient(180deg,#000 var(--lab-fade),transparent 100%);
          mask-image:linear-gradient(180deg,#000 var(--lab-fade),transparent 100%);
  /* ease-io, not ease-cine: both ease in and out, but cine is 70% done at the halfway point,
     so its acceleration is brief and its tail long. ease-io peaks harder and later, which is
     what reads as the panel gathering speed and then settling. */
  transition:max-height .75s var(--ease-io),--lab-fade .75s var(--ease-io);
}
/* No max-height here: the script sets it to the measured content height. A fixed large value
   makes the curve finish early, because once max-height passes the real height nothing more moves
   and the whole ease-out tail plays out invisibly. Measured, 85% of the visible travel was done in
   301ms of a 750ms transition. */
.lab-prose.is-open{--lab-fade:100%}
.lab-more{
  appearance:none;background:none;border:0;padding:0;margin-top:14px;cursor:pointer;
  display:inline-flex;align-items:center;gap:9px;
  font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);text-transform:uppercase;
  color:var(--sand-dim);transition:color .4s var(--ease);
}
.lab-more:hover,.lab-more:focus-visible{color:var(--sand-hi)}
.lab-more .chev{
  width:7px;height:7px;border-right:1px solid currentColor;border-bottom:1px solid currentColor;
  transform:translateY(-2px) rotate(45deg);transition:transform .5s var(--ease-cine);
}
.lab-more[aria-expanded="true"] .chev{transform:translateY(1px) rotate(-135deg)}
.entry p{margin-top:11px;font-size:16.5px;line-height:1.7;color:var(--dim);max-width:74ch}
/* The mark has its own column now, so the cap no longer has to leave room for one. */
.entry:has(.entry__mark) p{max-width:none}
/* The laboratory and experience entries carry the longest prose on the site, so they are justified --
   with hyphenation on, because justifying without it opens rivers of whitespace between words, and
   this text is full of unbreakable terms like "microarchitectural" and "self-distillation". */
#view-research .timeline .entry p,#view-experience .timeline .entry p{text-align:justify;hyphens:auto;-webkit-hyphens:auto}

/* An inline link inside an entry's prose -- a lab director's profile, say -- marked by an underline
   in the view's accent rather than by color alone. */
.entry p a.inl{color:var(--text);text-decoration:none;border-bottom:1px solid var(--vc);
  transition:color .5s var(--ease),background .5s var(--ease)}
.entry p a.inl:hover{color:var(--sand-hi)}

/* --- CONTACT --- */
.contact-lead{
  font-family:var(--disp);font-weight:600;text-transform:uppercase;
  font-size:clamp(26px,3.6vw,48px);line-height:1.02;letter-spacing:-.028em;color:var(--text-hi);
  /* 26ch, not 24. "Expect replies promptly." is the longest line and at 24 it filled 648px of a
     672px box, 96% of the measure, so it was one rasterising decision away from breaking onto a
     second line. Nothing was constraining it from outside; it was simply cut too close. */
  max-width:26ch;margin-bottom:44px;
}
.contact-lead em{font-style:normal;color:var(--sand)}
/* The portrait takes a third column of the head rather than being positioned into the gap beside
   the text, so the space it occupies is space the other two columns were never given. The kicker
   and the lead, which otherwise run the whole width of the head, stop at that column.

   :has() rather than a contact-specific class on the head itself, so the head is a plain view
   head everywhere and only behaves differently where a portrait is actually present. */
/* The measure, declared once.
   The kicker is capped at it and the portrait is centred in what is left beside it, so the two
   have to agree; the portrait used to carry 801px written out by hand, which is that cap resolved
   with a calculator and left to drift the moment the cap moved.

   ch is a font-relative unit and resolves against the element it is used ON, not the one it was
   declared on, so both users are pinned to the same font-size for it to mean the same thing in
   each. The portrait holds only an image, so a font-size on it costs nothing. */
.view-head:has(.contact-portrait){grid-template-columns:auto 1fr auto}
.view-head:has(.contact-portrait),.vh-kicker{--measure-fs:17px;--measure:66ch}
.view-head:has(.contact-portrait) .vh-kicker,.view-head:has(.contact-portrait) .contact-lead{grid-column:1/3}
/* Both margins change when the lead moves inside the head.
   Below it, the head's own margin now separates it from the links, so its bottom margin would
   stack with that. Above it, the head's margin used to BE the gap between the kicker and the lead,
   back when the lead was the next element after the head; as a row of the same grid it sits in a
   grid whose row gap is zero, and the two ran straight into each other. */
.view-head:has(.contact-portrait) .contact-lead{margin:54px 0 0}
.contact-portrait{
  /* span 3, not 1/-1. The head declares no grid-template-rows, so its explicit grid is a single
     row and every row here is implicit; -1 therefore resolves to the end of row one and the
     portrait sat beside the title with the kicker and lead passing underneath it. Counting rows
     is the only thing that spans an implicit grid. Three of them: the index and the title share
     the first, then the kicker, then the lead. */
  grid-column:3;grid-row:1/span 3;align-self:center;justify-self:end;
  --pw:clamp(160px,19vw,280px);
  width:var(--pw);aspect-ratio:1;

  /* Sat in the middle of the space beside the text rather than against the page edge.
     The head cannot be asked to do this by giving the portrait's column a share of the width,
     because the text columns are what is left over: widening the third column narrows the kicker,
     which pushes the text edge left faster than the circle moves, and the gap grows rather than
     closes. Measured, a 32% column left the kicker a line shorter at 1000px and no better centred.

     So the column stays the width of the portrait and the circle is inset instead. The slack is
     whatever the head has beyond the kicker's own measure, the circle and the column gap, and half
     of it is the middle. max() so that where the text uses the whole row and there is no empty
     space to sit in, this returns to zero and the circle goes back to the edge on its own.

     --measure is that cap, declared once on the head and shared. It is expressed in ch, which
     resolves against the element using it, so this element carries the kicker's font-size for the
     unit to mean the same thing here as it does there.

     --vi and not 100%. A percentage margin on a grid item resolves against its grid area, which
     here is the portrait's own column, so 100% meant 240px, the whole expression went negative and
     max() quietly returned zero. --vi is the width the view is actually laid out to. */
  font-size:var(--measure-fs);
  margin:0 max(0px, calc((var(--vi) - var(--measure) - var(--pw)) / 2)) 0 0;
}
/* The artwork is already a circle on a transparent square, cropped and framed by him, so there is
   nothing here to round off, clip or position. The earlier version of this rule cut the circle out
   of a 2:3 photograph with border-radius, overflow and a tuned object-position; all of that is his
   crop's job now, and the square fills a square box with nothing discarded. */
.contact-portrait img{width:100%;height:100%;display:block}
.links{border-top:1px solid var(--line)}
.link-row{
  display:flex;align-items:center;gap:22px;padding:24px 6px;border-bottom:1px solid var(--line);
  position:relative;overflow:hidden;
  /* These are anchors, and nothing had turned the browser's underline off, so every address in the
     list was underlined at 26px. The row's own bottom rule is the divider; the text does not need one
     as well. */
  text-decoration:none;
}
.link-row::before{
  content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(143,126,119,.13),transparent 70%);
  transform:translateX(-101%);transition:transform .8s var(--ease-cine);
}
.link-row:hover::before,.link-row:focus-visible::before{transform:none}
/* The label is the brand mark rather than its name. Inlined in the markup rather than loaded as
   files so it inherits currentColor and can light up with the row; the paths came from Simple Icons,
   except LinkedIn's, which that set no longer carries, so it comes from Iconify. */
.link-row .lk{
  width:26px;height:26px;flex:0 0 auto;color:var(--sand);
  transition:color .6s var(--ease),transform .7s var(--ease-out);
}
.link-row .lk svg{display:block;width:100%;height:100%}
/* alphaXiv publish no SVG and are in no icon set, so their mark is carried as an alpha mask painted
   with currentColor. That way it inherits the row's color and hover exactly like the inline SVGs,
   instead of sitting there as a red-on-white PNG. */
.link-row .lk--mask{
  background:currentColor;
  -webkit-mask:url("../images/logos/alphaxiv-mark.png") center/contain no-repeat;
          mask:url("../images/logos/alphaxiv-mark.png") center/contain no-repeat;
}
.link-row:hover .lk,.link-row:focus-visible .lk{color:var(--sand-hi);transform:scale(1.08)}
.link-row .lv{
  flex:1;font-family:var(--hud);font-weight:300;text-transform:uppercase;letter-spacing:.16em;
  font-size:clamp(17px,2vw,26px);color:var(--text-hi);transition:transform .7s var(--ease-out);
}
.link-row:hover .lv,.link-row:focus-visible .lv{transform:translateX(10px)}
.link-row .arw{color:var(--sand);font-size:18px;transition:transform .7s var(--ease-out)}
.link-row:hover .arw{transform:translate(8px,-8px)}
.contact-foot{display:flex;gap:40px;flex-wrap:wrap;margin-top:42px}
.contact-foot div{display:flex;flex-direction:column;gap:7px}
.contact-foot dt{font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);text-transform:uppercase;color:var(--dimmer)}
.contact-foot dd{margin:0;font-family:var(--hud);font-size:15px;letter-spacing:.14em;text-transform:uppercase;color:var(--text)}

/* view footer nav */
.view-next{
  margin-top:86px;padding-top:26px;border-top:1px solid var(--line);
  /* flex-end, not space-between: the row used to carry an "End of file NN" label on the left and the
     button on the right. With the label gone, space-between would push the button to the left. */
  display:flex;justify-content:flex-end;align-items:center;gap:20px;flex-wrap:wrap;
}
.next-btn{
  /* The chrome has to go explicitly. These are the only <button>s on the page and nothing reset them,
     so each one kept the browser's own light gray fill and black border -- and the rule below paints
     pale sand text, which on near-white is unreadable. */
  appearance:none;background:none;border:0;padding:0;margin:0;text-align:left;cursor:pointer;
  display:flex;align-items:center;gap:14px;
  font-family:var(--hud);font-weight:300;text-transform:uppercase;letter-spacing:.3em;font-size:14.5px;color:var(--text);
  transition:color .5s,gap .5s var(--ease-out);
}
.next-btn:focus-visible{outline:1px solid var(--vc);outline-offset:6px}
.next-btn:hover{color:#fff;gap:22px}
.next-btn .dia{background:var(--vc)}

/* ==========================================================================
   LOADER
   ========================================================================== */
.loader{
  position:fixed;inset:0;z-index:200;background:#04060a;
  display:flex;flex-direction:column;justify-content:flex-end;
  padding:0 0 8vh;overflow:hidden;
}
.loader::after{
  content:"";position:absolute;inset:0;pointer-events:none;
  background:radial-gradient(70% 60% at 50% 58%, rgba(48,74,112,.16), transparent 70%);
}
/* The glow belongs to the plate, so it goes when the plate is handed to the tessellation. A
   pseudo-element paints after every child, so this sat on top of the outro's canvas and the wave
   could not erase it: it lit the middle of the screen while the erased edges went dark, and the
   pair read as a black vignette laid over the effect. */
.loader[data-outro]::after{content:none}
.ldr-mid{width:min(1180px,90vw);margin:0 auto;position:relative;z-index:2}
/* Centered on the SCREEN and out of flow, so the HUD block below keeps its own placement. */
.ldr-glyph{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
  width:min(38vh,38vw);aspect-ratio:1;z-index:2;pointer-events:none}
/* The figure is drawn at P0 and held there while this fade runs; main.js releases the lap and starts
   the progress line when it ends. Hidden until then because the worker's news of its first frame
   cannot reach the main thread before that frame is already composited -- measured, the lattice was
   on screen 27 to 77ms before the bar had any ink -- and appearing at full strength mid-fade-in
   would show the figure already on its way to P1. */
#ldrGlyph{opacity:0}
#ldrGlyph.is-lit{animation:glyph-in 200ms linear forwards}
@keyframes glyph-in{from{opacity:0}to{opacity:1}}
.ldr-line{
  height:1px;background:var(--line);position:relative;margin-bottom:28px;overflow:hidden;
}
.ldr-line i{position:absolute;inset:0;background:linear-gradient(90deg,var(--sand),var(--sand-hi));transform-origin:left;transform:scaleX(0)}
.loader.out{
  transform:translateY(-100%);
  transition:transform 1.15s cubic-bezier(.76,0,.24,1);
}
/* Once the plate is spent it must stop taking clicks. Keyed to the class the script actually sets:
   this looked for `body.loaded`, which nothing adds, and the normal path only got away with it
   because it also sets display:none. Skipping the intro with ?nl left the whole page covered by an
   invisible plate that swallowed every click. */
.loader.is-done{pointer-events:none}
@keyframes destIn{from{opacity:0;transform:scale(.72)}to{opacity:1;transform:none}}
@keyframes fadeIn{from{opacity:0}to{opacity:.5}}

/* ==========================================================================
   RESPONSIVE
   ========================================================================== */
@media (max-width:1100px){
  .identity{grid-template-columns:1fr;gap:40px}
  .portrait{max-width:320px}
}
@media (max-width:860px){
  :root{--top:60px;--bot:46px}
  .view{padding:calc(var(--top) + 26px) 0 calc(var(--bot) + 90px)}
  .view-inner{--vi:88vw;--rail:14px}
  .view-head{grid-template-columns:1fr;gap:8px}
  .vh-idx{padding-top:0}
  .vh-kicker{grid-column:1}
  /* One column here, so there is no third column to stand the portrait in. It goes under the
     text at a size that does not dominate the page it introduces. */
  .view-head:has(.contact-portrait){grid-template-columns:1fr}
.view-head:has(.contact-portrait) .vh-kicker,.view-head:has(.contact-portrait) .contact-lead{grid-column:1}
  .contact-portrait{grid-column:1;grid-row:auto;justify-self:start;margin-top:26px;width:150px}
  .pub{grid-template-columns:auto 1fr;gap:0 16px}
  /* .entry__top and .when are handled by the @container rule on .timeline instead. Keyed to
     the viewport they fought it: at 860px the container is still ~720px wide, so the entry was
     side by side while these were loosening the date to wrap and closing the gap to 6px. */
  .link-row{gap:14px}
  .link-row .lk{width:22px;height:22px}
  .loader{padding-bottom:14vh}
  .ldr-mid{width:88vw}
}

/* ════════════════════  DESTINATIONS OVER THE LANDING  ════════════════════
   The landing is a long scrolling page and the destinations are full-screen, so a view has to
   cover it rather than sit after it in the flow. `position:fixed` + its own scroll container is
   what makes the two independent: the landing keeps its scroll position while a view is open,
   which is why returning from a destination puts you back where you were. */
.view{position:fixed;inset:0;z-index:60;overflow-y:auto;overscroll-behavior:contain;
  background:linear-gradient(180deg,var(--void) 0%,var(--void-2) 60%,var(--void) 100%)}
.view[hidden]{display:none}
body.scene-view{overflow:hidden}
/* the landing's fixed chrome would otherwise float over a destination */
body.scene-view .beat,body.scene-view .rail{visibility:hidden}
/* The atmosphere sits at z0, safely under the panel, but the color grade is at z90 and the film
   overlays above that, so all three were painting ON TOP of an open destination. The grade is the one
   that shows: it is warm orange on soft-light and its opacity is driven by landing scroll, so it
   washed across the panel. The stage goes too, since nothing can see it and it is a canvas redraw
   every frame. Vignette and grain stay, being static texture that suits the destinations as well. */
body.scene-view .stage,body.scene-view .grade{visibility:hidden}
.hud__nav a[aria-current="page"]{color:var(--sand-hi)}
.hud__nav a[aria-current="page"]::after{transform:scaleX(1)}

/* The bar is animated by the compositor, not by script.
   It used to be driven frame by frame from requestAnimationFrame, which meant any long task on the main
   thread stopped it dead: recovering the hero letters' stroke geometry blocked for 369ms against a
   normal frame of 16, and the bar visibly parked a third of the way along. A keyframe animation is
   handed to the compositor and keeps running regardless of what script is doing. The curve approximates
   the expo-out the script used, so the pacing is unchanged. */
/* One continuous fill, deliberately gentle.
   This was cubic-bezier(.16,1,.3,1), which peaks at 6.2x the average rate. Measured against the run:
   it passed 98% with 2.6 SECONDS still to go, so the bar looked finished while the sigil had most of
   its four moves left. Stepping the bar through the figure's own surges cured that but lost the point
   of a progress line, which is that it flows.
   It leaves the line at twice the average rate, tops out at 2.1x nine percent of the way in, and spends
   the whole remaining nine tenths slowing down -- 35% filled by a sixth of the run, 64% by a third,
   83% by half, then a long settle. That is the shape the old curve had, at a third of its peak: 6.2x
   put the fill 90% done by a third of the run and left 2.6 SECONDS of invisible crawl, where this
   reads as full about a second before it lands.
   The duration is the sigil's own ARRIVE_MS and the bar starts on the engine's first reported frame,
   so whatever the curve does in between, the two finish together. */
@keyframes ldr-fill{from{transform:scaleX(0)}to{transform:scaleX(1)}}
.ldr-line i.is-running{
  animation:ldr-fill var(--ldr-dur,5616ms) cubic-bezier(.25,.5,.4,1) forwards;
}

/* The progress line fades once it is full: the bar reaching 100% and the plate breaking up are two
   separate beats, and running them together made the bar look like part of the tessellation. */
.ldr-line.is-spent{opacity:0;transition:opacity .42s var(--ease-io)}

/* ── hero name drawn stroke by stroke ─────────────────────────────────
   The drawing replaces the line-by-line slide-up: both are reveals, and running them together makes
   the name climb into place while it is still being written. The text stays in the document, hidden
   from sight but not from a screen reader or a crawler, so the strokes are decoration over real text. */
.hero__name.is-drawn{font-size:clamp(2.4rem,8.4vw,7.2rem)}
.hero__name.is-drawn .line{overflow:visible;padding-bottom:0}
.hero__name.is-drawn .line>span{transform:none;transition:none}
.hero__name.is-drawn .np{display:block;line-height:0}
.np-sr{
  position:absolute;width:1px;height:1px;overflow:hidden;
  clip-path:inset(50%);white-space:nowrap;
}

/* ══════════════════════════════════════════════════════════════════════════════
   PROJECT TILES AND THE DOSSIER THEY OPEN
   The grid is an index: a picture, a title, a date, and enough of a summary to
   earn a click. Everything else lives in the dossier, so the tile never has to
   carry a stack of chips and links it has no room for.
   ══════════════════════════════════════════════════════════════════════════════ */
.tiles{display:grid;gap:20px;grid-template-columns:repeat(3,1fr)}
.tile{
  position:relative;border:1px solid var(--line);background:var(--void-2);cursor:pointer;
  overflow:hidden;text-align:left;padding:0;color:inherit;font:inherit;appearance:none;
  display:flex;flex-direction:column;
  clip-path:polygon(0 0,100% 0,100% calc(100% - 18px),calc(100% - 18px) 100%,0 100%);
  transition:border-color .5s var(--ease-cine),transform .7s var(--ease-cine);
}
.tile:hover{border-color:rgba(143,126,119,.5);transform:translateY(-4px)}
/* Only where there is no pointer to hover with. Holding a tile is how a touch screen previews its
   video, and iOS answers a long press with its own callout and a text selection, both of which fight
   the gesture and neither of which the page can dismiss. Scoped to touch so a mouse user can still
   select the description. */
@media (hover: none){
  .tile{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
}
.tile:focus-visible{outline:1px solid var(--sand-hi);outline-offset:2px}
.tile figure{margin:0;aspect-ratio:3/2;overflow:hidden;position:relative;background:var(--void-2)}
/* A screenshot can be cropped to the frame and gain from filling it. A recording or a plot
   cannot -- cover would cut the axis labels off -- so those fit whole, as does a pair. */
.tile figure > img{width:100%;height:100%;object-fit:cover;display:block}
.tile figure > video{width:100%;height:100%;object-fit:contain;display:block}
.tile figure.inv > video{filter:invert(1) hue-rotate(180deg) saturate(.9)}
.tile figure:not(.inv) img,.tile figure:not(.inv) video{
  filter:saturate(.6) brightness(.8);transition:filter .8s var(--ease-cine),transform 1.2s var(--ease-cine)}
.tile:hover figure:not(.inv) img,.tile:hover figure:not(.inv) video{filter:saturate(.95) brightness(1)}
.tile:hover figure > img{transform:scale(1.04)}
/* No padding on a pair in a tile: the panels take the full height of the frame, since dead bands
   above and below waste the only space the tile has. */
.tile figure .dpair{gap:10px}
.tile figure .dpair > img,.tile figure .dpair > video{border-color:var(--line)}
.tile figure::after{content:"";position:absolute;inset:0;
  background:linear-gradient(180deg,rgba(6,9,14,.2),rgba(6,9,14,.6))}
.tile figure.blank{display:flex;align-items:center;justify-content:center;
  background:repeating-linear-gradient(135deg,rgba(143,126,119,.05) 0 10px,transparent 10px 20px)}
.tile figure.blank span{font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);
  text-transform:uppercase;color:var(--dimmer)}
.tile .tx{padding:16px 18px 18px;display:flex;flex-direction:column;flex:1}
.tile-n{font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);text-transform:uppercase;color:var(--dimmer)}
.tile h4{font-family:var(--disp);font-weight:600;
  letter-spacing:-.005em;color:var(--text-hi);margin:8px 0 0}
.tile h4{font-size:19px}
.tile .when{font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);text-transform:uppercase;
  color:var(--dimmer);margin:6px 0 0}
.tile .abr{font-size:14.5px;line-height:1.6;color:var(--dim);margin:9px 0 0;
  text-align:justify;hyphens:auto;-webkit-hyphens:auto}
.tile .go{margin:13px 0 0;font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);
  text-transform:uppercase;color:var(--sand-dim);transition:color .5s var(--ease)}
.tile:hover .go{color:var(--sand-hi)}

/* ── the dossier ─────────────────────────────────────────────────────────────
   A <dialog> rather than a div: the browser gives the focus trap, Escape, the
   inert background and a real ::backdrop, and hand-rolled overlays are where
   those go wrong. The scroller is the inner div, because a modal dialog is
   position:fixed by definition and position:sticky inside one has nothing to
   resolve against. */
dialog.dos{
  --vc:#a8894e;
  border:0;padding:0;margin:0;width:100vw;height:100vh;max-width:100vw;max-height:100vh;
  background:linear-gradient(180deg,var(--void),var(--void-2) 55%,var(--void));
  color:var(--text);overflow:hidden;
}
dialog.dos::backdrop{background:rgba(2,4,8,.72);backdrop-filter:blur(3px)}
dialog.dos[open]{animation:dos-in .46s var(--ease-out) both}
@keyframes dos-in{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}
.dos-scroll{height:100%;overflow-y:auto;scrollbar-width:none;overscroll-behavior:contain}
.dos-scroll::-webkit-scrollbar{width:0}
.dos-close{
  position:fixed;top:20px;right:clamp(18px,4vw,52px);z-index:9;
  appearance:none;background:rgba(4,6,10,.7);border:1px solid var(--line);color:var(--sand);
  font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);text-transform:uppercase;
  padding:9px 13px;cursor:pointer;backdrop-filter:blur(6px);
  transition:color .4s var(--ease),border-color .4s var(--ease);
}
.dos-close:hover{color:var(--sand-hi);border-color:var(--sand-hi)}
.dos-in{width:min(1120px,90vw);margin:0 auto;padding:60px 0 120px}
.dos-in .tile-n{display:block}
.dos-in h3{font-family:var(--disp);font-weight:600;letter-spacing:-.022em;
  font-size:clamp(30px,4.4vw,58px);line-height:.94;color:var(--text-hi);margin:8px 0 0}
.dos-in .sub{font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);text-transform:uppercase;
  color:var(--dimmer);margin:12px 0 0}

/* Media on top as a horizontal snap strip, so several figures are one gesture apart
   instead of hidden behind a control. */
.dstrip{margin-top:30px;border:1px solid var(--line);display:flex;
  overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none;overscroll-behavior-x:contain}
.dstrip::-webkit-scrollbar{height:0}
/* Height, not aspect ratio: at 1120px wide a 16:10 slide is 700px tall and pushes the index bar
   off the bottom of the screen, so the strip reads as one picture with nothing beside it. */
.dslide{flex:0 0 100%;scroll-snap-align:center;height:clamp(320px,60vh,660px);
  background:var(--void-2);position:relative}
/* contain, not cover: these are plots and screenshots, and cropping one to fill the box cuts
   the axis labels off the bottom. */
.dslide > img,.dslide > video{width:100%;height:100%;object-fit:contain;display:block}
/* The plot exports are black-on-white. Invert plus a 180 degree hue rotation puts them on the
   site's background while leaving the series colors where they were. */
.dslide.inv > img,.dslide.inv > video{filter:invert(1) hue-rotate(180deg) saturate(.9)}
/* A pair shares one slide. Each panel is the full slide height and takes its width from its own
   aspect ratio; capping either at half the slide made the squarer one shorter than its neighbor,
   which is the mismatch the pair exists to avoid. */
.dpair{width:100%;height:100%;display:flex;align-items:center;justify-content:center;gap:16px}
.dpair > img,.dpair > video{height:100%;width:auto;flex:0 1 auto;min-width:0;object-fit:contain;
  display:block;border:1px solid var(--line-soft)}
/* A sequence of stills occupying one slide, cross-fading in order. */
.dsteps{position:relative;width:100%;height:100%}
.dsteps img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;
  opacity:0;transition:opacity .45s var(--ease-out)}
.dsteps img.on{opacity:1}
.nofig{width:100%;height:100%;display:flex;align-items:center;justify-content:center;
  background:repeating-linear-gradient(135deg,rgba(143,126,119,.05) 0 10px,transparent 10px 20px)}
.nofig span{font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);
  text-transform:uppercase;color:var(--dimmer)}

/* Both an index and a caption: it reports which slide is centered and scrolls to one when clicked,
   so dragging the strip and clicking a tab end in the same state. */
.figbar{display:flex;border:1px solid var(--line);border-top:0}
.figbar button{flex:1;appearance:none;background:none;border:0;border-right:1px solid var(--line);
  color:var(--dimmer);font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);text-transform:uppercase;
  padding:11px 12px;cursor:pointer;text-align:center;transition:color .4s,background .4s}
.figbar button:last-child{border-right:0}
.figbar button[aria-selected="true"]{color:var(--sand-hi);background:rgba(168,137,78,.09)}
.figbar button:hover{color:var(--sand)}
.dcap{font-family:var(--mono);font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);text-transform:uppercase;
  color:var(--dimmer);display:flex;justify-content:center;gap:12px;text-align:center;
  padding:11px 12px;border:1px solid var(--line);border-top:0;margin:0}

.dcols{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,260px);gap:44px;margin-top:34px}
.dprose{font-size:16.5px;line-height:1.78;color:var(--dim);text-align:justify;hyphens:auto;-webkit-hyphens:auto}
.dprose p + p{margin-top:15px}
/* A footnote, not a section: smaller than the prose, set left rather than justified, and hung off
   a dagger in the margin so it reads as an aside on the description above it. */
.dnote{margin:26px 0 0;padding-top:16px;border-top:1px solid var(--line-soft);
  font-size:13px;line-height:1.6;color:var(--dimmer);text-align:left;
  padding-left:18px;text-indent:-18px}
.dnote span{display:inline-block;width:18px;text-indent:0;color:var(--sand-dim)}
/* a second footnote sits under the first rather than starting its own block */
.dnote + .dnote{margin-top:7px;padding-top:0;border-top:0}
.dside{align-self:start;position:sticky;top:74px}
.dside h5{font-family:var(--mono);font-weight:400;font-size:var(--lbl-s);letter-spacing:var(--lbl-s-ls);
  text-transform:uppercase;color:var(--dimmer);margin:0 0 4px;
  padding-bottom:10px;border-bottom:1px solid var(--line)}
.dsl{list-style:none;margin:0;padding:0}
.dsl li{border-bottom:1px solid var(--line-soft)}
.dsl a{display:flex;align-items:baseline;justify-content:space-between;gap:12px;
  padding:12px 0;text-decoration:none;color:var(--sand-dim);
  font-family:var(--mono);font-size:var(--lbl-m);letter-spacing:var(--lbl-m-ls);text-transform:uppercase;
  transition:color .35s var(--ease-out),padding-left .35s var(--ease-out)}
.dsl a:hover{color:var(--sand-hi);padding-left:6px}
.dsl a i{font-style:normal;color:var(--sand-dim);opacity:.6;
  transition:opacity .35s,transform .35s var(--ease-out)}
.dsl a:hover i{opacity:1;transform:translateX(3px)}

@media(max-width:1100px){.tiles{grid-template-columns:repeat(2,1fr)}}
/* Under ~800px the two panels of a pair cannot sit side by side at full height without being
   squeezed, so the pair becomes a stack and each takes half the slide. */
@media(max-width:860px){
  .dslide .dpair{flex-direction:column;gap:10px}
  .dslide .dpair > img,.dslide .dpair > video{height:auto;width:100%;max-height:calc(50% - 5px)}
}
@media(max-width:860px){
  .dcols{grid-template-columns:1fr;gap:26px}
  .dside{position:static}
}
@media(max-width:700px){.tiles{grid-template-columns:1fr}}

/* Narrow enough that a second column would leave the prose too little, so the entry becomes a single
   column and the mark returns to the flow beneath the text. */
@media (max-width:1100px){
}

/* Deliberately no prefers-reduced-motion block. It is an operating-system setting that Windows also
   turns on for battery saver and for a general visual-effects preference, so it is a poor proxy for
   "this visitor wants less animation". Honoring it here disabled the smooth scrolling, froze the AES
   round sequence on its first frame, and made every transition on the site run at 0.14s, including
   the laboratory panels, the tiles, and every hover in the navigation. */

/* ════════════════════  BACKGROUND PROTOTYPE  ════════════════════
   Only in the landing-bg branch. The seven beats are emptied of copy so the atmosphere, the descent
   and the rail can be judged on their own, and each keeps the height it measured on main so the
   scene's clock -- scroll over document height -- runs at exactly the pace it really does. */
.proto-empty{min-height:calc(var(--beat-vh) * 100vh);position:relative}
/* The hero still has to centre its name, which the real .hero rules did by way of content that is
   no longer here. */
.proto-empty#top{display:grid;place-items:center}
.proto-empty#top .hero{width:100%}
/* A faint marker per beat, so it is possible to see where the descent thinks it is without any
   copy on the page. Prototype only; nothing like this ships. */
.proto-empty::after{
  content:attr(id);position:absolute;left:var(--pad);top:50%;transform:translateY(-50%);
  font-family:var(--f-mono);font-size:11px;letter-spacing:.24em;text-transform:uppercase;
  color:rgba(219,214,208,.13);pointer-events:none;
}
.proto-empty#top::after{content:none}
/* Prototype: hazard lamps on the tower, out of step with each other. */
.proto-beacon{animation:proto-beacon 2.6s ease-in-out infinite}
@keyframes proto-beacon{0%,62%,100%{opacity:.18}72%{opacity:1}}
/* Prototype: the tower strips glow rather than merely being pale. */
.proto-strip{filter:drop-shadow(0 0 3px rgba(214,232,255,.85)) drop-shadow(0 0 9px rgba(150,190,255,.5))}
/* Prototype: the ship's own layer, above the terrain. The sky canvas is below .ground, so anything
   that has to land IN FRONT of the mountains cannot be drawn there. */
/* No inset and no 100%: proto-art sizes and places this to the ship, so the store it commits every
   frame is the ship's box and not the window's. */
.proto-ship{position:absolute;left:0;top:0;transform-origin:0 0;pointer-events:none;z-index:3}
/* Prototype: pad lamps fade around the ring rather than blinking as one, and the spotlights breathe. */
@keyframes proto-lamp{0%,100%{opacity:.30}45%{opacity:1}}
.proto-spot{animation:proto-spot 5.2s ease-in-out infinite}
@keyframes proto-spot{0%,100%{opacity:.72}50%{opacity:1}}
/* PROTOTYPE: the hangar's ceiling fans, seen from the side.
   A blade at angle a projects to a horizontal half-length of cos(a), so a cosine on scaleX IS the
   rotation viewed edge on -- zero is the blade end-on to us, negative is the same blade come round to
   the far side. Sampled every eighth of the turn; interpolating linearly between those is close
   enough to a cosine at this size, and cheaper than animating a real transform per blade.
   Duration, phase and --t are set per blade in the markup: a shared period would flap them
   together, and the tilt has to be in the bay's own units. */
.proto-blade{animation-name:proto-blade;animation-timing-function:linear;animation-iteration-count:infinite}
/* Each blade is drawn twice, once either side of the motor, and each copy shows for the half turn it
   is on. translateY is the depth -- negative far, positive near -- so the halves swap where it
   crosses zero, at 0% and 50%. The switch is a tenth of a percent wide, which is a step at any
   sensible period. It lands where the blade is at full extension, and at full extension the blade
   sits outboard of the motor, so nothing on screen changes hands. Both copies run the transform
   animation as well, so the two cannot drift apart. */
.proto-blade.pb-far{animation-name:proto-blade,pb-vis-far}
.proto-blade.pb-near{animation-name:proto-blade,pb-vis-near}
@keyframes pb-vis-far{0%,49.9%{opacity:1}50%,100%{opacity:0}}
@keyframes pb-vis-near{0%,49.9%{opacity:0}50%,100%{opacity:1}}
@keyframes proto-blade{
  0%{transform:translateY(0) scaleX(1)}
  12.5%{transform:translateY(calc(var(--t) * -.707)) scaleX(.707)}
  25%{transform:translateY(calc(var(--t) * -1)) scaleX(0)}
  37.5%{transform:translateY(calc(var(--t) * -.707)) scaleX(-.707)}
  50%{transform:translateY(0) scaleX(-1)}
  62.5%{transform:translateY(calc(var(--t) * .707)) scaleX(-.707)}
  75%{transform:translateY(calc(var(--t) * 1)) scaleX(0)}
  87.5%{transform:translateY(calc(var(--t) * .707)) scaleX(.707)}
  100%{transform:translateY(0) scaleX(1)}
}
/* PROTOTYPE: the aeroplane's propeller, seen side on.
   The same argument as the ceiling fans above, turned a quarter turn. A fan's disc is horizontal, so
   edge on a blade at angle a projects to a horizontal half-length of cos a and the animation is a
   scaleX. A propeller's disc stands vertical and square across the fuselage, so its blades project
   to a VERTICAL half-length and it is scaleY that carries the rotation; negative scaleY is the same
   blade come round to point downward, which covers the whole revolution in one keyframe set.
   scaleY and nothing else. There was a translateX on this too, carrying each blade's depth as a
   small sideways swing, and it had to go: three blades sit at 120 degrees, so at any instant the one
   above the hub and the one below it are at OPPOSITE depths and were therefore given opposite
   offsets. They meet at the hub, so the offset showed up there as a hard step across the blade line
   -- the propeller looked snapped in half at its own centre. Any lateral swing does this; the size
   only sets how obvious it is.
   Depth is not lost by dropping it. It is carried by pp-front/pp-back below, which decide whether a
   blade passes in front of the spinner or behind it, and that is the stronger cue of the two because
   it is an occlusion rather than a nudge. */
.proto-prop{animation-name:proto-prop;animation-timing-function:linear;animation-iteration-count:infinite}
/* Each blade is drawn twice, once behind the spinner and once in front of it, and each copy shows
   for the half revolution it is on -- the same device as the fan's pb-far/pb-near above, and needed
   for the same reason: a blade coming round the near side passes IN FRONT of the hub it is bolted
   to, and one going away passes behind it.
   The switch is at 0% and 50%, which is where scaleY is +/-1. That is not an arbitrary choice: the
   blade projects to scaleY = sin a and its depth is cos a, so full vertical extension is exactly
   where the depth is zero and the blade lies in the spinner's own plane. Handing over anywhere else
   would swap a blade that is visibly in front for one visibly behind. */
.proto-prop.pp-front{animation-name:proto-prop,pp-front}
.proto-prop.pp-back{animation-name:proto-prop,pp-back}
@keyframes pp-front{0%,49.9%{opacity:1}50%,100%{opacity:0}}
@keyframes pp-back{0%,49.9%{opacity:0}50%,100%{opacity:1}}
@keyframes proto-prop{
  0%{transform:scaleY(1)}
  12.5%{transform:scaleY(.707)}
  25%{transform:scaleY(0)}
  37.5%{transform:scaleY(-.707)}
  50%{transform:scaleY(-1)}
  62.5%{transform:scaleY(-.707)}
  75%{transform:scaleY(0)}
  87.5%{transform:scaleY(.707)}
  100%{transform:scaleY(1)}
}
/* PROTOTYPE: the strip's sequenced flashers.
   Each position along the runway carries one light on the near edge and one on the far edge, and the
   two sit in a single group so they can only ever fire together: a pair that drifted apart would
   read as two unrelated lamps rather than as one gate the aeroplane passes through. The delay is
   staggered along the strip so the bright pair runs down it, in the direction the aeroplane points.
   They never go out, they sit at a third and pulse to full. Blinking them off left gaps in the edge
   lighting between flashes, which reads as lamps failing rather than as a sequence. */
.proto-rwseq{animation:proto-rwseq 1.5s linear infinite}
@keyframes proto-rwseq{0%{opacity:1}9%{opacity:.34}100%{opacity:.34}}
/* Prototype: the searchlights pan. Three periods that do not divide into each other, so the beams
   drift out of phase instead of sweeping together.

   skewX, not rotate. A beam leaving a fixed lamp and falling on flat ground keeps its apex still and
   slides its footprint ALONG that ground -- the lit patch stays on the surface. Rotating the whole
   triangle tilted the far edge off the ground with it, which is a beam pointing into the air.
   skewX(a) maps (x,y) to (x + y*tan a, y): with the origin at the lamp, every point shifts sideways
   in proportion to how far below the lamp it is, so the apex is fixed, the base slides, and the
   bottom edge stays exactly horizontal however far the beam swings. The triangle shears instead of
   turning, which is what a searchlight actually does. */
/* Nothing in the scene turns while the scene is off screen. Set by an observer on #ground; see the
   note beside it in js/scene/proto-art.js for what it was costing. The descendant selector is the
   point of it -- every fan, beacon, beam and blade in there stops on one class toggle -- and it is
   affordable because the class changes when the ground enters or leaves the frame, not per frame. */
#ground.proto-idle *{animation-play-state:paused}
/* The beams' own layer, over the facility and over the ground cover. `screen` is what makes it
   LIGHT rather than merely sit on top: it can only add, so the pavement and the scrub the beam
   crosses come up brighter instead of being veiled by a pale film. isolation keeps the blend from
   reaching past the ground box into the sky. */
.proto-beams{mix-blend-mode:screen;isolation:isolate}
.proto-cone{animation:proto-pan-a 13s ease-in-out infinite}
.proto-cone-1{animation:proto-pan-b 17s ease-in-out infinite}
.proto-cone-2{animation:proto-pan-c 21s ease-in-out infinite}
@keyframes proto-pan-a{0%,100%{transform:skewX(-24deg)}50%{transform:skewX(17deg)}}
@keyframes proto-pan-b{0%,100%{transform:skewX(20deg)}50%{transform:skewX(-15deg)}}
@keyframes proto-pan-c{0%,100%{transform:skewX(-13deg)}38%{transform:skewX(26deg)}70%{transform:skewX(-4deg)}}
/* Prototype: the plain. .ridge only reaches 130px below the horizon, so anything standing on the
   open ground -- the pad especially -- has nowhere to be inside it. This covers the whole ground box.

   overflow:visible because things standing ON the ground are still allowed to be TALLER than it. An
   SVG clips to its viewBox by default, and the viewBox starts at the horizon, so the catch arms at
   y = -56 and the top 89 units of both masts were being cut off -- present in the DOM, laid out, and
   simply not drawn. A tower rising into the sky against the mountains is the point of a tower. */
.proto-plain{position:absolute;left:0;right:0;top:0;width:100%;height:100%;overflow:visible}
/* The tumbleweed is the only thing out here that changes per frame, and it was sharing one raster
   with nine static SVGs stacked around it, so each hop redrew all of them. Measured at the crash,
   hiding the tumbleweed took a 7.7ms frame to 3.3 -- and hiding a STATIC cover under it took it to
   3.9, which can only happen if that static cover was being redrawn too. Its own layer holds one
   texture and takes the invalidation with it. */
.proto-cover-roll{will-change:transform}
