/* ═══════════════════════════════════════════════════════ type
   Playfair for display, Literata for text, JetBrains Mono for data.
   Greek on the display face is delegated to GFS Didot under the SAME family
   name — Playfair has 4 of 144 Greek glyphs (verified in the binary) and
   simply cannot set ΛCDM. Both are Didones, so the composite reads as one
   voice rather than a fallback. Literata carries its own Greek. */

@font-face {
  font-family: "Plate Display";
  src: url(../fonts/plate-display.woff2) format("woff2-variations");
  font-weight: 400 700;
  font-display: swap;
}
@font-face {
  font-family: "Plate Display";
  src: url(../fonts/plate-display-greek.woff2) format("woff2");
  unicode-range: U+0370-03FF, U+1F00-1FFF;
  font-weight: 400 700;
  size-adjust: 96%;    /* GFS Didot upm 1000 vs Playfair 1240 */
  font-display: swap;
}

@font-face {
  font-family: "Plate Text";
  src: url(../fonts/plate-text.woff2) format("woff2-variations");
  font-weight: 300 600;
  font-display: swap;
}
@font-face {
  font-family: "Plate Text";
  src: url(../fonts/plate-text-greek.woff2) format("woff2-variations");
  unicode-range: U+0370-03FF, U+1F00-1FFF;
  font-weight: 300 600;
  font-display: swap;
}

@font-face {
  font-family: "Plate Mono";
  src: url(../fonts/plate-mono.woff2) format("woff2-variations");
  font-weight: 400 700;
  font-display: swap;
}

/* The math shim is declared LAST and attached to all three families, so M⊙
   sets in one visual family wherever it appears. font-display: block is right
   HERE and nowhere else — better to hide one symbol for two frames than to
   flash a mismatched system ⊙ and reflow. Weight range is explicit, or
   matching against a variable body weight is underspecified. */
@font-face {
  font-family: "Plate Text";
  src: url(../fonts/math-shim.woff2) format("woff2");
  unicode-range: U+2299, U+229A, U+2295, U+2297, U+2273, U+2272, U+221D,
                 U+2248, U+223C, U+27E8, U+27E9, U+2261, U+226A, U+226B;
  size-adjust: 97%;
  font-weight: 100 900;
  font-style: normal;
  font-display: block;
}
@font-face {
  font-family: "Plate Display";
  src: url(../fonts/math-shim.woff2) format("woff2");
  unicode-range: U+2299, U+229A, U+2295, U+2297, U+2273, U+2272, U+221D,
                 U+2248, U+223C, U+27E8, U+27E9, U+2261, U+226A, U+226B;
  size-adjust: 97%;
  font-weight: 100 900;
  font-style: normal;
  font-display: block;
}
@font-face {
  font-family: "Plate Mono";
  src: url(../fonts/math-shim.woff2) format("woff2");
  unicode-range: U+2299, U+229A, U+2295, U+2297, U+2273, U+2272, U+221D,
                 U+2248, U+223C, U+27E8, U+27E9, U+2261, U+226A, U+226B;
  size-adjust: 97%;
  font-weight: 100 900;
  font-style: normal;
  font-display: block;
}

/* Metric-matched local fallbacks. The page had zero webfonts before this, so
   this is where CLS gets newly introduced if it is not handled. */
@font-face {
  font-family: "Plate Display Fallback";
  src: local("Iowan Old Style"), local("Palatino Linotype"), local("Georgia");
  size-adjust: 92%; ascent-override: 92%; descent-override: 24%; line-gap-override: 0%;
}
@font-face {
  font-family: "Plate Text Fallback";
  src: local("Charter"), local("Georgia"), local("Times New Roman");
  size-adjust: 97%; ascent-override: 90%; descent-override: 24%; line-gap-override: 0%;
}

/* ─────────────────────────────── registered pointer properties
   The JS already writes --glow-x/--glow-y/--tilt-x/--tilt-y per card, but they
   were UNREGISTERED — so the browser treated them as strings and the gradient
   teleported frame to frame with zero interpolation. Registering them is the
   cheapest upgrade available in this file.

   inherits: true on the glow pair specifically: with false the pseudo-element
   would NOT see the parent's value and would fall back to the initial one, so
   the ::before glow stops tracking the cursor. Note that registering also
   gives these an initial-value, which makes every var(--glow-x, 30%) fallback
   in this file unreachable — those sites were rewritten to literals. */
@property --glow-x { syntax: "<percentage>"; inherits: true;  initial-value: 50%; }
@property --glow-y { syntax: "<percentage>"; inherits: true;  initial-value: 0%; }
@property --tilt-x { syntax: "<angle>";      inherits: false; initial-value: 0deg; }
@property --tilt-y { syntax: "<angle>";      inherits: false; initial-value: 0deg; }
@property --rim-angle { syntax: "<angle>";   inherits: false; initial-value: 0deg; }
:root {
  /* ─────────── INK — near-neutral, hue ~272° through the mid ramp ───────────
     The mid and upper ramp sit at 269–276°, close to the OKLCH hue of an O/B
     stellar continuum (268°, computed below). The two darkest steps do NOT
     hold that hue — at 8-bit, #020304 is hue 243 and #000101 is hue 195 —
     because near black there are not enough levels to carry one. Chroma
     floor is 0.006, not 0.004. Hex first on every line so text can never
     vanish if oklch() fails to parse. */
  --ink-void:     #000101;
  --ink-bg:       #020304;   /* the page. was #03050b — that was blue-black. */
  --ink-bg-2:     #060609;
  --ink-panel:    #0c0d12;
  --ink-surface:  #15171d;
  --ink-hairline: #24262d;   /* non-interactive 1px rules */
  --ink-edge:     #373a43;   /* interactive borders — never the focus ring */
  --ink-dim:      #656974;   /* decorative only, never text */
  --ink-caption:  #9fa4b3;   /* captions and metadata only     8.3:1 */
  --ink-muted:    #b2b7c6;   /* secondary text                 ~10:1 */
  --ink-text-2:   #d1d4dc;   /*                                14.0:1 */
  --ink-text:     #f2f3f8;   /* headings, emphasis             18.7:1 */

  /* ─────────── ACCENT — nine hues from real spectral lines ───────────
     Hue is the only free variable; lightness and chroma are frozen per use
     tier. The anchors are computed from the CIE 1931 colour-matching
     functions, not picked off a wheel:
       Hα 6563Å → 23°   ·  Na D 5893Å → 61°  ·  Mg b 5184Å → 160°
       [O III] 5007Å → 176°  ·  Hβ 4861Å → 199°  ·  O/B continuum → 268°
       Hγ 4340Å → 291°
     318° is unanchored. 340° is a declared false-colour: Lyα and the O VI
     doublet are far-ultraviolet and have no colour at all — the pink in
     nebula imagery is Balmer, not Lyman. Never label a swatch "Lyα".

     The old palette separated seven accents by HUE alone: cyan sat at
     L 0.828 and magenta at L 0.833 — a lightness difference of 0.005, which
     is invisible under any colour-vision deficiency. Freezing L per tier and
     moving only hue fixes that. */
  --h-halpha: 23;   --h-gold: 61;    --h-green: 160;  --h-o3: 176;
  --h-cyan: 199;    --h-blue: 268;   --h-violet: 291; --h-magenta: 318;
  --h-lya: 340;

  --l-text:  0.845; --c-text:  0.115;   /* accent type, links        ~12-13:1 */
  --l-solid: 0.720; --c-solid: 0.165;   /* filled buttons, dots, icons */
  --l-glow:  0.660; --c-glow:  0.230;   /* radial washes, bloom, rims */
  --l-wash:  0.430; --c-wash:  0.090;   /* tinted borders, card fills */

  /* Per-hue chroma CEILINGS at text lightness, solved by bisection for the
     maximum in-gamut chroma. Four of the nine hues — Hα, Na D, the O/B
     continuum and Hγ — do NOT fit in sRGB at C 0.115 and L 0.845, so without
     these the browser silently clips them and the frozen-lightness discipline
     the whole system rests on quietly stops holding. Raising --c-text above
     a ceiling does literally nothing; it maps straight back. */
  --cmax-halpha: 0.085; --cmax-gold: 0.106;  --cmax-green: 0.191;
  --cmax-o3:     0.158; --cmax-cyan: 0.144;  --cmax-magenta: 0.120;
  --cmax-lya:    0.115; --cmax-violet: 0.083; --cmax-blue: 0.076;

  /* Ready-made text-tier accents, each clamped to its own ceiling. */
  --a-halpha:  oklch(var(--l-text) min(var(--c-text), var(--cmax-halpha))  var(--h-halpha));
  --a-gold:    oklch(var(--l-text) min(var(--c-text), var(--cmax-gold))    var(--h-gold));
  --a-green:   oklch(var(--l-text) min(var(--c-text), var(--cmax-green))   var(--h-green));
  --a-o3:      oklch(var(--l-text) min(var(--c-text), var(--cmax-o3))      var(--h-o3));
  --a-cyan:    oklch(var(--l-text) min(var(--c-text), var(--cmax-cyan))    var(--h-cyan));
  --a-blue:    oklch(var(--l-text) min(var(--c-text), var(--cmax-blue))    var(--h-blue));
  --a-violet:  oklch(var(--l-text) min(var(--c-text), var(--cmax-violet))  var(--h-violet));
  --a-magenta: oklch(var(--l-text) min(var(--c-text), var(--cmax-magenta)) var(--h-magenta));
  --a-lya:     oklch(var(--l-text) min(var(--c-text), var(--cmax-lya))     var(--h-lya));

  /* ─────────── MAGMA — the colormap in his own TNG figures ───────────
     Verified against matplotlib's _cm_listed.py. The punchline: magma's zero
     point is #000004, which is --ink-bg to within two 8-bit levels. The page
     background IS the bottom of the colormap, and every accent is a step up
     it. Minimum adjacent ΔL is 0.096 across a 0.883 spread, so any two steps
     stay distinguishable under any dichromacy. */
  /* CONSUMED BY THE HERO PORTRAIT — .hero-portrait .plate-light and
     .hero-portrait .portrait-halo, both further down this file, both styling
     live elements. This comment previously said these were declared for the
     spectrum and sightline work and had no consumers below it. That was true
     the day it was written and was falsified seven commits later by PLATE I,
     which took the tokens without updating it. It matters now: the sightline
     is parked, so anyone tidying up greps for it, lands here, and reads that
     nine apparently-dead tokens belong to the parked feature. Deleting them
     makes both hero gradients invalid at computed-value time — the backlight
     and the rotating halo silently resolve to transparent, with no error. */
  --ramp-0: #020304; --ramp-1: #1d1147; --ramp-2: #51127c;
  --ramp-3: #832681; --ramp-4: #b73779; --ramp-5: #e75263;
  --ramp-6: #fc8961; --ramp-7: #fec488; --ramp-8: #fcfdbf;

  /* ─────────── MOTION ───────────
     Budget rule: only opacity and transform may take --dur-m or longer.
     Colour, border and shadow stay at --dur-xs/s — a 0.8s hover reads as
     broken. Never `transition: all`; this file has none today. */
  /* Also ahead of their consumers: the ~40 existing transitions still use
     hardcoded ms and are migrated with the motion work, not swept blindly —
     four of them animate non-compositable properties and must not move onto
     the long tokens. */
  --dur-xs: 0.18s; --dur-s: 0.4s; --dur-m: 0.8s; --dur-l: 1.2s;
  --ease-out: cubic-bezier(0.25, 1, 0.5, 1);
  --ease-in-out: cubic-bezier(0.76, 0, 0.24, 1);
  --ease-in: cubic-bezier(0.5, 0, 0.75, 0);

  /* ─────────── COMPATIBILITY ───────────
     The 1900 lines below still reference the old names. Remapped here so this
     commit reskins the whole page; the rules themselves migrate later. */
  --bg: var(--ink-bg);
  --bg-soft: var(--ink-bg-2);
  --surface: rgba(255, 255, 255, 0.05);
  --surface-strong: rgba(255, 255, 255, 0.09);
  --border: var(--ink-hairline);
  --glass-bg: rgba(12, 13, 18, 0.62);
  --glass-bg-strong: rgba(21, 23, 29, 0.74);
  --glass-border: var(--ink-edge);
  --glass-highlight: rgba(255, 255, 255, 0.16);
  --glow-cyan: oklch(var(--l-glow) var(--c-glow) var(--h-cyan) / 0.30);
  --glow-violet: oklch(var(--l-glow) var(--c-glow) var(--h-violet) / 0.26);
  --text: var(--ink-text);
  --muted: var(--ink-muted);
  --accent:   var(--a-cyan);
  --accent-2: var(--a-violet);
  --accent-3: var(--a-lya);
  --accent-4: var(--a-blue);
  --gold:     var(--a-gold);
  --magenta:  var(--a-magenta);
  --green:    var(--a-green);
  --shadow: 0 28px 90px rgba(0, 0, 0, 0.62);
  --font-display: "Plate Display", "Plate Display Fallback", "Iowan Old Style", Palatino, Georgia, serif;
  --font-text: "Plate Text", "Plate Text Fallback", Charter, Georgia, "Times New Roman", serif;
  --font-mono: "Plate Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  --header-width: 310px;
  color-scheme: dark;
  scroll-behavior: auto;   /* was smooth: it makes ScrollTrigger start/end
                              points drift, and stacks badly with scrub */
}

/* Defaults first, per-component overrides after — the reverse order silently
   loses, because [class*="-card"] and .research-card are both (0,1,0) and the
   later rule wins every tie. */
[class*="-card"],
[class*="-panel"],
.contact-tile,
.link-section {
  --card-l: 0.845;
  --card-c: 0.115;
  --card-h: var(--h-cyan);
  --card-accent: oklch(var(--card-l) var(--card-c) var(--card-h));
  --accent-glow: oklch(var(--l-glow) var(--c-glow) var(--card-h) / 0.28);
  --accent-wash: oklch(var(--l-wash) var(--c-wash) var(--card-h) / 0.55);
}

/* Lightness VARIES across components, deliberately.
   An earlier version of this froze L at 0.845 for every accent and claimed
   that helped colour-vision deficiency. That was backwards: lightness is the
   one channel every dichromacy preserves, so flattening it removes the only
   cue that survives. Simulated pairwise, the flat palette put four pairs
   under 1 JND in tritanopia where the original had none.
   These six span L 0.70–0.90 with a minimum adjacent ΔL of 0.040 — two JND —
   and every one clears 7.6:1 on the page. Chroma is clamped per hue to the
   in-gamut ceiling at ITS OWN lightness, since a ceiling solved at one
   lightness is not valid at another. */
.research-card    { --card-h: var(--h-cyan);   --card-l: 0.90; --card-c: 0.115; }
.project-card     { --card-h: var(--h-o3);     --card-l: 0.82; --card-c: 0.115; }
.publication-card { --card-h: var(--h-green);  --card-l: 0.74; --card-c: 0.115; }
.timeline-content { --card-h: var(--h-gold);   --card-l: 0.86; --card-c: 0.095; }
.contact-tile     { --card-h: var(--h-violet); --card-l: 0.78; --card-c: 0.115; }
.link-section     { --card-h: var(--h-blue);   --card-l: 0.70; --card-c: 0.115; }

* {
  box-sizing: border-box;
}

/* Minimal reset. Reproduces the Bootstrap Reboot rules this design actually
   depended on, now that Bootstrap is no longer loaded. Values match Reboot's
   rather than being zeroed, so the cascade below behaves exactly as before. */
body {
  margin: 0;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  margin-top: 0;
  margin-bottom: 0.5rem;
  font-weight: 500;
  line-height: 1.2;
}

p {
  margin-top: 0;
  margin-bottom: 1rem;
}

figure,
blockquote,
dl,
dd {
  margin: 0;
}

ul,
ol {
  margin-top: 0;
  margin-bottom: 1rem;
  padding-left: 2rem;
}

button,
input,
select,
textarea {
  font: inherit;
  color: inherit;
  margin: 0;
}

button {
  background: none;
  border: 0;
  line-height: 1.5;
  cursor: pointer;
}

img,
svg,
video,
canvas {
  vertical-align: middle;
  max-width: 100%;
}

a {
  color: inherit;
}

table {
  border-collapse: collapse;
}

b,
strong {
  font-weight: bolder;
}

html {
  scroll-padding-top: 30px;
  /* The ground belongs on the root element, not only on the fixed layers that
     paint over it. Measured before this: html and body both computed
     rgba(0, 0, 0, 0), so the entire dark page was those layers — and anything
     outside them (rubber-band overscroll on iOS, the area beyond a short page)
     fell through to the browser's own canvas. */
  background-color: var(--ink-void);
}

body {
  position: relative;
  min-height: 100svh;
  margin: 0;
  color: var(--text);
  background:
    radial-gradient(circle at 75% 8%, oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.26), transparent 34rem),
    radial-gradient(circle at 12% 24%, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.19), transparent 32rem),
    linear-gradient(135deg, var(--bg) 0%, #060a13 42%, #090e1c 100%);
  font-family: var(--font-text);
  font-optical-sizing: auto;
  line-height: 1.65;
  overflow-x: hidden;
  overflow-x: clip;
}

body::before {
  position: fixed;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  content: "";
  background-image:
    radial-gradient(circle at 22% 18%, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.08), transparent 26rem),
    radial-gradient(circle at 78% 36%, oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.08), transparent 30rem),
    linear-gradient(rgba(255, 255, 255, 0.032) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.032) 1px, transparent 1px);
  background-size: auto, auto, 86px 86px, 86px 86px;
  mask-image: radial-gradient(circle at center, black, transparent 78%);
  animation: gridDrift 42s linear infinite;
}

.cosmic-image-bg {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: url("../../images/cosmic-bg.webp") center / cover no-repeat;
  opacity: 0.9;
  filter: brightness(1.35) contrast(1.15) saturate(1.12);
  mix-blend-mode: screen;
}

.cosmic-image-shade {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    radial-gradient(circle at 50% 34%, rgba(2, 3, 4, 0.22), rgba(2, 3, 4, 0.76) 58%, rgba(2, 3, 4, 0.94) 100%),
    linear-gradient(90deg, rgba(2, 3, 4, 0.82), rgba(2, 3, 4, 0.48), rgba(2, 3, 4, 0.82)),
    linear-gradient(180deg, rgba(2, 3, 4, 0.6), rgba(2, 3, 4, 0.88));
}

body::after {
  position: fixed;
  inset: -14%;
  z-index: 4;
  pointer-events: none;
  content: "";
  background:
    radial-gradient(ellipse at 25% 32%, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.07), transparent 34rem),
    radial-gradient(ellipse at 74% 18%, oklch(var(--l-text) var(--cmax-lya) var(--h-lya) / 0.055), transparent 30rem),
    radial-gradient(ellipse at 62% 74%, oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.055), transparent 36rem);
  filter: blur(18px);
  opacity: 0.9;
  transform: translate3d(calc(var(--parallax-x, 0) * -0.35px), calc(var(--parallax-y, 0) * -0.35px), 0);
  animation: nebulaDrift 34s ease-in-out infinite alternate;
}

a {
  color: var(--accent);
  text-decoration: none;
  transition: color 180ms ease, background 180ms ease, border-color 180ms ease, transform 180ms ease;
}

a:hover {
  color: var(--accent-3);
}

a:focus-visible,
button:focus-visible {
  outline: 3px solid oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.7);
  outline-offset: 4px;
}

h1,
h2,
h3,
h4 {
  margin: 0;
  color: var(--text);
  line-height: 1.04;
  letter-spacing: 0;
}

p {
  color: var(--muted);
}

img {
  max-width: 100%;
  height: auto;
}

.skip-link {
  position: fixed;
  top: 12px;
  left: 12px;
  z-index: 2000;
  padding: 10px 14px;
  color: #06121f;
  background: var(--accent);
  border-radius: 8px;
  transform: translateY(-140%);
}

.skip-link:focus {
  transform: translateY(0);
}

#galaxy-canvas {
  position: fixed;
  inset: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0.94;
  mix-blend-mode: screen;
}

/* THE NIGHT SKY. cosmos.js draws a new sky on every visit (Milky Way band,
   dust lanes, nebulae, black-body-coloured stars) and inserts this canvas
   right after the photograph. It sits under the contrast shade exactly where
   the photograph did; the photograph stays as the no-script fallback and
   fades out once the drawn sky is ready. The pointer parallax reuses the
   --parallax-* properties snapfolio-astro.js already writes. */
#sky-canvas {
  position: fixed;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  mix-blend-mode: screen;
  opacity: 0;
  filter: brightness(1.3) contrast(1.1);
  transform: translate3d(calc(var(--parallax-x, 0) * -0.55px), calc(var(--parallax-y, 0) * -0.55px), 0) scale(1.03);
  transition: opacity 1200ms var(--ease-out);
}

html.sky-ready #sky-canvas { opacity: 1; }
html.sky-ready .cosmic-image-bg { opacity: 0; transition: opacity 1200ms var(--ease-out); }

/* THE WARP. warp.js inserts this just under the merger. It is empty at rest
   and draws only while the page is being scrolled, so it needs no fade of its
   own; reduced motion never creates it. */
#warp-canvas {
  position: fixed;
  inset: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  pointer-events: none;
  mix-blend-mode: screen;
}

.cosmic-vignette {
  position: fixed;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  background:
    radial-gradient(circle at var(--cursor-x, 50%) var(--cursor-y, 45%), rgba(255, 255, 255, 0.065), transparent 3.8rem),
    radial-gradient(circle at var(--cursor-x, 50%) var(--cursor-y, 45%), oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.12), transparent 15rem),
    repeating-radial-gradient(circle at var(--cursor-x, 50%) var(--cursor-y, 45%), oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.018) 0 1px, transparent 1px 118px),
    radial-gradient(ellipse at 65% 18%, oklch(var(--l-text) var(--cmax-lya) var(--h-lya) / 0.08), transparent 34rem),
    linear-gradient(180deg, transparent, rgba(2, 3, 4, 0.68));
}

@keyframes gridDrift {
  from {
    background-position: 0 0, 0 0, 0 0, 0 0;
  }

  to {
    background-position: 0 0, 0 0, 86px 86px, 86px 86px;
  }
}

@keyframes nebulaDrift {
  from {
    opacity: 0.72;
    transform: translate3d(calc(var(--parallax-x, 0) * -0.35px), calc(var(--parallax-y, 0) * -0.35px), 0) rotate(-1deg) scale(1);
  }

  to {
    opacity: 0.95;
    transform: translate3d(calc(var(--parallax-x, 0) * -0.55px), calc(var(--parallax-y, 0) * -0.55px), 0) rotate(1deg) scale(1.04);
  }
}

.site-header {
  position: fixed;
  inset: 0 auto 0 0;
  z-index: 997;
  display: flex;
  align-items: center;
  width: var(--header-width);
  padding: 24px 18px;
}

.header-container {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  max-height: calc(100vh - 48px);
  padding: 18px;
  overflow-y: auto;
  background: rgba(5, 7, 13, 0.78);
  border: 1px solid var(--border);
  border-radius: 18px;
  box-shadow: var(--shadow);
  backdrop-filter: blur(24px);
  -webkit-backdrop-filter: blur(24px);
  transform-origin: center center;
  transition:
    transform 900ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 620ms ease,
    background 260ms ease,
    border-color 260ms ease,
    box-shadow 260ms ease;
  will-change: transform, opacity;
}

html.intro-hold .header-container,
html.intro-moving .header-container {
  background: rgba(5, 7, 13, 0.9);
  border-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.24);
  box-shadow: 0 34px 120px rgba(0, 0, 0, 0.62), 0 0 70px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.12);
}

html.intro-hold .header-container {
  /* 1.04, not 1.12. With the scrim up the slab is the only lit thing on screen,
     and a slab blown up by an eighth over a black field reads as a splash
     screen. The small scale reads as depth instead. */
  transform: translate3d(calc(50vw - (var(--header-width) / 2)), 0, 0) scale(1.04);
}

html.intro-moving .header-container,
html.intro-complete .header-container {
  transform: translate3d(0, 0, 0) scale(1);
}

html.intro-skipped .header-container {
  transition-duration: 560ms;
}

/* ── the arrival scrim ───────────────────────────────────────────────────
   This is what makes the intro an entrance rather than an exit. Before it, the
   page sat fully painted and fully lit while a dark slab slid off it: the slab
   and the page had no relationship, and the move read as something leaving
   rather than something arriving.

   Now the field is held back and resolves as the slab travels, so the one
   gesture does both jobs. It is a flat tone lift, not a backdrop blur: blur is
   the device this page already overuses (57 blurred surfaces measured) and the
   one most likely to read as generated.

   Cost, stated honestly: this does hold the page back about 1.4s on a first
   visit, which the previous version deliberately did not. Any key, click,
   wheel or touch skips it, prefers-reduced-motion never arms it, and the page's
   own motion toggle never arms it. */
#intro-scrim {
  position: fixed;
  inset: 0;
  z-index: 990;        /* over .main (5) and .deep-grain (6);
                          under .scroll-top (996) and the slab itself (997) */
  background-color: var(--ink-void);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  /* The transition lives HERE, on the resting state, not on the moving state.
     With it declared only under .intro-moving, the scrim lost its transition
     the instant .intro-complete replaced that class — and since the completion
     timer can land before the fade has finished, the scrim snapped from full
     to nothing. Measured: opacity 1.00 at 900ms, 0.00 at 1200ms, with no
     intermediate frame. On the resting rule every state change animates,
     including the one that ends the intro.
     visibility is delayed past the fade so it cannot clip it. */
  transition:
    opacity 860ms cubic-bezier(0.22, 1, 0.36, 1) 90ms,
    visibility 0s linear 950ms;
}
html.intro-hold #intro-scrim {
  opacity: 1;
  visibility: visible;
  transition: none;    /* the first frame must already be covered */
}
html.intro-moving #intro-scrim {
  opacity: 0;
  visibility: visible;
}
html.intro-skipped #intro-scrim {
  transition:
    opacity 280ms ease,
    visibility 0s linear 280ms;
}

/* The intro does not touch page CONTENT: no element is transformed or hidden,
   so first contentful paint and LCP are never delayed by a layout the script
   has to undo. The scrim covers; it does not move anything. */

.brand-lockup {
  display: grid;
  grid-template-columns: 48px 1fr;
  gap: 12px;
  align-items: center;
  margin-bottom: 24px;
  color: var(--text);
  transition: transform 620ms cubic-bezier(0.19, 1, 0.22, 1), margin 620ms cubic-bezier(0.19, 1, 0.22, 1);
}

.brand-lockup:hover {
  color: var(--text);
}

.brand-mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  color: #041019;
  font-weight: 850;
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  border-radius: 14px;
}

.brand-mark.image-mark {
  position: relative;
  overflow: hidden;
  padding: 2px;
  background:
    linear-gradient(135deg, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.75), oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.7)),
    rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.22);
  box-shadow: 0 0 28px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.22);
}

.brand-mark.image-mark::after {
  position: absolute;
  inset: -32%;
  pointer-events: none;
  content: "";
  background: linear-gradient(115deg, transparent 24%, rgba(255, 255, 255, 0.22), transparent 62%);
  transform: translateX(-80%) rotate(12deg);
  transition: transform 520ms ease;
}

.brand-lockup:hover .brand-mark.image-mark::after {
  transform: translateX(72%) rotate(12deg);
}

.brand-mark.image-mark img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 12px;
}

.brand-lockup strong,
.brand-lockup small {
  display: block;
}

.brand-lockup small {
  color: var(--muted);
  font-size: 0.82rem;
}

.navmenu ul {
  padding: 0;
  margin: 0;
  list-style: none;
}

.navmenu,
.social-links {
  transition: opacity 360ms ease, transform 360ms ease, max-height 520ms ease, margin 360ms ease, padding 360ms ease;
}

.navmenu a {
  display: flex;
  gap: 12px;
  align-items: center;
  min-height: 44px;
  padding: 10px 12px;
  margin: 4px 0;
  color: rgba(248, 250, 252, 0.72);
  border: 1px solid transparent;
  border-radius: 12px;
}

.navmenu a:hover,
.navmenu a.active {
  color: var(--text);
  background: var(--surface-strong);
  border-color: var(--border);
}

.navicon {
  width: 20px;
  color: var(--accent);
}

.social-links {
  display: flex;
  gap: 8px;
  padding-top: 18px;
  margin-top: auto;
}

.social-links a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 50%;
}

.social-links a:hover {
  color: #041019;
  background: var(--accent);
  border-color: var(--accent);
  transform: translateY(-2px);
}

.header-toggle {
  position: fixed;
  top: 18px;
  right: 18px;
  z-index: 998;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 46px;
  height: 46px;
  color: var(--text);
  background: rgba(5, 7, 13, 0.86);
  border: 1px solid var(--border);
  border-radius: 50%;
  box-shadow: var(--shadow);
  backdrop-filter: blur(20px);
}

/* Was Bootstrap's .d-xl-none. The toggle only exists below 1200px, where the
   header collapses to a drawer; at 1200px and up the sidebar is permanent. */
@media (min-width: 1200px) {
  .header-toggle {
    display: none;
  }
}

.main,
.site-footer {
  position: relative;
  z-index: 5;
  margin-left: var(--header-width);
  transition: opacity 520ms ease, filter 520ms ease, transform 520ms ease;
}

/* ── the slim rail (1200–1499px) ─────────────────────────────────────────
   On a 1280 or 1440 laptop the 310px sidebar took a quarter of the screen.
   In this band it rests as a 76px icon rail and opens to the full sidebar on
   hover or keyboard focus, laid OVER the page: main keeps the slim margin, so
   opening it never reflows content or the pinned research rail.
   Every state difference is a custom property on .site-header, so each open
   trigger is one small rule (a :has() selector would otherwise invalidate the
   whole list in a browser without it). The labels stay in the DOM and are
   only clipped to zero width, so links and buttons keep their accessible
   names; the open-on-hover rail is the tooltip.
   Transition delays come from the DESTINATION state: opening waits a beat so
   a pointer crossing to the page does not pop it, closing waits a little
   longer so a brief slip off the edge does not snap it shut. */
@media (min-width: 1200px) and (max-width: 1499px) {
  :root { --rail-width: 100px; }   /* 76px slab + 12px gutter either side */

  .site-header {
    --rail-w: var(--rail-width);
    --rail-lbl-o: 0;
    --rail-lbl-w: 0px;
    --rail-lbl-delay: 0ms;
    --rail-w-delay: 140ms;
    --rail-gap: 0px;
    --rail-tog-w: 36px;
    --rail-tog-pad: 0px;
    --rail-soc-dir: column;
    width: var(--rail-w);
    padding: 12px;
    transition: width 320ms cubic-bezier(0.22, 1, 0.36, 1) var(--rail-w-delay);
  }

  /* Open. The intro states open it too: the arrival slab is the full panel,
     and it settles into the rail when the intro completes. */
  .site-header:hover,
  html.intro-hold .site-header,
  html.intro-moving .site-header {
    --rail-w: var(--header-width);
    --rail-lbl-o: 1;
    --rail-lbl-w: 14rem;
    --rail-lbl-delay: 110ms;
    --rail-w-delay: 70ms;
    --rail-gap: 12px;
    --rail-tog-w: auto;
    --rail-tog-pad: 0.65rem;
    --rail-soc-dir: row;
  }
  /* Keyboard only: a mouse click leaves focus on the link, and :focus-within
     would hold the panel open over the page after the pointer has gone. */
  .site-header:has(:focus-visible) {
    --rail-w: var(--header-width);
    --rail-lbl-o: 1;
    --rail-lbl-w: 14rem;
    --rail-lbl-delay: 110ms;
    --rail-w-delay: 0ms;
    --rail-gap: 12px;
    --rail-tog-w: auto;
    --rail-tog-pad: 0.65rem;
    --rail-soc-dir: row;
  }
  @supports not selector(:has(*)) {
    .site-header:focus-within {
      --rail-w: var(--header-width);
      --rail-lbl-o: 1;
      --rail-lbl-w: 14rem;
      --rail-gap: 12px;
      --rail-tog-w: auto;
      --rail-tog-pad: 0.65rem;
      --rail-soc-dir: row;
    }
  }

  /* Full height and top-anchored, so opening never moves an icon vertically:
     the social row sits at the bottom whichever way it is laid out. */
  .header-container {
    height: calc(100vh - 24px);
    max-height: none;
    padding: 12px;
    overflow-x: hidden;
    scrollbar-width: none;
  }
  .header-container::-webkit-scrollbar { display: none; }
  /* A fixed-height column shrinks overflow:hidden children first, and the
     avatar was the one it chose. Nothing in the rail may shrink. */
  .header-container > * { flex-shrink: 0; }

  .brand-lockup {
    grid-template-columns: 48px minmax(0, 1fr);
    gap: var(--rail-gap);
    margin-bottom: 10px;
    overflow: hidden;
    transition: gap 320ms cubic-bezier(0.22, 1, 0.36, 1) var(--rail-w-delay);
  }

  /* Icon centres line up on one axis (25px in from the slab edge) in both
     states, so the labels unfold beside icons that stay put. */
  .navmenu a {
    gap: var(--rail-gap);
    min-height: clamp(34px, 5vh, 44px);
    padding: 4px 14px;
    margin: 2px 0;
    overflow: hidden;
    transition: gap 320ms cubic-bezier(0.22, 1, 0.36, 1) var(--rail-w-delay);
  }
  .navicon { flex-shrink: 0; text-align: center; }

  .brand-lockup > span:last-child,
  .navmenu a span,
  .header-container .motion-toggle span {
    max-width: var(--rail-lbl-w);
    overflow: hidden;
    white-space: nowrap;
    opacity: var(--rail-lbl-o);
    transition: opacity 200ms ease var(--rail-lbl-delay);
  }

  .header-container .motion-toggle-row { margin: 6px 0 0 6px; }
  .header-container .motion-toggle {
    justify-content: center;
    width: var(--rail-tog-w);
    min-height: 36px;
    padding: 0 var(--rail-tog-pad);
    gap: var(--rail-gap);
    white-space: nowrap;
  }
  .header-container .motion-toggle i { width: 14px; flex-shrink: 0; text-align: center; }

  .social-links {
    flex-direction: var(--rail-soc-dir);
    gap: 4px;
    padding: 12px 0 0 9px;
  }
  .social-links a {
    flex-shrink: 0;
    width: 32px;
    height: 32px;
  }

  .main,
  .site-footer {
    margin-left: var(--rail-width);
  }
}

@media (min-width: 1200px) and (max-width: 1499px) and (prefers-reduced-motion: reduce) {
  .site-header,
  .brand-lockup,
  .navmenu a,
  .brand-lockup > span:last-child,
  .navmenu a span,
  .header-container .motion-toggle span { transition: none !important; }
}
@media (min-width: 1200px) and (max-width: 1499px) {
  html.motion-off .site-header,
  html.motion-off .brand-lockup,
  html.motion-off .navmenu a,
  html.motion-off .brand-lockup > span:last-child,
  html.motion-off .navmenu a span,
  html.motion-off .header-container .motion-toggle span { transition: none !important; }
}

.section {
  position: relative;
  padding: 104px 0;
}

/* Previously only overrode max-width; width, the 12px gutters and the auto
   margins came from Bootstrap's .container. Spelled out in full now. */
.container {
  width: 100%;
  max-width: 1160px;
  padding-right: 12px;
  padding-left: 12px;
  margin-right: auto;
  margin-left: auto;
}

.section-heading {
  max-width: 860px;
  margin-bottom: 36px;
}

.section-heading h2 {
  max-width: 780px;
  margin-bottom: 16px;
  font-size: clamp(2.2rem, 4.4vw, 4.7rem);
}

.section-heading p:last-child {
  max-width: 760px;
  margin-bottom: 0;
  font-size: 1.06rem;
}

/* .eyebrow is deleted, markup and rule together. It put a tiny uppercase
   letter-spaced label in the accent colour above every heading, introduced by
   a 1px gradient rule — four separate generated-page tells stacked in one
   element, repeated nine times. Every instance also said what the heading
   underneath it already said ("About" over "Academic background and current
   focus."), so nothing was lost by removing them. */

.eyebrow a,
.hero-subtitle a,
.about-text a,
.personal-copy a,
.link-section a {
  color: #93e5ff;
  text-decoration: underline;
  text-decoration-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.45);
  text-underline-offset: 4px;
}

.eyebrow a:hover,
.hero-subtitle a:hover,
.about-text a:hover,
.personal-copy a:hover,
.link-section a:hover {
  color: var(--accent-3);
  text-decoration-color: oklch(var(--l-text) var(--cmax-lya) var(--h-lya) / 0.7);
}

/* Links inside the research and earlier-research cards (and the talk venues
   and footer credit) were told apart from the body text by colour alone:
   1.3:1 against it in dark, 1.6:1 in light, where WCAG 1.4.1 asks for 3:1 or
   a second cue. They take the about text's underline; their own colour
   stays. --l-text carries it into light mode. */
#research p a:not([class]),
#past-projects p a:not([class]),
.cv-venue a:not([class]),
.site-footer p a:not([class]) {
  text-decoration: underline;
  text-decoration-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.45);
  text-underline-offset: 4px;
}

.hero {
  display: flex;
  align-items: center;
  min-height: 100svh;
  padding: 92px 0;
  overflow: hidden;
}

.hero::before,
.hero::after {
  position: absolute;
  pointer-events: none;
  content: "";
  border: 1px solid oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.12);
  border-radius: 50%;
  transform: translate3d(calc(var(--parallax-x, 0) * 0.65px), calc(var(--parallax-y, 0) * 0.65px), 0) rotate(-14deg);
}

.hero::before {
  top: 11%;
  right: -8%;
  width: min(58vw, 720px);
  aspect-ratio: 1.9;
  box-shadow: 0 0 90px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.07), inset 0 0 70px oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.045);
}

.hero::after {
  top: 17%;
  right: 5%;
  width: min(42vw, 520px);
  aspect-ratio: 1.8;
  border-color: oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.12);
  transform: translate3d(calc(var(--parallax-x, 0) * 0.45px), calc(var(--parallax-y, 0) * 0.45px), 0) rotate(-19deg);
}

.hero-nebula {
  position: absolute;
  inset: 2% 0 auto auto;
  z-index: -1;
  width: min(70vw, 900px);
  aspect-ratio: 1.25;
  background:
    radial-gradient(ellipse at 54% 48%, oklch(var(--l-text) var(--cmax-lya) var(--h-lya) / 0.26), transparent 0 12%, transparent 24%),
    radial-gradient(ellipse at 46% 52%, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.28), transparent 0 28%, transparent 58%),
    radial-gradient(ellipse at 70% 32%, oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.32), transparent 0 26%, transparent 62%);
  filter: blur(22px);
  opacity: 0.9;
  transform: translate3d(calc(var(--parallax-x, 0) * -0.9px), calc(var(--parallax-y, 0) * -0.9px), 0) rotate(-10deg);
  animation: heroNebulaPulse 12s ease-in-out infinite alternate;
}

@keyframes heroNebulaPulse {
  from {
    filter: blur(22px) saturate(1);
    opacity: 0.74;
  }

  to {
    filter: blur(28px) saturate(1.2);
    opacity: 0.98;
  }
}

.hero-shell {
  display: grid;
  grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
  gap: 46px;
  align-items: center;
}

.hero-copy,
.about-text,
.project-body,
.publication-card,
.timeline-content,
.contact-panel,
.contact-tile {
  min-width: 0;
}

.hero h1 {
  max-width: 820px;
  margin-bottom: 18px;
  font-size: clamp(4rem, 10vw, 8rem);
  font-weight: 900;
}

.hero-role {
  margin-bottom: 18px;
  /* Was --accent-3 (magenta) at up to 34px, which put a third accent hue on the
     first screen next to the cyan actions and the portrait's rim, and wrapped
     to two lines at 1440. It is a subtitle, so it is set as one. */
  color: var(--ink-text-2);
  font-size: clamp(1.05rem, 1.5vw, 1.35rem);
  font-weight: 500;
  letter-spacing: 0.01em;
}

.hero-subtitle {
  max-width: 760px;
  margin-bottom: 28px;
  color: #d6deea;
  font-size: clamp(1.02rem, 1.45vw, 1.28rem);
}

.hero-actions,
.hero-links,
.tag-cloud,
.publication-actions {
  display: flex;
  flex-wrap: wrap;
}

.hero-actions {
  gap: 12px;
  margin-bottom: 24px;
}

.btn {
  line-height: 1.5;
  position: relative;
  display: inline-flex;
  gap: 8px;
  align-items: center;
  justify-content: center;
  min-height: 48px;
  padding: 12px 18px;
  overflow: hidden;
  font-weight: 800;
  border-radius: 999px;
  transition: transform 220ms ease, box-shadow 220ms ease, border-color 220ms ease, background 220ms ease;
}

/* The shine sweep is deleted. A white gradient travelling across a button on
   hover is pure template ornament: it says nothing about the control and
   appears on roughly every generated landing page. The lift and the tone step
   are the feedback. */
.btn i,
.btn span {
  position: relative;
  z-index: 1;
}

.btn-primary {
  color: #041019;
  background: var(--accent);
  border: 0;
}

.btn-primary:hover {
  color: #041019;
  transform: translateY(-2px);
  /* A tone step, not a glow. The old rule stacked a 135deg gradient fill, a
     44px cyan drop glow and a second violet glow on hover — three ornaments on
     the one element a reader is most likely to look at. */
  background: color-mix(in oklab, var(--accent) 86%, white);
}

.btn-outline {
  color: var(--text);
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid var(--border);
}

.btn-outline:hover {
  color: var(--text);
  background: var(--surface-strong);
  border-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.46);
  box-shadow: 0 18px 44px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.14);
}

/* Each link carries its service's icon on a small glass chip, so the row reads
   as four destinations at a glance instead of a line of underlined words. */
.hero-links {
  gap: 10px;
}

.hero-links a {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 40px;
  padding: 8px 14px 8px 12px;
  color: #e4ecf7;
  font-size: 0.95rem;
  font-weight: 600;
  line-height: 1.2;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.025));
  border: 1px solid rgba(255, 255, 255, 0.13);
  border-radius: 999px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
  -webkit-backdrop-filter: blur(12px) saturate(140%);
  backdrop-filter: blur(12px) saturate(140%);
  overflow-wrap: anywhere;
  transition: transform 200ms ease, background 200ms ease, border-color 200ms ease, color 200ms ease;
}

.hero-links a i {
  flex: 0 0 auto;
  font-size: 1.05rem;
  color: var(--accent);
}

.hero-links a:hover,
.hero-links a:focus-visible {
  color: #fff;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05));
  border-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.5);
  transform: translateY(-2px);
}

/* SKY HINT. cosmos.js adds this once per browser, on desktop, while the merger
   is running, to say the sky is clickable. It sits in the hero's bottom
   padding, which the centred copy never reaches, and cosmos.js drops it if
   the copy would overlap anyway. Small glass, like the hero link chips. */
.sky-hint {
  position: absolute;
  bottom: 28px;
  left: 50%;
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 6px 6px 16px;
  white-space: nowrap;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 999px;
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  opacity: 0;
  transform: translate(-50%, 6px);
  transition: opacity 600ms ease, transform 600ms ease;
}

.sky-hint.is-shown {
  opacity: 1;
  transform: translate(-50%, 0);
}

.sky-hint p {
  margin: 0;
  color: var(--ink-muted);
  font-size: 0.82rem;
}

.sky-hint-close {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  color: var(--ink-caption);
  font-size: 1rem;
  line-height: 1;
  background: none;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
}

.sky-hint-close:hover {
  color: var(--ink-text);
  background: rgba(255, 255, 255, 0.08);
}

/* News strip: a dated list under the links, so a visitor sees the page is
   current. Set as small type with a hairline above, not as another card;
   the date column is fixed so the lines align and wrap under the text. */
.hero-news {
  max-width: 640px;
  margin-top: 22px;
  padding-top: 14px;
  border-top: 1px solid var(--ink-hairline);
}

.hero-news-title {
  margin: 0 0 8px;
  color: var(--ink-caption);
  font-family: var(--font-mono);
  font-size: 0.74rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.hero-news-list {
  display: grid;
  gap: 5px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.hero-news-list li {
  display: grid;
  grid-template-columns: 5.6em minmax(0, 1fr);
  column-gap: 12px;
  color: var(--ink-muted);
  font-size: 0.9rem;
  line-height: 1.45;
}

.hero-news-list time {
  color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.8);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  line-height: 1.8;
  white-space: nowrap;
}

.hero-news-list a {
  color: var(--ink-text-2);
  text-decoration: underline;
  text-decoration-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.45);
  text-underline-offset: 3px;
}

.hero-news-list a:hover,
.hero-news-list a:focus-visible {
  color: var(--accent);
}


.hero-portrait {
  position: relative;
  isolation: isolate;
  justify-self: center;
  width: min(100%, 420px);
  margin: 0;
  padding: 14px;
  overflow: hidden;
  background:
    conic-gradient(from 130deg, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.72), oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.58), oklch(var(--l-text) var(--cmax-lya) var(--h-lya) / 0.46), oklch(var(--l-text) var(--cmax-gold) var(--h-gold) / 0.32), oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.72)),
    linear-gradient(145deg, rgba(255, 255, 255, 0.2), transparent 28%),
    linear-gradient(145deg, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.2), oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.14), rgba(255, 255, 255, 0.05));
  background-size: 220% 220%, auto, auto;
  border: 1px solid var(--glass-border);
  border-radius: 34px;
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-highlight);
  transform: perspective(1000px) rotateX(var(--tilt-y, 0deg)) rotateY(var(--tilt-x, 0deg)) translate3d(calc(var(--parallax-x, 0) * 0.4px), calc(var(--parallax-y, 0) * 0.4px), 0);
  transition: transform 500ms cubic-bezier(0.19, 1, 0.22, 1), border-color 260ms ease, box-shadow 260ms ease, filter 260ms ease;
  animation: none;
}

.hero-portrait:hover {
  border-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.32);
  box-shadow:
    var(--shadow),
    0 0 42px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.18),
    0 0 82px oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.12),
    inset 0 1px 0 var(--glass-highlight);
  filter: saturate(1.04);
  transform: perspective(1000px) rotateX(var(--tilt-y, 0deg)) rotateY(var(--tilt-x, 0deg)) translate3d(calc(var(--parallax-x, 0) * 0.4px), calc(var(--parallax-y, 0) * 0.4px - 8px), 0) scale(1.015);
}

.hero-portrait::before {
  position: absolute;
  inset: 1px;
  z-index: 2;
  pointer-events: none;
  content: "";
  background:
    radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.16), transparent 0 1.5px, transparent 2.5px),
    radial-gradient(circle at 48% calc(var(--glow-y, 20%) + 16%), oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.17), transparent 0 1.5px, transparent 2.5px),
    linear-gradient(130deg, rgba(255, 255, 255, 0.16), transparent 22%, transparent 74%, oklch(var(--l-text) var(--cmax-lya) var(--h-lya) / 0.1));
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 30px;
  opacity: 0.62;
  mix-blend-mode: screen;
  transition: opacity 260ms ease;
}

.hero-portrait:hover::before {
  opacity: 0.86;
}

.portrait-halo {
  position: absolute;
  inset: -34px;
  z-index: 0;
  background: conic-gradient(from 90deg, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.18), oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.28), oklch(var(--l-text) var(--cmax-lya) var(--h-lya) / 0.16), oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.18));
  border-radius: 42px;
  filter: blur(20px);
  opacity: 0.8;
  transform: translate3d(calc(var(--parallax-x, 0) * -0.7px), calc(var(--parallax-y, 0) * -0.7px), 0);
}

.hero-portrait img {
  position: relative;
  z-index: 1;
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  object-position: center center;
  border-radius: 24px;
}

.hero-portrait::after {
  position: absolute;
  inset: 14px;
  z-index: 3;
  pointer-events: none;
  content: "";
  background:
    radial-gradient(circle at 24% 22%, rgba(255, 255, 255, 0.18), transparent 0 1px, transparent 2px),
    radial-gradient(circle at 72% 28%, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.2), transparent 0 1px, transparent 2px),
    linear-gradient(115deg, rgba(255, 255, 255, 0.08), transparent 32%, transparent 70%, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.06));
  border-radius: 24px;
  opacity: 0.68;
  mix-blend-mode: screen;
}

@keyframes portraitRim {
  from {
    background-position: 0% 50%;
  }

  to {
    background-position: 100% 50%;
  }
}

.card-kicker {
  display: block;
  margin-bottom: 0.35rem;
  color: var(--muted);
  font-size: 0.95rem;
  font-weight: 500;
  letter-spacing: 0;
}

/* ═══════════════════════════════════════════ A DECISION, NOT AN OVERSIGHT
   The glass recipe below fails three of the taste skill's house rules —
   gradient, shadow and glass — and it stays anyway. Tanmay was shown the
   measurement (29 blurred surfaces, 26 shadows at rest, 36 gradients, nearly
   every remaining failure on the page) together with the argument for removing
   it, and chose to keep it: stripping the recipe is what turns a dense page
   into a thin one, and that trade has already been made once on this site and
   reverted.

   So the gate is run as

       node <skill>/scripts/inspect.mjs index.html --allow gradient,shadow,glass

   which keeps every other rule live. Do not silence those three any other way,
   and do not quietly delete this recipe to make a report go green — the report
   is green on them because someone decided, and the decision is recorded here
   so the next reader can disagree with it on purpose rather than by accident.

   What this costs, stated plainly: glass is currently the single strongest
   signal that a page was generated rather than designed. Removing it is the
   largest remaining move available on this page, and it is available whenever
   he wants it. */

/* ─────────────────────────────── the glass recipe
   saturate() on the BACKDROP is the whole trick. On a near-black page a plain
   blur averages the backdrop toward black, so the panel reads grey and dead —
   which is exactly why the previous frosted glass looked cheap. Saturate pulls
   the nebula's chroma *through* the glass, so the panel looks lit from behind,
   which is physically what glass over a bright nebula does.

   Blur drops 22px -> 16px. Separable-Gaussian cost scales with radius x area,
   so this is ~27% less blur work on every glass element — and 16px WITH
   saturate reads richer than 22px without. The better recipe is the cheaper
   one. Skia folds consecutive colour filters into one 5x4 matrix, so the three
   added colour ops are effectively free against blur's texture reads.

   brightness capped at 1.12, not 1.20: backdrop-filter never samples outside
   the viewport, so a card crossing the top or bottom edge shows a clamped
   band, and 1.20 makes it visible during a slow scroll. */.ovi-panel,
.about-panel,
.contact-panel,
.research-card,
.project-card,
.publication-card,
.timeline-content,
.teaching-panel,
.personal-panel,
.link-section,
.contact-tile,
.fact-grid div,
.glass-panel,
.cosmic-card,
.education-card,
.personal-card,
.link-card,
.hover-lift {
  position: relative;
  isolation: isolate;            /* strictly redundant — backdrop-filter already
                                    makes a stacking context — but load-bearing
                                    under prefers-reduced-transparency, which
                                    removes backdrop-filter and would otherwise
                                    let a negative-z child escape to .main */
  overflow: hidden;
  border: 1px solid transparent; /* NOT border: 0 — forced-colors drops
                                    backdrop-filter, box-shadow and pseudo
                                    backgrounds, so with no border all 17
                                    panels collapse into one wall of text.
                                    box-sizing is already global, so this
                                    causes no layout shift. */
  border-radius: 24px;
  background:
    radial-gradient(circle at var(--glow-x, 50%) var(--glow-y, 0%),
      color-mix(in oklab, var(--card-accent) 17%, transparent), transparent 42%),
    linear-gradient(180deg in oklab,
      color-mix(in oklab, var(--ink-panel) 76%, transparent),
      color-mix(in oklab, var(--ink-bg-2) 86%, transparent));
  /* 180deg reads as gravity. The 145deg diagonal it replaces reads as a
     graphic — a lit edge that runs corner to corner is not a thing light does. */
  backdrop-filter: blur(16px) saturate(1.75) brightness(1.12) contrast(1.06);
  -webkit-backdrop-filter: blur(16px) saturate(1.75) brightness(1.12) contrast(1.06);
  box-shadow:
    inset 0 1px 0 0 rgba(255, 255, 255, 0.15),   /* top catch-light   ┐ together the */
    inset 0 -1px 0 0 rgba(0, 0, 0, 0.6),         /* bottom occlusion  ┘ strongest tell */
    0 0 0 1px rgba(255, 255, 255, 0.035),        /* hairline seat */
    0 20px 44px -16px rgba(0, 0, 0, 0.88),       /* tight contact shadow */
    0 70px 150px -70px color-mix(in oklab, var(--card-accent) 45%, black);
  transition:
    transform var(--dur-s) var(--ease-out),
    border-color var(--dur-xs) linear,
    box-shadow var(--dur-s) var(--ease-out),
    --glow-x 220ms cubic-bezier(0.22, 1, 0.36, 1),
    --glow-y 220ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* The bevel. A flat 1px uniform border is the second-biggest cheapness tell,
   because real edges catch light unevenly. border-image cannot do this (it
   ignores border-radius); a padded mask-composite pseudo-element can, and it
   respects border-radius: inherit. No markup added — ::before is already the
   wash, ::after was free. */
.ovi-panel::after,
.about-panel::after,
.contact-panel::after,
.research-card::after,
.project-card::after,
.publication-card::after,
.timeline-content::after,
.teaching-panel::after,
.personal-panel::after,
.link-section::after,
.contact-tile::after,
.fact-grid div::after,
.glass-panel::after,
.cosmic-card::after,
.education-card::after,
.personal-card::after,
.link-card::after,
.hover-lift::after {
  content: "";
  position: absolute;
  inset: -1px;   /* not 0: inset resolves against the PADDING box, 1px
                    inside the transparent border, while border-radius:
                    inherit copies the full 24px — the ring read 1px too
                    round at each corner */
  border-radius: inherit;
  padding: 1px;
  pointer-events: none;          /* mandatory — these cards contain links */
  z-index: 1;
  background: linear-gradient(180deg in oklab,
    rgba(255, 255, 255, 0.58),
    color-mix(in oklab, var(--card-accent) 22%, transparent) 34%,
    rgba(255, 255, 255, 0.05) 60%,
    rgba(255, 255, 255, 0.05));
  /* The accent shoulder is held at 22%: with this many accents in play the
     shoulder stop is the actual tackiness vector. And the bottom stop is a
     dim cool bounce, not black — on #020304 a pure-black bottom edge makes
     the card vanish into the page and no shadow rescues it. */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;   /* -webkit keyword */
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask-composite: exclude;  /* standard keyword — NOT interchangeable */
  opacity: 0.82;
  transition: opacity var(--dur-s) var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .ovi-panel:hover::after,
  .about-panel:hover::after,
  .contact-panel:hover::after,
  .research-card:hover::after,
  .project-card:hover::after,
  .publication-card:hover::after,
  .timeline-content:hover::after,
  .teaching-panel:hover::after,
  .personal-panel:hover::after,
  .link-section:hover::after,
  .contact-tile:hover::after,
  .fact-grid div:hover::after,
  .glass-panel:hover::after,
  .cosmic-card:hover::after,
  .education-card:hover::after,
  .personal-card:hover::after,
  .link-card:hover::after,
  .hover-lift:hover::after {
    opacity: 1;   /* opacity, not padding — padding cannot composite and
                     re-rasterizes the mask */
  }
}

/* Focus affordance must NOT sit inside a hover query: a keyboard user on a
   tablet or a phone with a BT keyboard matches (hover: none) and would get
   no focus ring on any card. */
.ovi-panel:focus-within::after,
.about-panel:focus-within::after,
.contact-panel:focus-within::after,
.research-card:focus-within::after,
.project-card:focus-within::after,
.publication-card:focus-within::after,
.timeline-content:focus-within::after,
.teaching-panel:focus-within::after,
.personal-panel:focus-within::after,
.link-section:focus-within::after,
.contact-tile:focus-within::after,
.fact-grid div:focus-within::after,
.glass-panel:focus-within::after,
.cosmic-card:focus-within::after,
.education-card:focus-within::after,
.personal-card:focus-within::after,
.link-card:focus-within::after,
.hover-lift:focus-within::after {
  opacity: 1;
}

.ovi-panel::before,
.about-panel::before,
.contact-panel::before,
.research-card::before,
.project-card::before,
.publication-card::before,
.timeline-content::before,
.teaching-panel::before,
.personal-panel::before,
.link-section::before,
.contact-tile::before,
.fact-grid div::before,
.glass-panel::before,
.cosmic-card::before,
.education-card::before,
.personal-card::before,
.link-card::before,
.hover-lift::before {
  position: absolute;
  inset: 0;
  pointer-events: none;
  content: "";
  background:
    linear-gradient(120deg, rgba(255, 255, 255, 0.18), transparent 32%),
    radial-gradient(circle at var(--glow-x, 50%) var(--glow-y, 0%), color-mix(in oklab, var(--card-accent) 19%, rgba(255, 255, 255, 0.08)), transparent 35%);
  border-radius: inherit;
  opacity: 0.38;
  transition: opacity 240ms ease;
}

.ovi-panel:hover,
.about-panel:hover,
.contact-panel:hover,
.research-card:hover,
.project-card:hover,
.publication-card:hover,
.timeline-content:hover,
.teaching-panel:hover,
.personal-panel:hover,
.link-section:hover,
.contact-tile:hover,
.fact-grid div:hover,
.glass-panel:hover,
.cosmic-card:hover,
.education-card:hover,
.personal-card:hover,
.link-card:hover,
.hover-lift:hover {
  border-color: color-mix(in oklab, var(--card-accent) 44%, rgba(255, 255, 255, 0.16));
  box-shadow:
    0 30px 90px rgba(0, 0, 0, 0.58),
    0 0 42px color-mix(in oklab, var(--card-accent) 17%, transparent),
    0 0 70px oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.08),
    inset 0 1px 0 rgba(255, 255, 255, 0.22);
  transform: perspective(900px) rotateX(var(--tilt-y, 0deg)) rotateY(var(--tilt-x, 0deg)) translateY(-6px) scale(1.01);
}

.ovi-panel:hover::before,
.about-panel:hover::before,
.contact-panel:hover::before,
.research-card:hover::before,
.project-card:hover::before,
.publication-card:hover::before,
.timeline-content:hover::before,
.teaching-panel:hover::before,
.personal-panel:hover::before,
.link-section:hover::before,
.contact-tile:hover::before,
.fact-grid div:hover::before,
.glass-panel:hover::before,
.cosmic-card:hover::before,
.education-card:hover::before,
.personal-card:hover::before,
.link-card:hover::before,
.hover-lift:hover::before {
  opacity: 0.68;
}

.accent-cyan,
.research-card:nth-child(1),
.contact-tile:nth-child(1) {
}

.accent-violet,
.research-card:nth-child(2),
.publication-card,
.timeline-content {
}

.accent-blue,
.project-card:nth-child(2n),
.link-section:nth-child(2n) {
}

.accent-magenta,
.research-card:nth-child(3),
.personal-panel,
.contact-tile:nth-child(2) {
}

.accent-gold,
.project-card:nth-child(2n + 1),
.teaching-panel,
.fact-grid div:nth-child(2n) {
}

.about-panel {
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(300px, 0.95fr);
  gap: 28px;
  padding: clamp(24px, 4vw, 42px);
}

.about-text p:last-child {
  margin-bottom: 0;
}

.fact-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  margin: 0;
}

.fact-grid div {
  padding: 16px;
  border-radius: 14px;
}

.fact-grid dt {
  color: var(--muted);
  font-size: 0.84rem;
  font-weight: 850;
  text-transform: none;
}

.fact-grid dd {
  margin: 5px 0 0;
  color: var(--text);
  font-weight: 700;
}

.research-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 18px;
}

.research-card {
  min-height: 320px;
  padding: clamp(22px, 3vw, 34px);
}

.research-card.featured {
  grid-row: span 2;
  min-height: 660px;
}

.card-glow {
  position: absolute;
  inset: auto -20% -35% 18%;
  height: 300px;
  background: radial-gradient(circle, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.18), transparent 68%);
  pointer-events: none;
}

.research-card h3,
.project-card h3,
.publication-card h3,
.timeline-content h3,
.contact-panel h3 {
  margin: 10px 0 14px;
  font-size: clamp(1.25rem, 2vw, 2rem);
}

.tag-cloud {
  gap: 8px 9px;
  margin-top: 20px;
}

/* The explorer card is a doorway, not a description: a title, one line and
   its two buttons right under it. In the rail it is narrower than the
   project cards, so it reads as the end of the row. */
.research-card.explorer-card {
  min-height: 0;
}

.research-card.explorer-card .card-actions {
  padding-top: 6px;
}

/* Buttons on a card that open something you can use. */
.card-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 4px;
}

.card-button {
  display: inline-flex;
  gap: 8px;
  align-items: center;
  min-height: 44px;
  padding: 10px 18px;
  color: #041019;
  font-weight: 700;
  background: var(--accent);
  border: 1px solid transparent;
  border-radius: 999px;
  transition: transform 200ms ease, background 200ms ease, border-color 200ms ease;
}

.card-button:hover,
.card-button:focus-visible {
  color: #041019;
  background: color-mix(in oklab, var(--accent) 86%, white);
  transform: translateY(-2px);
}

.card-button.is-quiet {
  color: var(--text);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.025));
  border-color: rgba(255, 255, 255, 0.14);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
}

.card-button.is-quiet:hover,
.card-button.is-quiet:focus-visible {
  color: #fff;
  border-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.5);
}

/* READ MORE ON PHONES. card-read-more.js gathers a card's paragraphs into
   .card-more-text and sets html.cards-clamp; only then, and only below 641 px,
   is the text held to three lines. Without the script, and on every wider
   screen including the research rail, all of it shows and the button stays
   hidden. The fade is a mask, not a colour laid over the text, so it reads the
   same on the smoked dark card and the frosted light one. */
.card-more-toggle {
  display: none;
}

@media (max-width: 640px) {
  /* The 320 px floor was for the two-column grid; on a phone it would keep
     the card tall around three lines of text. */
  html.cards-clamp #research .research-card {
    min-height: 0;
  }

  html.cards-clamp .card-more-text:not(.is-open):not(.is-short) {
    max-height: 4.95em;   /* three lines at the body's 1.65 */
    max-height: 3lh;
    overflow: hidden;
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1lh), rgb(0 0 0 / 0.18));
    mask-image: linear-gradient(to bottom, #000 calc(100% - 1lh), rgb(0 0 0 / 0.18));
  }

  /* The last paragraph's margin would otherwise sit as dead space between the
     clamped text and its button. */
  html.cards-clamp .card-more-text:not(.is-open):not(.is-short) > p:last-child,
  html.cards-clamp .card-more-text.is-open > p:last-child {
    margin-bottom: 0;
  }

  html.cards-clamp .card-more-toggle:not([hidden]) {
    display: inline-flex;
    gap: 6px;
    align-items: center;
    min-height: 44px;
    margin: 0 0 -8px;
    padding: 6px 0;
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: 0.86rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    background: none;
    border: 0;
    cursor: pointer;
  }

  html.cards-clamp .card-more-toggle i {
    font-size: 0.8em;
    transition: transform 200ms ease;
  }

  html.cards-clamp .card-more-toggle[aria-expanded="true"] i {
    transform: rotate(180deg);
  }

  html.motion-off .card-more-toggle i {
    transition: none;
  }
}

@media (max-width: 640px) and (prefers-reduced-motion: reduce) {
  html.cards-clamp .card-more-toggle i {
    transition: none;
  }
}

/* These name real things — datasets, codes, instruments. The owner asked for
   them on glass, so each is a small frosted chip: a faint top-lit fill, a
   hairline rim and a blur of whatever sits behind the card. One colour for
   all of them; the old rule rotated the accent hue by nth-child, which made
   four tags look like four categories when they are one. */
.tag-cloud span {
  display: inline-flex;
  align-items: center;
  min-height: 30px;
  padding: 5px 12px;
  color: #e2e9f3;
  font-size: 0.86rem;
  font-weight: 600;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.03));
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 999px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  backdrop-filter: blur(10px) saturate(140%);
  transition: background 180ms ease, border-color 180ms ease, color 180ms ease;
}

.tag-cloud span:hover {
  color: #fff;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.05));
  border-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.45);
}

.project-mosaic {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 18px;
}

.project-card {
  grid-column: span 3;
}

.project-card.large {
  grid-column: span 6;
  display: grid;
  grid-template-columns: 0.9fr 1.1fr;
}

.project-body {
  padding: clamp(22px, 3vw, 32px);
}

/* FIGURES ON THE CARDS. A plot from one of the papers keeps its own white
   ground and sits on a white plate, the way it sits on the journal page; it is
   never recoloured to suit the dark page. A photograph runs to the card's
   edges. Either way the caption says what the picture shows and the credit
   says whose it is, and the picture opens full size in a new tab. */
.card-figure,
.project-figure {
  margin: 0;
}

.card-figure {
  margin-top: 22px;
}

.figure-media {
  display: block;
  overflow: hidden;
}

.figure-media img {
  display: block;
  width: 100%;
  height: auto;
}

.is-plot .figure-media {
  padding: clamp(8px, 1.2vw, 14px);
  background: #fff;
  border-radius: 12px;
}

.project-figure.is-plot {
  padding: clamp(14px, 2vw, 22px) clamp(14px, 2vw, 22px) 0;
}

/* Side by side, two cards share a row, so their pictures share a shape: a
   plot is set inside a 16:9 white panel at its own proportions, the same
   height as its neighbour's photograph. Full width (phones, the large card)
   it keeps its natural shape. */
@media (min-width: 821px) {
  .project-card:not(.large) .project-figure.is-plot .figure-media {
    display: flex;
    align-items: center;
    justify-content: center;
    aspect-ratio: 16 / 9;
  }
  .project-card:not(.large) .project-figure.is-plot .figure-media img {
    width: auto;
    max-width: 100%;
    height: 100%;
    object-fit: contain;
  }
}

.project-figure.is-photo .figure-media img {
  aspect-ratio: 16 / 9;
  object-fit: cover;
  transition: transform 420ms ease, filter 420ms ease;
}

.project-card:hover .project-figure.is-photo .figure-media img {
  filter: saturate(1.12) contrast(1.04);
  transform: scale(1.04);
}

.card-figure figcaption,
.project-figure figcaption {
  margin-top: 10px;
  color: var(--ink-caption);
  font-size: 0.86rem;
  line-height: 1.5;
}

.project-figure.is-photo figcaption {
  padding: 0 clamp(22px, 3vw, 32px);
}

.figure-credit {
  display: block;
  margin-top: 4px;
  font-size: 0.8rem;
  color: var(--muted);
}

.figure-credit a {
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
}

.figure-credit a:hover {
  color: var(--text);
}

.publication-tools {
  margin-bottom: 18px;
}

.publication-list {
  display: grid;
  gap: 18px;
}

.publication-card {
  padding: clamp(22px, 3vw, 34px);
}

.publication-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.publication-meta span {
  padding: 6px 10px;
  color: var(--accent);
  font-size: 0.84rem;
  font-weight: 850;
  background: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.09);
  border: 1px solid oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.22);
  border-radius: 999px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.11);
  text-transform: none;
}

.publication-card p {
  margin-bottom: 0;
  color: #cdd8e7;
}

.publication-actions {
  gap: 10px;
  margin-top: 22px;
}

.chip-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 36px;
  padding: 7px 12px;
  color: #06121f;
  font-size: 0.85rem;
  font-weight: 850;
  /* Flat accent. Was a 135deg two-hue gradient under a 28px cyan drop glow,
     and the hover swapped in a THIRD hue — the same small link rendered in up
     to three accent colours depending on state. */
  background: var(--accent);
  border-radius: 4px;
}

.chip-link:hover {
  color: #06121f;
  background: color-mix(in oklab, var(--accent) 86%, white);
  transform: translateY(-2px);
}

/* ── Publications: Cite button and the phone "Show all" toggle ──────────── */
/* The Cite chip is an action, not a link, so it is outlined rather than
   filled: the same shape as its neighbours, read as a different kind of thing. */
.chip-cite {
  gap: 6px;
  font-family: inherit;
  line-height: inherit;
  color: var(--accent);
  background: transparent;
  border: 1px solid color-mix(in oklab, var(--accent) 70%, transparent);
  cursor: pointer;
}

.chip-cite:hover,
.chip-cite.is-copied {
  color: #06121f;
  background: var(--accent);
}

/* A zero-height, invisible "Copied" stacked under the label sets its width, so
   the chip keeps its size on copy and the row never re-wraps under the tap. */
.chip-cite [data-cite-label] {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
}
.chip-cite [data-cite-label]::after {
  content: "Copied";
  height: 0;
  overflow: hidden;
  visibility: hidden;
}

/* `display` on .chip-link and .btn would otherwise beat the hidden attribute
   the no-script markup relies on. */
.chip-cite[hidden],
.publication-more[hidden] { display: none; }

.publication-more {
  width: 100%;
  margin-top: 18px;
  font-family: inherit;
  cursor: pointer;
}

/* The list only collapses on phones; at wider widths the toggle is pointless. */
@media (min-width: 641px) {
  .publication-more { display: none; }
}

@media (max-width: 640px) {
  .publication-list.is-collapsed > .publication-card:nth-child(n + 4) { display: none; }
}

.timeline {
  position: relative;
  display: grid;
  gap: 34px;
  padding-left: 190px;
}

.timeline-track {
  position: absolute;
  top: 10px;
  bottom: 10px;
  left: 148px;
  width: 6px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.08);
  border-radius: 99px;
  box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.08);
}

.timeline-fill {
  width: 100%;
  height: var(--timeline-progress, 0%);
  background: linear-gradient(180deg, var(--accent), var(--accent-2) 52%, #5eead4 100%);
  border-radius: inherit;
  box-shadow: 0 0 22px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.62), 0 0 48px oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.42);
  transition: height 120ms linear;
}

.timeline-item {
  position: relative;
  display: grid;
  grid-template-columns: 150px minmax(0, 1fr);
  gap: 40px;
  margin-left: -190px;
}

.timeline-item::before {
  position: absolute;
  top: 20px;
  left: 141px;
  z-index: 1;
  width: 20px;
  height: 20px;
  content: "";
  background: radial-gradient(circle, #ffffff 0 18%, var(--accent) 42%, oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.3) 72%);
  border: 4px solid rgba(8, 13, 24, 0.96);
  border-radius: 50%;
  box-shadow: 0 0 0 8px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.1), 0 0 22px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.24);
  opacity: 0.66;
  transition: transform 240ms ease, box-shadow 240ms ease, opacity 240ms ease, background 240ms ease;
}

.timeline-item.is-past::before,
.timeline-item.is-active::before {
  opacity: 1;
}

.timeline-item.is-active::before {
  background: radial-gradient(circle, #ffffff 0 18%, var(--accent-3) 38%, var(--accent) 72%);
  box-shadow: 0 0 0 10px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.16), 0 0 34px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.64), 0 0 60px oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.36);
  transform: scale(1.12);
}

.timeline-date {
  padding-top: 16px;
  color: var(--accent-3);
  font-size: 0.92rem;
  font-weight: 850;
}

.timeline-content {
  padding: 24px;
  overflow: hidden;
}

.timeline-item.is-active .timeline-content {
  border-color: oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.34);
  box-shadow:
    0 30px 90px rgba(0, 0, 0, 0.58),
    0 0 44px oklch(var(--l-text) var(--cmax-violet) var(--h-violet) / 0.13),
    inset 0 1px 0 rgba(255, 255, 255, 0.22);
}

.timeline-org {
  color: #d5dfeb;
  font-weight: 700;
}

.teaching-panel {
  display: grid;
  grid-template-columns: minmax(220px, 0.42fr) minmax(0, 1fr);
  gap: 18px 30px;
  margin-top: 32px;
  padding: clamp(22px, 3vw, 30px);
}

.teaching-panel.teaching-panel h3 {
  font-size: clamp(1.45rem, 2.4vw, 2.2rem);
}

.teaching-panel p {
  margin-bottom: 10px;
}

.personal-panel {
  display: grid;
  grid-template-columns: minmax(220px, 0.45fr) minmax(0, 1fr);
  gap: clamp(24px, 4vw, 42px);
  padding: clamp(24px, 4vw, 42px);
}

.interest-grid {
  display: grid;
  gap: 12px;
  align-content: start;
}

.interest-grid span {
  display: inline-flex;
  gap: 10px;
  align-items: center;
  min-height: 46px;
  padding: 11px 14px;
  color: #eaf6ff;
  font-weight: 800;
  background: rgba(255, 255, 255, 0.055);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 999px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
}

.interest-grid i {
  color: var(--gold);
}

.personal-copy p:last-child {
  margin-bottom: 0;
}

.useful-main {
  min-height: 100svh;
}

.useful-hero {
  padding-top: 112px;
}

.useful-hero h1 {
  font-size: clamp(4rem, 9vw, 7rem);
  font-weight: 900;
}

.useful-links-grid {
  display: grid;
  gap: 18px;
}

.link-section {
  padding: clamp(22px, 3vw, 34px);
}

.link-section h2 {
  margin-bottom: 16px;
  font-size: clamp(1.5rem, 2.4vw, 2.45rem);
}

.link-section h3 {
  margin: 20px 0 10px;
  color: var(--accent-3);
  font-size: 1rem;
  text-transform: none;
}

.link-section p {
  margin-bottom: 10px;
}

.link-section p:last-child {
  margin-bottom: 0;
}

.contact-panel {
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  gap: clamp(24px, 4vw, 48px);
  align-items: start;
  padding: clamp(24px, 4vw, 40px);
}

/* The panel holds a form, so it stays put: no tilt toward the cursor and no
   lift on hover, which the shared card rule gives every other panel. */
.contact-panel,
.contact-panel:hover,
.contact-panel:focus-within {
  transform: none;
}

.contact-intro p {
  max-width: 44ch;
  margin-bottom: 22px;
  color: var(--ink-text-2);
}

.contact-links {
  gap: 10px;
}

/* THE FORM. Fields are quiet glass wells that light up on focus; the label
   sits above each one in sentence case, never inside it as a placeholder. */
.contact-form {
  display: grid;
  gap: 16px;
}

.form-row {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px;
}

.form-field {
  display: grid;
  gap: 7px;
}

.form-field label {
  color: var(--ink-text-2);
  font-size: 0.92rem;
  font-weight: 600;
}

.form-field input,
.form-field textarea {
  width: 100%;
  min-height: 46px;
  padding: 11px 14px;
  color: var(--text);
  font: inherit;
  font-size: 1rem;
  background: rgba(255, 255, 255, 0.045);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 12px;
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease;
}

.form-field textarea {
  min-height: 132px;
  resize: vertical;
}

.form-field input:focus,
.form-field textarea:focus {
  outline: none;
  background: rgba(255, 255, 255, 0.07);
  border-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.7);
  box-shadow: 0 0 0 3px oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.18);
}

.form-field [aria-invalid="true"] {
  border-color: oklch(0.72 0.16 25 / 0.9);
}

.form-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px 18px;
  align-items: center;
}

.form-actions .btn {
  border: 0;
  cursor: pointer;
}

.form-actions .btn:disabled {
  opacity: 0.6;
  cursor: progress;
}

.form-status {
  margin: 0;
  color: var(--ink-text-2);
  font-size: 0.95rem;
}

.form-status.is-ok { color: oklch(0.86 0.12 160); }
.form-status.is-error { color: oklch(0.8 0.12 25); }

.h-captcha:empty { display: none; }

@media (max-width: 640px) {
  .form-row { grid-template-columns: 1fr; }
}

.site-footer {
  padding: 28px 0 42px;
  /* A 1130px hairline across the foot of the page, replaced by the two things
     that separate without drawing: a step in ground tone, and room. */
  background: color-mix(in oklab, var(--ink-void) 70%, transparent);
  padding-top: 2.75rem;
}

.site-footer p {
  margin: 0;
  font-size: 0.95rem;
}

.scroll-top {
  position: fixed;
  right: 22px;
  bottom: 22px;
  z-index: 996;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  color: #06121f;
  visibility: hidden;
  background: var(--accent);
  border-radius: 50%;
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 180ms ease, transform 180ms ease, visibility 180ms ease;
}

.scroll-top.active {
  visibility: visible;
  opacity: 1;
  transform: translateY(0);
}

/* Visible BY DEFAULT. The hiding is gated behind html.js-reveal, which an
   inline head script adds — so if the script 404s, is cached stale, or throws,
   the page renders fully rather than staying blank forever.
   The durations are deliberately asymmetric: opacity finishes before the
   transform does, so the element is readable before it stops moving. */
.reveal {
  opacity: 1;
  transform: none;
}

html.js-reveal .reveal {
  opacity: 0;
  translate: 0 22px;
  /* `translate`, not `transform`: a separate property, so the hover 3D tilt
     composes with it instead of being overridden.
     An animation, not a transition: `transition` is a shorthand, and at
     (0,2,1) this rule was resetting transition-property to opacity,transform
     and wiping the glass transitions on 17 of the 24 cards that carry
     .reveal — which made the whole @property registration decorative. */
}

/* Specificity must clear html.js-reveal .reveal (0,2,1) -- the hero is
   deliberately exempt from the scroll reveal so it does not fight the intro. */
.hero .reveal,
html.js-reveal .hero .reveal {
  opacity: 1;
  transform: none;
}

html.js-reveal .reveal.is-visible {
  animation: revealIn 680ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

@keyframes revealIn {
  from { opacity: 0; translate: 0 22px; }
  60%  { opacity: 1; }
  to   { opacity: 1; translate: none; }
}

@media (max-width: 1199px) {
  .site-header {
    width: min(86vw, 330px);
    pointer-events: none;
  }

  .site-header .header-container {
    pointer-events: auto;
    transform: translateX(calc(-100% - 42px));
    transition: transform 620ms cubic-bezier(0.16, 1, 0.3, 1), opacity 420ms ease, background 260ms ease, border-color 260ms ease, box-shadow 260ms ease;
  }

  .site-header.header-show .header-container {
    transform: translateX(0) !important;
  }

  html:not(.intro-active) .site-header:not(.header-show) .header-container {
    opacity: 1;
    transform: translateX(calc(-100% - 42px)) !important;
  }

  html.intro-hold .site-header .header-container {
    opacity: 1;
    transform: translateX(calc(50vw - 50% - 18px)) scale(1.02) !important;
  }

  html.intro-moving .site-header .header-container {
    opacity: 0;
    transform: translateX(calc(-100% - 42px)) !important;
  }

  html.intro-complete .site-header .header-container {
    opacity: 1;
    transform: translateX(calc(-100% - 42px)) !important;
  }

  html.intro-complete .site-header.header-show .header-container {
    transform: translateX(0) !important;
  }

  .header-toggle {
    pointer-events: auto;
  }

  .main,
  .site-footer {
    margin-left: 0;
  }

  .hero-shell,
  .about-panel,
  .contact-panel {
    grid-template-columns: minmax(0, 1fr);
  }

  /* One column: the portrait leads, centred, instead of arriving after the
     whole hero (name, paragraph, three buttons, links) as a stray block. */
  .hero-portrait {
    order: -1;
    justify-self: center;
    width: min(100%, 320px);
    max-width: 320px;
  }

  /* Stacked, the hero is taller than its content needs to be centred in; a
     forced full-screen height only adds empty sky under the links. */
  .hero {
    min-height: auto;
  }

  .section {
    padding: 84px 0;
  }
}

@media (max-width: 820px) {
  .cosmic-image-bg {
    opacity: 0.06;
  }

  html.sky-ready #sky-canvas {
    opacity: 0.6;
  }

  .cosmic-image-shade {
    background:
      radial-gradient(circle at 50% 30%, rgba(2, 3, 4, 0.36), rgba(2, 3, 4, 0.9) 68%),
      linear-gradient(180deg, rgba(2, 3, 4, 0.82), rgba(2, 3, 4, 0.94));
  }

  body::after {
    opacity: 0.56;
  }

  .hero::before,
  .hero::after {
    opacity: 0.42;
  }

  .research-grid,
  .fact-grid,
  .contact-grid,
  .teaching-panel,
  .personal-panel {
    grid-template-columns: 1fr;
  }

  .research-card.featured {
    grid-row: auto;
    min-height: auto;
  }

  .project-mosaic {
    grid-template-columns: 1fr;
  }

  .project-card,
  .project-card.large {
    grid-column: auto;
    grid-template-columns: 1fr;
  }

  .timeline {
    gap: 22px;
    padding-left: 0;
  }

  .timeline-track,
  .timeline-item::before {
    display: none;
  }

  .timeline-item {
    grid-template-columns: 1fr;
    gap: 10px;
    margin-left: 0;
  }

  .timeline-date {
    padding-top: 0;
  }
}

@media (max-width: 640px) {
  html,
  body,
  .main,
  .section,
  .hero,
  .container,
  .hero-shell,
  .hero-copy {
    width: 100%;
    max-width: 100%;
    overflow-x: clip;
  }

  .hero {
    min-height: auto;
    padding-top: 92px;
    padding-bottom: 56px;
  }

  .hero-shell {
    gap: 26px;
  }

  /* On a phone a full section of empty sky between every heading reads as
     the page having stopped; the step between sections stays clearly larger
     than any gap inside one. */
  .section:not(.hero) {
    padding: 60px 0;
  }

  .site-header {
    width: min(calc(100vw - 28px), 330px);
    padding: 16px 14px;
  }

  .site-header .header-container {
    max-height: min(82vh, 620px);
  }

  html.intro-hold .site-header .header-container {
    transform: translateX(calc(50vw - 50% - 14px)) scale(1) !important;
  }

  .container {
    max-width: 100vw !important;
    padding-right: 18px;
    padding-left: 18px;
  }

  .hero-copy {
    width: min(calc(100vw - 36px), 354px);
    max-width: min(calc(100vw - 36px), 354px);
  }

  .hero-portrait {
    justify-self: center;
    width: min(64vw, 248px);
    max-width: 248px;
    padding: 8px;
    border-radius: 22px;
  }

  .hero-portrait img,
  .hero-portrait::after {
    border-radius: 18px;
  }

  .hero-portrait::before {
    border-radius: 22px;
  }

  .eyebrow::before {
    width: 22px;
    flex: 0 0 22px;
  }

  .hero h1 {
    max-width: min(calc(100vw - 36px), 354px);
    font-size: clamp(3.2rem, 16vw, 4.75rem);
  }

  .hero-role {
    max-width: min(calc(100vw - 36px), 330px);
    font-size: clamp(1.08rem, 7vw, 1.45rem);
    overflow-wrap: anywhere;
    word-break: break-word;
  }

  .hero-subtitle,
  .section-heading p:last-child {
    max-width: min(calc(100vw - 36px), 354px);
    overflow-wrap: break-word;
    word-break: break-word;
  }

  .section-heading,
  .section-heading h2 {
    width: min(calc(100vw - 36px), 354px);
    max-width: min(calc(100vw - 36px), 354px);
    overflow-wrap: break-word;
    word-break: normal;
  }

  .section-heading h2 {
    font-size: clamp(2rem, 10vw, 3rem);
    line-height: 1.05;
  }

  .hero-actions,
  .hero-actions .btn {
    width: calc(100vw - 36px);
    max-width: calc(100vw - 36px);
  }

  .hero-links {
    gap: 8px;
    width: calc(100vw - 36px);
    max-width: calc(100vw - 36px);
  }

  .hero-links a {
    flex: 1 1 auto;
    min-width: 0;
  }

  /* The chip row in the contact panel sits inside the panel's padding, so
     the hero row's full-screen width would push it past the panel's edge. */
  .contact-links {
    width: auto;
    max-width: 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  :root {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
    transition-duration: 0.001ms !important;
  }

  /* Must outrank html.js-reveal .reveal (0,2,1), otherwise reduced-motion
     visitors lose the CSS-only fallback and depend on script again. */
  .reveal,
  html.js-reveal .reveal,
  html.js-reveal .reveal.is-visible {
    opacity: 1;
    translate: none;
    transform: none;
    animation: none !important;
  }

  body::before,
  body::after,
  .hero-nebula,
  .hero-portrait {
    animation: none !important;
  }

  html.intro-active .site-header,
  html.intro-active .header-container,
  html.intro-active .brand-lockup {
    filter: none;
    opacity: 1;
    transform: none;
  }

  .about-panel:hover,
  .contact-panel:hover,
  .research-card:hover,
  .project-card:hover,
  .publication-card:hover,
  .timeline-content:hover,
  .teaching-panel:hover,
  .personal-panel:hover,
  .link-section:hover,
  .contact-tile:hover,
  .fact-grid div:hover,
  .glass-panel:hover,
  .cosmic-card:hover,
  .education-card:hover,
  .personal-card:hover,
  .link-card:hover,
  .hover-lift:hover,
  .hero-portrait:hover {
    transform: none;
  }
}

/* ─────────────────────────────────────────────── environment guards
   None of these existed in this file. Each one is a mode a real visitor
   can be in where the design currently has no answer. */

@media print {
  #sky-canvas,
  #warp-canvas,
  .site-header,
  .header-toggle,
  .scroll-top,
  .cosmic-image-bg,
  .cosmic-image-shade,
  .cosmic-vignette,
  #galaxy-canvas,
  .hero-nebula,
  .card-glow,
  .portrait-halo {
    display: none !important;
  }

  html,
  body {
    background: #fff !important;
  }

  /* Headings, tags and most body copy each declare their own dark-theme
     colour, so they ignore an inherited one and print at ~1.05:1 on white. */
  *,
  *::before,
  *::after {
    color: #111 !important;
    text-shadow: none !important;
    box-shadow: none !important;
  }

  /* Fixed layers repeat on every printed page. */
  body::before,
  body::after,
  .skip-link { display: none !important; }

  .hero { min-height: 0 !important; }
  .section { padding: 18px 0 !important; }

  /* Four Scholar URLs are 126 characters with no spaces; print has no
     horizontal scroll, so they clip without this. */
  a[href^="http"]::after {
    overflow-wrap: anywhere;
    word-break: break-all;
    font-size: 0.75em;
    color: #444 !important;
  }

  .main,
  .site-footer { margin-left: 0 !important; }

  .reveal { opacity: 1 !important; transform: none !important; }

  a { color: #111 !important; text-decoration: underline; }
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 0.8em; }

  .research-card,
  .project-card,
  .publication-card,
  .timeline-content,
  .contact-panel,
  .contact-tile,
  .about-panel,
  .teaching-panel,
  .personal-panel,
  .link-section,
  .fact-grid div,
  .glass-panel {
    background: none !important;
    border: 1px solid #bbb !important;
    box-shadow: none !important;
    backdrop-filter: none !important;
    break-inside: avoid;
    color: #111 !important;
  }

  /* Every card carries a translucent ::before wash. Left on, it prints as a
     grey smear over the text. */
  .research-card::before,
  .project-card::before,
  .publication-card::before,
  .timeline-content::before,
  .contact-panel::before,
  .contact-tile::before,
  .about-panel::before,
  .teaching-panel::before,
  .personal-panel::before,
  .link-section::before,
  body::before,
  body::after,
  .timeline-track,
  .timeline-item::before {
    display: none !important;
  }

  /* The bevel ring would print on top of the 1px border print adds. */
  [class*="-card"]::after,
  [class*="-panel"]::after,
  .contact-tile::after,
  .link-section::after,
  .fact-grid div::after,
  .hero-portrait::after,
  .hero-portrait::before { display: none !important; }

  /* The tiles clip their content, so an appended URL would be cut off. */
  .contact-tile a[href^="http"]::after,
  .contact-tile::after { content: none !important; }
  .contact-tile { overflow: visible !important; min-height: 0 !important; }
  .contact-tile span,
  .contact-tile strong { color: #111 !important; }

  .timeline-item { break-inside: avoid; }
}

/* Windows High Contrast: backdrop-filter and low-alpha borders vanish
   entirely, leaving cards with no boundary at all. */
@media (forced-colors: active) {
  .research-card,
  .project-card,
  .publication-card,
  .timeline-content,
  .contact-panel,
  .contact-tile,
  .about-panel,
  .personal-panel,
  .link-section,
  .teaching-panel,
  .hero-portrait {
    border: 1px solid CanvasText !important;
    background: Canvas !important;
    backdrop-filter: none !important;
  }

  .card-glow,
  .hero-nebula,
  .portrait-halo,
  .cosmic-vignette,
  .cosmic-image-bg,
  .cosmic-image-shade,
  #galaxy-canvas,
  .research-card::before,
  .project-card::before,
  .publication-card::before,
  .timeline-content::before,
  .contact-panel::before,
  .contact-tile::before,
  .about-panel::before,
  .teaching-panel::before { display: none !important; }

  a { color: LinkText !important; }

  .btn,
  .btn-primary,
  .btn-outline,
  .chip-link {
    border: 1px solid LinkText !important;
    background: Canvas !important;
  }

  /* 3px, not 2px -- this is the mode that most needs a visible ring. */
  :focus-visible { outline: 3px solid Highlight !important; outline-offset: 4px !important; }
}

/* Some people disable transparency at the OS level for legibility. */
@media (prefers-reduced-transparency: reduce) {
  .research-card,
  .project-card,
  .publication-card,
  .timeline-content,
  .contact-panel,
  .contact-tile,
  .about-panel,
  .personal-panel,
  .link-section,
  .teaching-panel,
  .fact-grid div,
  .tag-cloud span,
  .header-toggle,
  .header-container {
    backdrop-filter: none !important;
    background: #0b1018 !important;
  }

  .cosmic-image-bg,
  html.sky-ready #sky-canvas { opacity: 0.25 !important; }

  .research-card::before,
  .project-card::before,
  .publication-card::before,
  .timeline-content::before,
  .contact-panel::before,
  .contact-tile::before,
  .about-panel::before,
  .teaching-panel::before { opacity: 0 !important; }
}

/* ─────────────────────────────────────────────────── the grain layer
   MANDATORY with the new ramp, not a nice-to-have.

   A solid colour cannot dither anything, so the earlier justification here
   was wrong. The real mechanism is narrower: unequal per-channel traversal
   across a gradient staggers band edges, and the body gradient still runs
   var(--bg) → #060a13 → #090e1c, whose per-channel traversals actually went
   UP (R 6→7, G 9→11, B 17→24). Banding on the four large soft radials is
   real on cheap 8-bit panels either way, and a static noise overlay is the
   cheap fix for it.

   ~2 KB, zero runtime, static — never animated, so reduced motion is a
   non-issue. On a plate archive it reads as emulsion. */
.deep-grain {
  position: fixed;
  inset: 0;
  z-index: 6;          /* above .main (5) — at z-index 3 it sat BELOW three of
                          the four gradients it exists to dither */
  pointer-events: none;
  mix-blend-mode: normal;  /* `overlay` is multiplicative below 0.5, so on a
                              #020304 backdrop the peak-to-peak swing was 0.24
                              of one 8-bit level: it quantised to nothing */
  opacity: 0.035;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}

@media (forced-colors: active) {
  .deep-grain { display: none; }
}

@media print {
  .deep-grain { display: none !important; }
}

@media (prefers-reduced-transparency: reduce) {
  .deep-grain { display: none; }
}

/* ═══════════════════════════════════════════════ type assignments
   Display on the name and section headings; text everywhere else; mono on
   anything that is a readout, a wavelength, a year or a catalogue number.
   Playfair's x-height is 0.413 em against Literata's 0.507, so display sizes
   are set UP or they read small beside body copy. */
h1,
.hero h1,
.section-heading h2,
.hero-role {
  font-family: var(--font-display);
  font-optical-sizing: auto;
  font-weight: 600;
  letter-spacing: -0.012em;
  text-wrap: balance;
}

h1,
.hero h1 { font-size: clamp(3rem, 2.1rem + 3.8vw, 5.25rem); line-height: 1.02; }

h3,
h4,
.research-card h3,
.project-card h3,
.publication-card h3 {
  font-size: 1.3rem;
  font-family: var(--font-display);
  font-weight: 600;
  letter-spacing: -0.006em;
  text-wrap: balance;
}

/* Headlines set apart from the text under them: card and panel titles a step
   heavier and in a pale tint of the page's cyan, section headings in a warm
   starlight white. Both stay well above 12:1 on the page. */
.research-card h3,
.project-card h3,
.publication-card h3,
.timeline-content h3,
.contact-panel h3,
.teaching-panel h3 {
  color: color-mix(in oklab, var(--a-cyan) 52%, #ffffff);
  font-weight: 700;
}

.section-heading h2 {
  color: #fbf4e8;
  font-weight: 700;
}

/* On a laptop or wider, a section heading is one line: it is short, and
   balancing it onto two made it read as two headings. Sized to the space
   beside the sidebar so it never runs off the edge. */
@media (min-width: 1000px) {
  .section-heading { max-width: none; }
  .section-heading h2 {
    max-width: none;
    white-space: nowrap;
    font-size: clamp(2.2rem, calc((100vw - 400px) / 15), 4.7rem);
  }
}

p, li, .hero-subtitle, .publication-card p {
  font-family: var(--font-text);
  text-wrap: pretty;
}

/* Monospace is for literal code and for data a reader is meant to compare
   column-wise. It was reaching six UI selectors from here — kickers, tags,
   publication meta, timeline dates, chip links — which is how a page ends up
   with a code face as its third typeface and every small label dressed as
   terminal output. The .mono and .data utilities stay as an explicit opt-in. */
.mono,
.data,
code,
kbd {
  font-family: var(--font-mono);
  font-variant-ligatures: none;
}

/* Years, wavelengths and measured values line up in a column. Literata's
   figures are proportional by default. */
.timeline-date,
.publication-meta span,
time,
.data,
table td { font-variant-numeric: tabular-nums; }

/* Real small caps from the binary, not scaled-down capitals. Kept for the
   plate caption, which is imitating a printed archival label and is the one
   place on the page where that is the point. The card kickers used to be here
   too; small caps on a subfield name is the same label affectation as the
   tracked uppercase it replaced. */
.plate-label {
  font-variant-caps: all-small-caps;
  text-transform: none;
  letter-spacing: 0.06em;
}

sub { font-size: 0.72em; vertical-align: -0.18em; }

/* Keyboard users currently get none of the glow a mouse gets. */
[class*="-card"]:focus-within,
[class*="-panel"]:focus-within,
.contact-tile:focus-within,
.link-section:focus-within,
.fact-grid div:focus-within {
  --glow-x: 50%;
  --glow-y: 50%;
}

/* ─────────────────────────────── the animated rim — budget of exactly TWO
   Registered custom properties never animate on the compositor: they force a
   style recalc and a paint every frame. A measured trace at 4x CPU throttle
   showed a blurred @property twin driving Paint from 3 to 17 events/frame.
   So the crisp rim uses @property and its bloom twin uses transform: rotate()
   on a promoted layer — identical look, about a third of the cost.
   Two instances. Never a third. */
@keyframes rimSweep { to { --rim-angle: 360deg; } }
@keyframes rimRotate { to { transform: rotate(360deg); } }

.research-card.featured::after {
  background:
    conic-gradient(from var(--rim-angle) at 50% 50%,
      transparent 0turn,
      color-mix(in oklab, var(--card-accent) 90%, white) 0.05turn,
      transparent 0.17turn,
      transparent 1turn),
    linear-gradient(180deg in oklab, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.05));
  /* first and last stops identical, or there is a visible wrap seam */
  background-blend-mode: screen;
  animation: rimSweep 16s linear infinite;
}

.research-card.featured > .card-glow {
  position: absolute;
  inset: 0;          /* -3px was clipped away by the card's overflow: hidden */
  height: auto;      /* the base rule's height: 300px orphaned this on a
                        660px card, so the bloom span about a centre ~180px
                        above the crisp rim's */
  z-index: 0;        /* -1 painted it UNDER the card's own background */
  border-radius: inherit;
  pointer-events: none;
  background: conic-gradient(from 0deg at 50% 50%,
    transparent 0turn, var(--accent-glow) 0.05turn, transparent 0.17turn, transparent 1turn);
  filter: blur(13px) saturate(1.15);
  opacity: 0.3;    /* 0.48 + saturate(1.5) + a second glint is the crypto-
                      landing-page signature a committee clocks instantly */
  animation: rimRotate 16s linear infinite;
  will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
  .research-card.featured::after,
  .research-card.featured > .card-glow { animation: none !important; }
}

/* forced-colors drops backdrop-filter, box-shadow and pseudo backgrounds, so
   the decorative layers must go or they leave artefacts with no boundary. */
@media (forced-colors: active) {
  [class*="-card"]::after,
  [class*="-panel"]::after,
  .contact-tile::after,
  .link-section::after,
  .fact-grid div::after { display: none !important; }
}

@media (prefers-reduced-transparency: reduce) {
  /* opacity: 1 here made the translucent decoration MORE prominent in the one
     mode that explicitly asked for less. */
  [class*="-card"]::after,
  [class*="-panel"]::after,
  .contact-tile::after,
  .link-section::after,
  .fact-grid div::after { display: none; }
}

/* ═══════════════════════════════════════════════════ PLATE I
   The portrait stays exactly 900x900. No crop, no mask, no clip-path, no
   fade-to-black bust, no grayscale duotone, no object-fit change. Every
   transform here is scale / rotate / translate only, which preserves 1:1 by
   construction rather than by care.

   And none of that is needed: the studio backdrop measures about L 0.20-0.22,
   BRIGHTER than the #020304 page, so it already reads as a lit object on a
   dark ground. That is the asset, not the problem. */

.hero-portrait { overflow: visible; }

/* 2. LIGHT TABLE — a warm glow BEHIND the plate, drawn from magma ramp-6/7.
   The one place this design spends real warmth, and what makes the emulsion
   read as backlit rather than merely framed. */
.hero-portrait .plate-light {
  position: absolute;
  inset: 8% 6% 14%;
  z-index: -2;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(ellipse at 50% 45%,
    var(--ramp-7) 0%, var(--ramp-6) 34%, transparent 72%);
  filter: blur(34px);
  opacity: 0.3;
}

/* 3. HALO — re-authored as a conic ring in magma. */
.hero-portrait .portrait-halo {
  position: absolute;
  inset: -10px;
  z-index: -1;
  border-radius: inherit;
  pointer-events: none;
  background: conic-gradient(from 0deg at 50% 50%,
    var(--ramp-2), var(--ramp-4), var(--ramp-6), var(--ramp-7), var(--ramp-2));
  filter: blur(16px) saturate(1.1);
  opacity: 0.22;
  animation: rimRotate 16s linear infinite;
  will-change: transform;
}

/* 5. ONE GLINT, 16s — not two, and not 8s. A fast rotating double-glint ring
   around a human face is the visual signature of a livestream "live now"
   avatar badge, and a hiring committee clocks that instantly even without
   naming it. */
.hero-portrait::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  pointer-events: none;
  background:
    conic-gradient(from var(--rim-angle) at 50% 50%,
      transparent 0turn,
      oklch(0.92 0.09 199) 0.04turn,
      transparent 0.15turn,
      transparent 1turn),
    linear-gradient(180deg in oklab,
      rgba(255, 255, 255, 0.58),
      oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.22) 34%,
      rgba(255, 255, 255, 0.05) 60%,
      rgba(255, 255, 255, 0.05));
  background-blend-mode: screen;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask-composite: exclude;
  opacity: 0.82;
  animation: rimSweep 16s linear infinite;
  transition: opacity var(--dur-s) var(--ease-out);
}

/* Keyboard users get the same lift a mouse gets — they got none before. */
.hero-portrait:hover::after,
.hero-portrait:focus-within::after { opacity: 1; }

/* 6. FIDUCIALS — four corner registration marks just outside the frame. */
.hero-portrait::before {
  content: "";
  position: absolute;
  inset: -9px;
  pointer-events: none;
  border-radius: 40px;
  background:
    linear-gradient(var(--ink-edge), var(--ink-edge)) 0 0 / 14px 1px no-repeat,
    linear-gradient(var(--ink-edge), var(--ink-edge)) 0 0 / 1px 14px no-repeat,
    linear-gradient(var(--ink-edge), var(--ink-edge)) 100% 0 / 14px 1px no-repeat,
    linear-gradient(var(--ink-edge), var(--ink-edge)) 100% 0 / 1px 14px no-repeat,
    linear-gradient(var(--ink-edge), var(--ink-edge)) 0 100% / 14px 1px no-repeat,
    linear-gradient(var(--ink-edge), var(--ink-edge)) 0 100% / 1px 14px no-repeat,
    linear-gradient(var(--ink-edge), var(--ink-edge)) 100% 100% / 14px 1px no-repeat,
    linear-gradient(var(--ink-edge), var(--ink-edge)) 100% 100% / 1px 14px no-repeat;
  opacity: 0.55;
}

/* 6. PLATE LABEL — this is what converts a headshot into an archival object,
   and it is the cheapest, highest-yield element in the design. */
/* The caption gets its OWN surface rather than inheriting whatever the plate
   happens to be. It is light lettering, and the plate behind it is a lit
   conic rim over a warm radial — light on light. Measured against the
   composite it sat at 2.64:1 for the plate number and 1.18:1 for the
   dimensions, well under the 4.5:1 a caption needs. A scrim is also what the
   object it is imitating actually has: an archival plate carries its
   annotation on a printed strip, not floating on the emulsion. */
.plate-label {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 0.7em;
  margin-top: 14px;
  padding: 10px 12px 9px;
  border-radius: 0 0 10px 10px;
  background: linear-gradient(180deg in oklab,
    color-mix(in oklab, var(--ink-void) 72%, transparent),
    color-mix(in oklab, var(--ink-void) 92%, transparent));
  /* no border-top: the caption already sits on its own darker strip, so the
     tone step does the separating and the line was drawing it twice */
  font-family: var(--font-display);
  font-variant-caps: all-small-caps;
  font-variant-numeric: oldstyle-nums;
  letter-spacing: 0.07em;
  font-size: 0.86rem;
  color: var(--ink-muted);
}
.plate-label .plate-id {
  /* The archival-plate conceit is kept — astronomical plates are real, and it
     is one of the few things on this page taken from the subject rather than
     from a template. The monospace is not kept: setting a caption in a code
     face is costume, and the "900 x 900 px" line that sat beside it was fake
     metadata that told the reader nothing true. */
  font-family: inherit;
  font-variant-caps: normal;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  color: var(--ink-text);
}
.plate-label .plate-dim {
  margin-left: auto;
  font-family: inherit;
  font-variant-caps: normal;
  font-variant-numeric: tabular-nums;
  font-size: 0.78rem;
  color: var(--ink-caption);
}

@media (prefers-reduced-motion: reduce) {
  .hero-portrait::after,
  .hero-portrait .portrait-halo { animation: none !important; }
}

@media (forced-colors: active) {
  .hero-portrait::before,
  .hero-portrait::after,
  .hero-portrait .plate-light,
  .hero-portrait .portrait-halo { display: none !important; }
  .plate-label { border-top-color: CanvasText; }
}

/* ═══════════════════════════════════════════════ the plate field
   The WebGL volume sits under the existing 2D starfield. The 2D canvas stays
   in the DOM and stays VISIBLE by default; it is only hidden once GL reports
   a successful first frame, and it comes straight back on context loss or a
   governor trip. Inverted default, so a GL failure cannot blank the hero. */
#plate-field {
  position: fixed;
  inset: 0;
  /* z-index 0, NOT 1. At 1 it tied with .cosmic-image-shade — same value, same
     root stacking context, so DOM order decided and the nebula painted ON TOP
     of the contrast scrim it is supposed to sit under, erasing the individual
     stars in the photograph beneath. 0 puts it above .cosmic-image-bg and below
     the scrim, which is where an emission layer belongs. */
  z-index: 0;
  /* screen, matching .cosmic-image-bg and #galaxy-canvas. The canvas is opaque
     (alpha: false), so this is the only compositing rule in play: emission is
     additive light, black contributes nothing, and this layer can never
     subtract brightness from the star field underneath it. */
  mix-blend-mode: screen;
  pointer-events: none;
  opacity: 0;
  transition: opacity 900ms var(--ease-out);
}

html.gl-ready #plate-field { opacity: 1; }
/* Was 0.34, when this canvas was a spiral of dots kept as sparkle over the GL
   nebula. It is the galaxy merger now (cosmos.js), the thing the hero is
   about, so it stays at full strength with the nebula behind it. */
html.gl-ready #galaxy-canvas { opacity: 0.94; }

@media (prefers-reduced-motion: reduce) {
  #plate-field { display: none; }
}

@media (forced-colors: active) {
  #plate-field { display: none; }
}

@media print {
  #plate-field { display: none !important; }
}

/* ════════════════════════════════════════════ split-line masking
   The padding / negative-margin pair is the detail nobody ships correctly.
   Without it the mask shears descenders and the tops of Λ, σ, Å and the
   shim's ⊙ — whose vertical extent arrives from a DIFFERENT font via
   unicode-range and is the one thing you cannot predict.
   `clip`, not `hidden`: clip creates no scroll container. */
.split-line { padding-block: 0.18em; display: inline-block; }
.split-line-mask { overflow: clip !important; margin-block: -0.18em; }

/* ════════════════════════════════════════════════════════ THE SIGHTLINE
   A live O VI absorption measurement.

   .ovi-panel is listed BY NAME in the glass recipe, the bevel ::after, the
   wash ::before and the hover/focus rules above. An earlier version of this
   comment claimed it inherited all of that from the [class*="-panel"] group.
   It does not, and the claim was worth four separate defects. That attribute
   selector appears exactly once in this file, at line 238, and it sets custom
   properties and nothing else — every visual rule is an enumerated class list.
   Measured, the panel had background-image: none, border: 0, border-radius: 0,
   backdrop-filter: none, box-shadow: none and no ::before or ::after content:
   a bare transparent rectangle in a page of seventeen glass cards, and the one
   panel that collapses under forced-colors because it had no border to keep.

   The accent is the group default — cyan at L 0.845 — and THAT part is
   deliberate.

   A seventh hue was tried first, Ly-α at 340. It does not fit. The six
   component lightnesses form an even ladder at 0.70 / 0.74 / 0.78 / 0.82 /
   0.86 / 0.90, a minimum adjacent ΔL of 0.040, which is the only separation
   dichromacy preserves. A seventh entry either interleaves (L 0.795 sits
   0.015 from .contact-tile, inside one JND) or extends below the ladder — and
   extending fails harder: computed against the panel's own surface, oklch(0.66
   0.115 340) is 2.20:1 and oklch(0.70 0.115 340) is 2.76:1. The slider track
   and thumb are real controls, so WCAG 1.4.11 wants 3:1, and both miss it.
   The cyan default is 5:1+ on the same surface, belongs to the research
   section this instrument measures for, and matches λ1031.93 in the figure. */

.sightline .container { max-width: 1120px; }

.ovi-panel {
  padding: clamp(1.1rem, 2.6vw, 2rem);
  display: grid;
  gap: clamp(1rem, 2vw, 1.6rem);
}

.ovi-figure { margin: 0; display: grid; gap: 0.85rem; }

/* aspect-ratio, not a fixed height: the figure has to stay a figure at 320px
   and at 1600px, and a canvas with a fixed height is the usual way that breaks.
   The floor stops it collapsing to a slot on a phone in landscape. */
#ovi-canvas {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 7;
  min-height: 210px;
  border-radius: 12px;
  background:
    linear-gradient(180deg in oklab,
      color-mix(in oklab, var(--ink-void) 92%, transparent),
      color-mix(in oklab, var(--ink-void) 74%, transparent));
}

.ovi-caption { display: grid; gap: 0.5rem; }

.ovi-readout {
  margin: 0;
  font-family: var(--font-mono);
  font-size: clamp(0.78rem, 1.5vw, 0.9rem);
  line-height: 1.55;
  color: var(--ink-text-2);
  /* Three lines. line-height is 1.55, so two lines is 3.10em and the old
     2.9em floor was 94% of two — it could not hold back even the 1->2 line
     shift it was written for. Measured across both sliders at ten widths,
     560-600px was the case that bit: the "unsaturated" branch wraps to three
     lines and the shorter branches to two, so the panel grew and shrank by
     19.35px as the column-density slider crossed log N 13.0 — the exact
     reflow-under-the-thumb this is here to prevent. */
  min-height: 4.65em;
}

.ovi-method {
  margin: 0;
  font-size: 0.74rem;
  line-height: 1.6;
  color: var(--ink-muted);
  max-width: 78ch;
}

.ovi-controls {
  display: grid;
  gap: clamp(0.7rem, 1.6vw, 1.1rem);
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}

.ovi-control { margin: 0; display: grid; gap: 0.4rem; }

.ovi-control label {
  font-size: 0.8rem;
  color: var(--ink-text-2);
  display: flex;
  flex-wrap: wrap;
  gap: 0.5ch;
  align-items: baseline;
}

.ovi-unit {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--ink-muted);
}

/* The track is styled, the thumb is 22px on a 44px-tall input: the pointer
   target meets 2.5.5 without the control looking like a 44px slab. */
.ovi-control input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 44px;
  background: transparent;
  cursor: ew-resize;
  /* touch-action is deliberately NOT set. Overriding it here is how a slider
     inside a scrolling page eats the vertical swipe that the reader meant for
     the page, and a range input already handles its own horizontal drag. */
}
.ovi-control input[type="range"]::-webkit-slider-runnable-track {
  height: 2px;
  border-radius: 2px;
  background: linear-gradient(90deg,
    color-mix(in oklab, var(--card-accent) 55%, transparent),
    color-mix(in oklab, var(--ink-dim) 70%, transparent));
}
.ovi-control input[type="range"]::-moz-range-track {
  height: 2px;
  border-radius: 2px;
  background: linear-gradient(90deg,
    color-mix(in oklab, var(--card-accent) 55%, transparent),
    color-mix(in oklab, var(--ink-dim) 70%, transparent));
}
.ovi-control input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 22px; height: 22px;
  margin-top: -10px;
  border-radius: 50%;
  background: var(--card-accent);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55), 0 4px 14px -4px var(--card-accent);
  transition: box-shadow var(--dur-xs) var(--ease-out);
}
.ovi-control input[type="range"]::-moz-range-thumb {
  width: 22px; height: 22px;
  border: 0;
  border-radius: 50%;
  background: var(--card-accent);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55), 0 4px 14px -4px var(--card-accent);
}
.ovi-control input[type="range"]:focus-visible {
  outline: 2px solid var(--card-accent);
  outline-offset: 4px;
  border-radius: 8px;
}

/* ── the gutter continuum rail ──────────────────────────────────────────
   The same numbers, 1px wide, running down the page margin: the reader meets
   the profile in the gutter before they reach the panel that explains it.

   It lives on the RIGHT and starts at 1660px, and both of those are measured
   rather than chosen. The left gutter is not a gutter — .header-container is a
   fixed sidebar occupying 18–292px at every desktop width, and an earlier
   left-anchored version of this rail rendered directly on top of the
   navigation. The right side is no better until the container stops growing:
   at 1400px viewport the section container measures 310→1400, i.e. it ends
   exactly at the viewport edge, with nothing to spare. The container caps at
   1120px, so free space only opens once main exceeds ~1320px, which is a
   viewport of roughly 1630. 1660 gives a measured 115px strip; the rail takes
   26 of it. Below that this element does not exist, which is the correct
   amount of a decorative instrument to show when there is nowhere to put it. */
.ovi-rail {
  position: fixed;
  right: 44px;
  top: 50%;
  translate: 0 -50%;
  width: 26px;
  height: min(560px, 62vh);
  /* 5, not 3. At 3 this sat BELOW .main (5), .deep-grain (6) and
     .cosmic-vignette (4) while geometrically INSIDE .main's box, and was
     visible only because .main, #sightline and #past-projects all happen to
     compute background-color: transparent with no background-image. Give any
     section a background and the rail disappears. That is the identical trap
     already documented for .deep-grain a few hundred lines up, repeated and
     surviving on luck. */
  z-index: 5;
  pointer-events: none;
  display: none;
  overflow: visible;
}
.ovi-rail path {
  stroke: var(--a-cyan);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  opacity: 0.62;
}
@media (min-width: 1660px) {
  .ovi-rail { display: block; }
}

@media (prefers-reduced-motion: reduce) {
  .ovi-rail path { transition: none !important; stroke-dashoffset: 0 !important; }
}

@media print {
  .ovi-rail { display: none !important; }
  .ovi-controls { display: none !important; }
  /* The canvas printed as a ~1032x452 near-blank rectangle: its dark backdrop
     is a CSS background and is dropped by print-color-adjust, while the trace
     itself is --ink-muted and --a-cyan on white, i.e. about 1.5:1. The global
     print rule's `color: #111 !important` cannot reach bitmap pixels. The
     caption and method paragraph carry every number the figure shows, and they
     print black and read correctly, so nothing is lost by dropping the empty
     box. */
  #ovi-canvas { display: none !important; }
}

@media (forced-colors: active) {
  .ovi-rail { display: none; }
  #ovi-canvas { forced-color-adjust: none; }
}


/* The project had no visually-hidden utility and the sightline needs two: the
   beam's keyboard instructions and the spectrum's live region. clip-path with a
   1px box, NOT display:none or visibility:hidden — both of those remove the
   text from the accessibility tree, which is the only place it was ever for. */
.visually-hidden {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Without JS the canvas is a 1032x452 empty box with intrinsic size 300x150,
   no fallback children and an aria-label announcing a spectrum that was never
   drawn — assistive tech told there is an image where there is nothing. The
   heading, the caption, the method paragraph and both labelled sliders are all
   real HTML and stay; only the empty frames go. */
html:not(.js) #ovi-canvas { display: none; }
html:not(.js) .ovi-rail { display: none !important; }


/* ─────────────────────────────────────────── the sightline map
   The map is generated per pixel in JS at a capped resolution and upscaled by
   the browser, so the canvas has no border, no card and no background of its
   own: magma at t = 0 is (0.002, 0.002, 0.014), within two 8-bit levels of the
   page, and 29% of the frame is measured to be fully transparent. The
   figure therefore bleeds into the page with no seam. Give it a border and all
   of that work is thrown away. */
.ovi-mapfig { margin: 0 0 clamp(1rem, 2vw, 1.6rem); display: grid; gap: 0.9rem; }

.ovi-map-wrap {
  position: relative;
  /* width is capped, NOT height. `aspect-ratio: 1/1` with `max-height: 62vh`
     loses to the max-height and silently produces a rectangle — measured 512
     x 213 in a short viewport — which stretches a circular halo into an
     ellipse and turns the R_vir ring into an oval. Capping the width keeps the
     box square at every viewport, which is what the field's own coordinates
     assume. */
  width: min(100%, 62vh);
  aspect-ratio: 1 / 1;
  margin-inline: auto;
  /* pan-y, not none. `none` made the map a 354x354 dead zone on a 390x844
     phone — 42% of the viewport width where a thumb cannot scroll the page.
     pan-y hands vertical panning back to the browser and keeps the horizontal
     drag for the beam, which is the axis the beam actually needs. */
  touch-action: pan-y;
  cursor: crosshair;
}

#ovi-map {
  display: block;
  width: 100%;
  height: 100%;
  image-rendering: auto;           /* the upscale is the point: a column-density
                                      map is a smooth field, and nearest-neighbour
                                      would show the generation grid */
  /* NO mix-blend-mode, and no border-radius. `screen` was here and it was
     inert: .main carries z-index 5 and is therefore a stacking context, so the
     blend group is .main's own contents, and the star field this needed to
     blend with is painted below .main, outside the group. Proven rather than
     argued — filling this canvas opaque black should make a `screen` layer
     vanish entirely, and instead a solid black square painted over the star
     field. The bleed is done entirely by per-pixel alpha and a radial cutoff
     in sightline.js, which is why that file, and not this rule, carries the
     explanation. */
}

/* R_vir at 1.0 in a frame that runs to 2.4 R_vir half-width: diameter is
   2/4.8 = 41.67% of the box. */
.ovi-rvir {
  position: absolute;
  left: 50%; top: 50%;
  width: 41.667%; height: 41.667%;
  translate: -50% -50%;
  border: 1px dashed color-mix(in oklab, var(--ink-text) 38%, transparent);
  border-radius: 50%;
  pointer-events: none;
}

.ovi-beam {
  position: absolute;
  width: 44px; height: 44px;       /* the target is 44px; only the crosshair shows */
  translate: -50% -50%;
  padding: 0;
  border: 0;
  background: none;
  cursor: grab;
  border-radius: 50%;
}
.ovi-beam::before,
.ovi-beam::after {
  content: "";
  position: absolute;
  background: var(--a-cyan);
  box-shadow: 0 0 10px color-mix(in oklab, var(--a-cyan) 70%, transparent);
}
/* the beam itself: a thin vertical line dropping through the halo */
.ovi-beam::before { left: 50%; top: -1000px; bottom: -1000px; width: 1px; translate: -50% 0; opacity: 0.5; }
.ovi-beam::after  { left: 50%; top: 50%; width: 9px; height: 9px; translate: -50% -50%;
                    border-radius: 50%; }
.ovi-beam:focus-visible { outline: 2px solid var(--a-cyan); outline-offset: 2px; }

.ovi-mapcap { display: grid; gap: 0.6rem; }

.ovi-probe {
  margin: 0;
  font-family: var(--font-mono);
  font-size: clamp(0.78rem, 1.5vw, 0.9rem);
  line-height: 1.55;
  color: var(--ink-text);
  /* Four lines. 2.6em was measured failing at 390px, where the clamp notice
     wraps to three lines rather than one and the caption block jumped 29.4px
     every time the beam crossed the 12.5 boundary mid-drag. The manual-override
     notice is longer again, so the floor is set for the longest of the three
     strings, not the shortest. */
  min-height: 6.2em;
}

.ovi-bar-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 0.9rem;
}
.ovi-bar { position: relative; width: 240px; padding-bottom: 1.1rem; }
#ovi-colorbar { display: block; width: 240px; height: 10px; border-radius: 2px; }
.ovi-bar-ticks {
  margin: 0; padding: 0; list-style: none;
  font-family: var(--font-mono); font-size: 0.68rem; color: var(--ink-muted);
}
/* The stretch runs 12.0 to 15.0, so the four decades sit at 0, 1/3, 2/3 and 1.
   translate -50% centres each label on its own tick; the end labels are pulled
   back inside the bar so neither overhangs. */
.ovi-bar-ticks li {
  position: absolute;
  top: 0.9rem;
  translate: -50% 0;
  white-space: nowrap;
}
.ovi-bar-ticks li:nth-child(1) { left: 0;      translate: 0 0; }
.ovi-bar-ticks li:nth-child(2) { left: 33.333%; }
.ovi-bar-ticks li:nth-child(3) { left: 66.667%; }
.ovi-bar-ticks li:nth-child(4) { left: 100%;   translate: -100% 0; }
.ovi-bar-label { margin: 0; font-family: var(--font-mono); font-size: 0.68rem; color: var(--ink-muted); }

@media (forced-colors: active) {
  .ovi-map-wrap { forced-color-adjust: none; }
  .ovi-beam::before, .ovi-beam::after { background: Highlight; }
}
@media print {
  .ovi-beam { display: none !important; }
}

/* ══════════════════════════════════════════════ THE CARD CATALOGUE
   #research becomes a horizontal rail above 900px. Everything here is inside
   the media query and nothing outside it changes, so the narrow layout, the
   print layout and the reduced-motion layout are the untouched vertical stack
   — the rail is an enhancement on top of a page that already worked.

   The 900px floor is not a guess about phones. Below it the cards are already
   one per row, a horizontal rail would show one panel at a time, and
   scroll-hijacking a single column is the version of this pattern that people
   rightly hate. */
@media (min-width: 900px) and (prefers-reduced-motion: no-preference) {
  /* html.rail-ready, NOT html.js. The gate has to mean "the tween that moves
     this rail exists", and only choreography.js knows that — it depends on
     GSAP from a third-party CDN. Gated on html.js alone, a blocked or failed
     CDN left the rail as a 2164px flex row with no transform and no pin:
     measured at 1400px, cards 3 and 4 sat entirely outside the viewport,
     reachable only by a 1086px horizontal page scroll with no affordance and
     no keyboard path. Half the research section, invisible, because a CDN
     blipped. The class is added by the same function that creates the tween
     and removed by its revert. */
  html.rail-ready #research .research-grid {
    display: flex;
    width: max-content;
    gap: 28px;
    grid-template-columns: none;
    /* will-change on the rail, not on each card: one promoted layer that moves,
       instead of four that each get composited separately. */
    will-change: transform;
  }
  html.rail-ready #research .research-card,
  html.rail-ready #research .research-card.featured {
    flex: 0 0 auto;
    width: min(42vw, 520px);
    min-height: 460px;
    grid-row: auto;
  }
  /* A card that carries a figure is twice as wide in the rail, with the plot
     beside the text: stacked under it, the card grew taller than the pinned
     screen it has to fit in. */
  html.rail-ready #research .research-card.has-figure {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
    gap: 30px;
    align-items: center;
    width: min(72vw, 980px);
  }
  html.rail-ready #research .research-card.has-figure .card-figure {
    margin-top: 0;
  }
  html.rail-ready #research .research-card.explorer-card {
    width: min(30vw, 400px);
    align-self: center;
    min-height: 0;
  }
  /* The rail must be able to leave the container's box horizontally while the
     container still centres it vertically. overflow-x on <body> is already
     `clip`, which creates no scroll container, so nothing here can produce a
     horizontal scrollbar. */
  html.rail-ready #research .container { overflow: visible; }
  /* M7: clip at the section. overflow-x: clip on <body> does NOT contain this
     — measured horizontal scroll ranges of 708px at 900, 1206px at 1280 and
     1086px at 1400, i.e. up to 94% of a viewport of empty page reachable by a
     two-finger swipe. `clip` rather than `hidden` so no scroll container is
     created and the pin still works. */
  html.rail-ready #research { overflow-x: clip; }
}

/* A laptop screen is short. The rail is pinned, so whatever of a card falls
   below the screen's edge is never seen at all while it slides past: at
   1280 x 720 that was the first card's buttons and tags. On short screens the
   heading and the cards give up some size so every card fits whole. */
@media (min-width: 900px) and (max-height: 860px) and (prefers-reduced-motion: no-preference) {
  html.rail-ready #research.section { padding-top: 56px; }
  html.rail-ready #research .section-heading { margin-bottom: 18px; }
  html.rail-ready #research .section-heading h2 {
    font-size: clamp(2rem, 3.2vw, 3.1rem);
    margin-bottom: 0;
  }
  html.rail-ready #research .research-card,
  html.rail-ready #research .research-card.featured {
    min-height: 0;
    padding: 22px 26px;
  }
  html.rail-ready #research .research-card h3 { margin: 4px 0 10px; }
  html.rail-ready #research .research-card p {
    margin-bottom: 0.7rem;
    font-size: 0.95rem;
    line-height: 1.5;
  }
  html.rail-ready #research .tag-cloud { margin-top: 12px; }
  html.rail-ready #research .card-figure figcaption { font-size: 0.8rem; }
}

/* ═══════════════════════════════════════════════ THE RAIL'S READOUT
   A pinned rail looks like one screen of cards; nothing says four more follow.
   The readout ("1 / 5", a bar, two arrows) sits at the right end of the
   heading row, so it costs no height in the pinned screen and can never cover
   a card. Hidden unless the tween exists, on the same html.rail-ready gate as
   the rail itself: on phones, with motion off and with GSAP blocked the cards
   are a plain stack and there is nothing to step through. */
.rail-nav { display: none; }

@media (min-width: 900px) and (prefers-reduced-motion: no-preference) {
  /* minmax(0, 1fr): the rail is width: max-content and spans both columns,
     and a plain 1fr track would grow to its 3000px and push the readout off
     the screen. */
  html.rail-ready #research .container {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    column-gap: 24px;
    align-items: end;
  }
  html.rail-ready #research .research-grid { grid-column: 1 / -1; }
  html.rail-ready #research .rail-nav {
    display: flex;
    align-items: center;
    gap: 14px;
    margin-bottom: 36px;
    /* The rail ends 28px inside the viewport (travel() in choreography.js);
       the container's padding is 12px, so 16 more lines the arrow up with the
       last card's edge and keeps its focus ring on screen. */
    margin-right: 16px;
  }
}
/* Matches the short-screen heading margin above, so the two still line up. */
@media (min-width: 900px) and (max-height: 860px) and (prefers-reduced-motion: no-preference) {
  html.rail-ready #research .rail-nav { margin-bottom: 18px; }
}

.rail-nav-btn {
  display: inline-grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  color: var(--ink-text);
  font-size: 1.1rem;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.025));
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 999px;
  cursor: pointer;
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  transition: border-color 200ms ease, opacity 200ms ease, transform 200ms ease;
}
.rail-nav-btn:hover,
.rail-nav-btn:focus-visible {
  border-color: oklch(var(--l-text) var(--cmax-cyan) var(--h-cyan) / 0.5);
  transform: translateY(-2px);
}
/* aria-disabled, not disabled: a disabled button drops keyboard focus to the
   body the moment the last press reaches the end of the rail. */
.rail-nav-btn[aria-disabled="true"] {
  opacity: 0.35;
  cursor: default;
  transform: none;
}
.rail-nav-meter {
  display: grid;
  gap: 7px;
  min-width: 76px;
  justify-items: center;
}
.rail-nav-count {
  margin: 0;
  color: var(--ink-muted);
  font-family: var(--font-text);
  font-size: 0.92rem;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
.rail-nav-count [data-rail-current] { color: var(--ink-text); font-weight: 700; }
.rail-nav-track {
  position: relative;
  width: 76px;
  height: 2px;
  overflow: hidden;
  background: var(--ink-hairline);
  border-radius: 2px;
}
/* scaleX, written by choreography.js on every scrub frame: a transform, so the
   bar never triggers layout while the rail is moving. */
.rail-nav-bar {
  position: absolute;
  inset: 0;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left center;
}
@media print {
  .rail-nav { display: none !important; }
}

/* Every fallback surface gets the stack back, explicitly. A transform left
   behind by a killed tween is the classic way a rail prints as one visible
   card and three blank pages. */
@media print {
  #research .research-grid {
    display: grid !important;
    width: auto !important;
    transform: none !important;
  }
  #research .research-card { width: auto !important; transform: none !important; }
}
@media (prefers-reduced-motion: reduce) {
  #research .research-grid { transform: none !important; }
  #research .research-card { transform: none !important; }
}

/* ═══════════════════════════════════════════════ THE MOTION TOGGLE
   The class is set before first paint by the inline head script, so a reader
   who has asked for a calm page never sees a frame of the intro. */
.motion-toggle-row { margin: 0.75rem 0 0; }

.motion-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.5ch;
  min-height: 34px;
  padding: 0.3rem 0.65rem;
  border: 1px solid color-mix(in oklab, var(--ink-dim) 55%, transparent);
  border-radius: 999px;
  background: transparent;
  color: var(--ink-muted);
  font-family: inherit;
  font-size: 0.82rem;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: color var(--dur-xs) var(--ease-out),
              border-color var(--dur-xs) var(--ease-out);
}
.motion-toggle:hover { color: var(--ink-text); border-color: var(--a-cyan); }
.motion-toggle:focus-visible { outline: 2px solid var(--a-cyan); outline-offset: 3px; }
/* Pressed state is carried by colour AND by the aria-pressed attribute the
   button already exposes — never by colour alone. */
.motion-toggle[aria-pressed="true"] {
  color: var(--ink-text);
  border-color: var(--a-cyan);
  background: color-mix(in oklab, var(--a-cyan) 14%, transparent);
}
/* forced-colors flattens colour, border-colour and background all at once, so
   the pressed state above becomes invisible in exactly the mode whose users
   most need this control. A border width and a marker are not colour. */
@media (forced-colors: active) {
  .motion-toggle { border-width: 1px; }
  .motion-toggle[aria-pressed="true"] { border-width: 3px; }
  .motion-toggle[aria-pressed="true"]::after { content: " \2713"; }
}

/* The blunt reset. This deliberately mirrors what the reduced-motion media
   query does rather than re-listing every animated selector: a per-selector
   copy of a growing file is a list that silently falls out of date, and the
   failure mode is motion that keeps running for the one reader who asked it
   not to. 0.001ms rather than 0 so animation `forwards` end states still
   apply and nothing is left mid-keyframe — in particular .reveal, whose end
   state is the visible one. */
html.motion-off *,
html.motion-off *::before,
html.motion-off *::after {
  animation-duration: 0.001ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.001ms !important;
  transition-delay: 0s !important;
}
html.motion-off { scroll-behavior: auto !important; }

/* The three things the blunt reset cannot reach, because they are not
   animations: a live WebGL canvas, a pinned horizontal rail, and a 2D
   starfield that redraws on rAF. */
html.motion-off #plate-field { display: none !important; }
html.motion-off #research .research-grid {
  display: grid !important;
  width: auto !important;
  transform: none !important;
}
html.motion-off #research .research-card,
html.motion-off #research .research-card.featured {
  width: auto !important;
  transform: none !important;
}

/* ───────────────────────────── NO USABLE GPU ─────────────────────────────
   perf-tier.js sets data-gpu="software" only when WEBGL_debug_renderer_info
   positively identifies a software rasteriser — SwiftShader, llvmpipe,
   Mesa offscreen, Microsoft Basic Render Driver — or when there is no WebGL
   at all. An unknown renderer is never assumed to be software, so a reader
   on a privacy build that hides the extension keeps the full page.

   Measured on a 6x-throttled, 2-core client rendering in software, with both
   JS animation loops already tiered down by perf-tier.js:
       seven infinite CSS animations running ......  8.6fps
       the same page with those animations off .... 59.9fps
       backdrop-filter off, animations still on ...  8.6fps
   No single animation is to blame; the cost is cumulative, because each one
   re-rasterises a large or full-viewport layer every frame and a software
   rasteriser cannot absorb that. The third line matters as much as the
   others: the glass is NOT the problem, so every backdrop-filter stays.

   Only MOTION is removed here. Every gradient, blur, grain, rim and glass
   surface remains exactly as designed — the composition is identical, it
   simply holds still.

   The instant-completion idiom (rather than a blunt animation:none) is
   copied deliberately from the prefers-reduced-motion block above: it lets
   `forwards` fills still land, so .reveal content cannot be stranded at
   opacity 0. That failure mode has bitten this file before. */
html[data-gpu="software"] *,
html[data-gpu="software"] *::before,
html[data-gpu="software"] *::after {
  animation-duration: 0.001ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.001ms !important;
}

/* Must outrank html.js-reveal .reveal (0,2,1), exactly as the
   reduced-motion block has to, or revealed content never becomes visible. */
html[data-gpu="software"] .reveal,
html[data-gpu="software"].js-reveal .reveal,
html[data-gpu="software"].js-reveal .reveal.is-visible {
  opacity: 1;
  translate: none;
  transform: none;
  animation: none !important;
}

/* The seven named offenders, stopped outright rather than merely shortened. */
html[data-gpu="software"] body::before,
html[data-gpu="software"] body::after,
html[data-gpu="software"] .hero-nebula,
html[data-gpu="software"] .hero-portrait,
html[data-gpu="software"] .hero-portrait .portrait-halo,
html[data-gpu="software"] .hero-portrait .portrait-rim,
html[data-gpu="software"] .research-card.featured::after,
html[data-gpu="software"] .research-card.featured > .card-glow {
  animation: none !important;
}

/* ── and the scroll cost, same software-rasteriser case ───────────────────
   Idle was solved by stopping the animations above. Scrolling is a separate
   problem: it is when a backdrop actually changes and has to be re-blurred,
   and when a position:fixed layer has to be repainted against moving
   content. Measured during a continuous scroll on the same 6x-throttled,
   2-core software client:
       as above .....................................  8.6fps
       backdrop-filter off ..........................   15fps
       fixed body::before/::after off ...............   12fps
       both off .....................................   30fps
       both off, plus the starfield canvas hidden ...   30fps
   The canvas is innocent (it is already tiered down and does not repaint on
   scroll), so it keeps drawing. The other two are halved-then-halved again,
   which is why neither alone is enough.

   The glass falls back to the SAME solid #0b1018 the prefers-reduced-
   transparency block already uses, deliberately: dropping blur(16px)
   without raising opacity would leave 76%-opaque panels over a live
   starfield, and this file's contrast audit does not survive that. Reusing
   the audited value keeps text legibility where it was measured.

   What a reader on a software rasteriser loses: the blur behind the panels,
   and the 18px softening on the background wash. What they keep: every
   gradient, colour, border, shadow, the grain, the full layout — and a page
   that scrolls at 30fps instead of 8.6. */
html[data-gpu="software"] .research-card,
html[data-gpu="software"] .project-card,
html[data-gpu="software"] .publication-card,
html[data-gpu="software"] .timeline-content,
html[data-gpu="software"] .contact-panel,
html[data-gpu="software"] .contact-tile,
html[data-gpu="software"] .about-panel,
html[data-gpu="software"] .personal-panel,
html[data-gpu="software"] .link-section,
html[data-gpu="software"] .teaching-panel,
html[data-gpu="software"] .fact-grid div,
html[data-gpu="software"] .tag-cloud span,
html[data-gpu="software"] .header-toggle,
html[data-gpu="software"] .header-container,
html[data-gpu="software"] .cosmic-card,
html[data-gpu="software"] .education-card,
html[data-gpu="software"] .personal-card,
html[data-gpu="software"] .link-card,
html[data-gpu="software"] .glass-panel {
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
  background: #0b1018 !important;
}

/* The two fixed, full-viewport decorative layers. Not hidden — that would
   flatten the page to bare background — but stripped of the two things that
   make them expensive to repaint on every scrolled frame: an 18px blur over
   the whole viewport, and a radial mask. The gradients underneath are
   already soft, so the blur was close to redundant anyway. */
html[data-gpu="software"] body::after {
  filter: none !important;
  transform: none !important;
}
html[data-gpu="software"] body::before {
  mask-image: none !important;
  -webkit-mask-image: none !important;
  opacity: 0.5 !important;   /* the mask was what kept it off the edges */
}

/* ── Talks, awards, observing and service ─────────────────────────────────
   CV-style rows inside one glass panel: a fixed date column so the titles
   line up, hairline rules between entries instead of one card per entry. */
.cv-panel {
  --card-h: var(--h-violet); --card-l: 0.78; --card-c: 0.115;
  padding: clamp(22px, 3vw, 34px);
}

.cv-columns {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  gap: 18px;
  align-items: start;
}

.cv-group + .cv-group {
  margin-top: 28px;
}

.cv-group h3 {
  margin-bottom: 6px;
  font-size: clamp(1.15rem, 1.8vw, 1.45rem);
  font-weight: 700;
  color: color-mix(in oklab, var(--a-cyan) 52%, #ffffff);
}

.cv-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.cv-list li {
  display: grid;
  grid-template-columns: 7.5rem minmax(0, 1fr);
  gap: 4px 18px;
  padding: 12px 0;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.cv-list li:first-child {
  border-top: 0;
}

.cv-date {
  padding-top: 2px;
  color: var(--accent);
  font-size: 0.86rem;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.cv-list p {
  margin: 0;
}

.cv-title {
  color: var(--ink-text-2);
  font-weight: 600;
}

.cv-venue {
  color: var(--ink-muted);
  font-size: 0.94rem;
}

@media (max-width: 1100px) {
  .cv-columns {
    grid-template-columns: 1fr;
  }
}

/* Phones: the date moves above its title rather than squeezing it. */
@media (max-width: 640px) {
  .cv-list li {
    grid-template-columns: 1fr;
  }
}
