:root {
  /* The whole site is designed against a fixed white background/dark text
     palette (--bg, --text, --gray-* etc. below are never swapped for a
     dark variant) — without this, browsers/OS that default to dark mode
     auto-darken form controls, native UI, and scrollbar chrome against
     that fixed-light design and it visibly breaks. */
  color-scheme: light;
  --bg: #ffffff;
  --text: #101828;
  --accent: #f5c400;

  /* Figma grid: 1440 base, 12 columns, 96 margin, 24 gap. --page-max is no
     longer applied as a max-width cap anywhere (see the "widescreen
     scaling" block below the hero rules) — it's kept only as the design's
     reference width, e.g. for .design-grid-overlay's frame. */
  --page-max: 1440px;
  --content-width: 1248px;
  --edge-margin: 96px;
  --grid-gap: 24px;
  --grid-columns: 12;
  /* No upper cap: below 1440 this is identical to the old
     clamp(16px, 6.67vw, 96px) (6.67vw never exceeds 96px until the
     viewport passes 1440px anyway), but above 1440px it keeps growing
     with the viewport instead of freezing — see the "widescreen scaling"
     comment block further down for why.
     vw, not %: % resolves against whatever element it's used on's own
     containing block. That's fine at the top level (.page-container etc.
     all genuinely span the page's full content width), but wrong for
     anything read deeper inside already-inset content (e.g. a label
     inset inside an already-inset sidebar column), where the local
     containing block is narrower than the true page content width. vw
     always means the same absolute thing regardless of nesting depth,
     which --col-w below relies on. This is only reliable because html
     (not body — see its own comment) reserves a stable scrollbar gutter,
     so vw and the actual rendered content width agree. */
  --side-padding: max(16px, calc(var(--edge-margin) / 1440px * 100vw));
  /* Precise column math, for aligning an element to an exact column edge
     instead of guessing a fixed px inset that only happens to look right
     at 1440px. Built entirely from vw (via --side-padding above), so —
     unlike a %-based version — this resolves correctly no matter how
     deeply nested the element using it is. --grid-gap stays flat (24px,
     per the Figma spec) as the viewport grows; only --col-w itself grows.
     Usage, given N = a 1-indexed column number:
       left edge of column N:  calc((N - 1) * (var(--col-w) + var(--grid-gap)))
       right edge of column N: calc(N * var(--col-w) + (N - 1) * var(--grid-gap))
     e.g. .student-card's 318px inset === exactly the left edge of column 4:
       3 * (var(--col-w) + var(--grid-gap)) — see .student-card below. */
  --content-w: calc(100vw - 2 * var(--side-padding));
  --col-w: calc((var(--content-w) - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns));

  /* Figma text colors */
  --gray-1: #333333;
  --gray-2: #4f4f4f;
  --gray-3: #828282;
  --gray-4: #bdbdbd;
  --gray-6: #f2f2f2;

  --hero-bg: #111114;
  --news-card-title-weight: 600;

  --font-body: "Geist", sans-serif;
  --font-display: "Archivo", sans-serif;

  /* Geist Medium/16 is the most-reused text style across the design — the
     site-wide base, overridden per component (headings, eyebrows, etc). */
  --text-base-weight: 500;
  --text-base-size: 16px;
  --text-base-line: 1.45;
  --text-base-color: var(--gray-3);

  --header-height: 64px;

  /* Every divider/separator line on the site uses this — change it once here.
     (Named "dash line" as its identifier, not because it renders dashed —
     it's drawn as a plain solid bar.) */
  --divider-color: rgba(0, 0, 0, 0.1);
  --divider-width: 1px;
}

* {
  box-sizing: border-box;
  /* Chrome/Edge otherwise paint a rounded, semi-transparent gray rect over
     any tappable element (links, buttons) on the moment of interaction —
     it's meant for touch but lingers visibly on some desktop input/
     rendering paths too, which is what made .site-header__contact look
     like it had its own faint background/blur next to the plain nav
     links. */
  -webkit-tap-highlight-color: transparent;
}

/* No global scroll-behavior: smooth — the momentum wheel scroll in
   script.js writes document.documentElement.scrollTop every frame, and that
   property assignment is itself subject to scroll-behavior, so a global
   smooth would fight the JS-driven motion every frame (the exact
   pile-up/stutter bug from earlier). The one in-page anchor jump (see the
   click handler at the top of script.js) already requests smooth directly
   via scrollIntoView({ behavior: 'smooth' }), which works regardless of
   this property. */

/* Safety net for the hero's full-bleed trick below: 100vw includes the
   scrollbar's own width in its calculation while the visual viewport (100%)
   doesn't, so on any page tall enough to show a vertical scrollbar, a
   100vw-wide element is actually wider than the visible area — producing a
   horizontal scrollbar. Since the hero is a solid background bleeding to
   the edges anyway, clipping that sliver is invisible; it just stops the
   page from ever measuring wider than the viewport. */
html,
body {
  overflow-x: hidden;
}

/* Reserves the vertical scrollbar's gutter width on every page, even ones
   short enough not to need scrolling. Without this, --side-padding and
   every other %-based inset below resolve against a content width that's
   ~16px narrower on tall (scrolling) pages than on short (non-scrolling)
   ones. html only, not body: overflow-x:hidden on both forces the
   longhand `overflow-y` to compute to `auto` wherever it's still
   `visible` (the interop rule that keeps overflow-x/-y from disagreeing
   about whether a box scrolls) — including on body, which otherwise
   never establishes its own scroll container. With scrollbar-gutter on
   BOTH html and body, that made body reserve a second, redundant gutter
   on top of html's, shrinking .page-container's actual width by roughly
   double a real scrollbar's — which is what was reading as the whole
   page's content sitting shifted off the grid. */
html {
  scrollbar-gutter: stable;
}

/* Unknown custom elements default to display:inline — this keeps them from
   generating their own box, so the real <header>/<footer> inside lay out
   exactly as if they were direct children of <body>. */
site-header,
site-footer {
  display: contents;
}

/* background-image (not just background-color) is the actual fix here —
   some mobile browsers' forced-dark mode (Samsung Internet's in particular)
   still repaints a page's flat CSS background-color even when color-scheme:
   light above says not to. That repaint targets computed *colors*, not
   images: a real image (even this trivial tiled 1x1 white square, done as
   a tiny inline SVG so there's no extra file request) reads as a photo to
   that heuristic and gets left alone, the same way it already leaves real
   photos on the page untouched. background-color stays too, as the actual
   fallback for the handful of browsers that don't support background-image
   data URIs at all. */
body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font-body);
  font-weight: var(--text-base-weight);
  font-size: var(--text-base-size);
  line-height: var(--text-base-line);
  background-color: var(--bg);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3Crect width='1' height='1' fill='%23fff'/%3E%3C/svg%3E");
  background-repeat: repeat;
  background-size: 1px 1px;
  color: var(--text);
}

img {
  max-width: 100%;
}

/* Page load reveal. Three distinct treatments:
   - Body content (.page-container) starts fully hidden and blurred, then
     fades/unblurs in.
   - The header BAR fades in and drops slightly from above — this motion is
     wanted, not the problem (see .site-header__links below for the actual
     complaint about horizontal jumping).
   - The logo (.site-header__logo-mark, the colored SVG, and
     .site-header__logo-invert, the A/M/D blend layer — see the big comment
     near its rule below for why that one's a separate top-level sibling in
     the first place) fades in with a blur instead of a drop. It used to
     share the header bar's reveal-fade/reveal-drop (translateY) pair, but
     the two are genuinely separate elements each running their own
     animation instance, and under real jank/dropped frames they can fall a
     frame or two out of sync — for a translateY drop, that reads as the
     A/M/D shape visibly detaching from the rest of the logo mid-animation.
     A blur-only reveal has no position to desync: if the two instances
     drift apart under lag, at worst one finishes blurring in a beat before
     the other, which doesn't read as breakage the way a spatial offset
     does. Isolation-wise this is the same as before: mix-blend-mode on
     .site-header__logo-invert only breaks via an animated ANCESTOR, not via
     an animation (this one included) on the blended element's own layer —
     confirmed by testing — so this is safe. */
@keyframes reveal-fade-blur {
  from {
    opacity: 0;
    filter: blur(12px);
  }
  to {
    opacity: 1;
    filter: none;
  }
}

@keyframes reveal-fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes reveal-drop {
  from {
    transform: translateY(-16px);
  }
  to {
    transform: none;
  }
}

.page-container {
  animation: reveal-fade-blur 0.55s ease-out both;
}

.site-header {
  animation:
    reveal-fade 0.4s ease-out both,
    reveal-drop 0.4s ease-out both;
}

.site-header__logo-mark,
.site-header__logo-invert {
  animation: reveal-fade-blur 0.4s ease-out both;
}

@media (prefers-reduced-motion: reduce) {
  .page-container,
  .site-header,
  .site-header__logo-mark,
  .site-header__logo-invert {
    animation: none;
  }
}

/* Reusable divider: sized to whatever container it's placed in (100% of its
   parent), never the raw viewport — drop it inside a padded container to
   inherit that padding, the way .site-footer does. */
.dash-line {
  width: 100%;
  height: var(--divider-width);
  background: var(--divider-color);
  border: none;
}

/* Widescreen scaling: the site was designed at a 1440px reference width
   with a 96px side margin. Rather than freeze at max-width: 1440px and
   center (which reads as "broken" on real desktop monitors wider than
   that — the design just shrinks into the middle with dead space on both
   sides), every container below stays fluid (no max-width) and its side
   padding keeps growing past 96px via --side-padding's vw-based formula
   above 1440px too. Font sizes are untouched (still flat px, per the
   original design) — only spacing/columns/images grow with the viewport,
   the way an ordinary responsive site handles anything wider than its
   design reference. Below 1440px nothing changed: --side-padding resolves
   to the exact same values as the old clamp(16px, 6.67vw, 96px) did. */

/* Every page's content (header excluded) sits in this: 96(+) side
   padding, 64 top, 0 bottom. Direct children are this page's stacked
   content containers (title, footer, and whatever's added later), gap 32
   between them. */
.page-container {
  margin: 0 auto;
  padding: 64px var(--side-padding) 0;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

/* Title always sits in a 96-tall container; width fills the padded area
   (the flex column's default stretch) unless a page says otherwise. */
.page-title-container {
  height: 96px;
  display: flex;
  align-items: center;
}

.page-title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 48px;
  color: var(--gray-1);
}

.page-title__dot {
  color: var(--accent);
}

/* Hero — full-bleed regardless of ancestor padding/max-width (the
   left:50%/margin:-50vw trick breaks it out of .page-container entirely),
   and exactly one viewport tall so it always fills the screen on load. The
   negative top margin cancels .page-container's 64px top padding so the
   hero starts at the true top of the viewport, behind the glass header. */
.hero {
  position: relative;
  left: 50%;
  right: 50%;
  width: 100vw;
  margin-left: -50vw;
  margin-right: -50vw;
  margin-top: -64px;
  /* svh (falls back to vh on browsers without it) is the viewport height
     with mobile browser toolbars already accounted for as shown, not
     whatever's visible at this exact instant — vh alone keeps changing
     value as the address bar hides/shows while scrolling, which combined
     with the scroll-linked parallax transform below (see script.js) is a
     real source of visible jitter on mobile specifically. */
  height: 100vh;
  height: 100svh;
  overflow: hidden;
  background: var(--hero-bg);
  display: flex;
  align-items: center;
  /* Explicit (not auto) so it's always unambiguously beneath .home-news —
     see .home-news below for why "positioned but z-index:auto" wasn't
     reliable. */
  z-index: 1;
}

/* Wraps the NEWS title + grid so the two scroll together as one solid
   sheet that slides up and fully covers the (slower, parallaxed) hero
   behind it — rather than the title/grid overlapping the hero as two
   separate loose items. An opaque background plus a z-index higher than
   .hero's guarantees it always paints on top: without an explicit z-index
   here, this being a plain non-positioned box while .hero is `position:
   relative` meant .hero (positioned) could paint OVER this (non-positioned)
   regardless of DOM order — positioned elements paint after non-positioned
   ones in the same stacking context, which is what was producing the
   "NEWS goes behind hero" flicker. The upward box-shadows are the "this is
   physically elevated above the hero" cue: a tight dark one right at the
   seam plus a softer, wider one for ambient depth.
   Full-bleed (same left:50%/100vw/-50vw trick as .hero) so its white sheet
   and shadow span the entire viewport width, not just the padded content
   column — .home-news__inner re-applies the normal max-width/side-padding
   for the actual title/grid content, same relationship as .hero/
   .hero__content. */
.home-news {
  position: relative;
  left: 50%;
  right: 50%;
  width: 100vw;
  margin-left: -50vw;
  margin-right: -50vw;
  z-index: 2;
  /* Same image-not-color trick as body's own background above (see its
     comment) — this is its own opaque sheet (has to be, to fully cover the
     parallaxed hero scrolling behind it), so it needs the same forced-dark
     resistance independently of body. */
  background-color: var(--bg);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3Crect width='1' height='1' fill='%23fff'/%3E%3C/svg%3E");
  background-repeat: repeat;
  background-size: 1px 1px;
}

/* The "elevated above the hero" shadow lives on a zero-height pseudo-element
   pinned to .home-news's own TOP edge, not as a box-shadow on .home-news
   itself. Tuning a box-shadow's blur/offset/spread so it never bleeds past
   the element's own bottom edge (reaching the footer below it) turned out
   to be fragile — the actual rendered blur extent isn't guaranteed to match
   that arithmetic exactly. Anchoring it to a 0-height strip at the top
   instead means even its worst-case downward bleed lands well inside
   .home-news's own (opaque, several-hundred-px-tall) box, nowhere near its
   bottom edge, regardless of exactly how far the blur really reaches. */
.home-news::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
  box-shadow:
    0 -20px 28px -12px rgba(0, 0, 0, 0.6),
    0 -48px 80px -36px rgba(0, 0, 0, 0.4);
}

.home-news__inner {
  margin: 0 auto;
  padding: 64px var(--side-padding) 0;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.hero__spotlight {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Two stacked radial glows: a wide soft white wash for ambient spread, plus
     a tighter, warm accent-tinted core right at the cursor — the second one
     is what sells "the light source itself" rather than a plain torch. */
  background:
    radial-gradient(
      circle 700px at var(--spot-x, 50%) var(--spot-y, 40%),
      rgba(255, 255, 255, 0.3),
      rgba(255, 255, 255, 0.1) 40%,
      transparent 72%
    ),
    radial-gradient(
      circle 240px at var(--spot-x, 50%) var(--spot-y, 40%),
      rgba(245, 196, 0, 0.22),
      transparent 70%
    );
}

.hero__content {
  position: relative;
  z-index: 1;
  width: 100%;
  margin: 0 auto;
  padding: 0 var(--side-padding);
}

.hero__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 104px;
  line-height: 1.05;
  color: var(--gray-6);
  /* Updated every mousemove (see script.js) to fake a light source casting
     the text's shadow away from the cursor. Several layers at increasing
     multiples of the same offset (instead of one soft shadow) is what
     actually reads as a long cast trail rather than a plain drop-shadow —
     a crisp near-black contact shadow close to the letters, deepening and
     softening with distance; the last layer is a faint counter-highlight on
     the side facing the light, for the pseudo-3D "programmed by light" look. */
  text-shadow:
    calc(var(--shadow-x, 14px) * 1) calc(var(--shadow-y, 22px) * 1) 3px rgba(0, 0, 0, 0.75),
    calc(var(--shadow-x, 14px) * 2) calc(var(--shadow-y, 22px) * 2) 10px rgba(0, 0, 0, 0.6),
    calc(var(--shadow-x, 14px) * 3.5) calc(var(--shadow-y, 22px) * 3.5) 20px rgba(0, 0, 0, 0.45),
    calc(var(--shadow-x, 14px) * 5.5) calc(var(--shadow-y, 22px) * 5.5) 32px rgba(0, 0, 0, 0.3),
    calc(var(--shadow-x, 14px) * 8) calc(var(--shadow-y, 22px) * 8) 44px rgba(0, 0, 0, 0.16),
    calc(var(--shadow-x, 14px) * -0.6) calc(var(--shadow-y, 22px) * -0.6) 8px rgba(255, 255, 255, 0.2);
}

.hero__title-dot {
  color: var(--accent);
  /* text-shadow is inherited, and since this dot is its own span (a
     separate inline box painted AFTER "PROGRAMMED"'s box, not part of the
     same text run), its shadow was painting as its own later paint step —
     on top of the already-painted "D" glyph before it, whenever the shadow
     direction pointed back that way. Every letter's shadow needs to land
     behind text, never over a neighboring letter — the fix is just not
     casting one here at all. */
  text-shadow: none;
}

.hero__subtitle {
  margin: 32px 0 0;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
  max-width: 640px;
}

@media (max-width: 900px) {
  .hero__title {
    font-size: clamp(48px, 10vw, 104px);
  }

  /* --shadow-x/-y's 14px/22px fallback (see .hero__title's text-shadow)
     was sized for the 104px desktop title in a 1248px-wide column, where
     the widest shadow layer's 8x offset has plenty of room before it'd
     ever reach an edge. It's a flat px value, not tied to font-size, so it
     doesn't shrink along with the title's own mobile clamp() above — on a
     narrow phone that same offset pushes the shadow trail almost to the
     viewport edge. touch screens never fire the pointermove that would
     otherwise recenter it (see script.js's isTouch check), so this resting
     default is effectively permanent there, not just a brief pre-first-move
     flash like on desktop. Scaling it down roughly with the title's own
     mobile size keeps the trail inside the frame at rest. */
  .hero {
    --shadow-x: 6px;
    --shadow-y: 10px;
  }
}

/* NEWS preview grid (Home) — built on the real 12-column/24-gutter grid
   (repeat(var(--grid-columns), 1fr) + var(--grid-gap), the exact same
   track definition as .design-grid-overlay__inner) instead of an
   unrelated ad hoc repeat(3, 1fr) + 32px gap — that mismatched track
   count/gutter meant the cards' edges never actually landed on the
   design grid's column lines. Each card spans 4 of the 12 columns (1-4 /
   5-8 / 9-12), so both the outer edges AND the two internal dividers line
   up with real column boundaries. Row gap (32px, between the two rows of
   cards) is unrelated to column alignment and stays as designed. */
.news-grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: 32px var(--grid-gap);
}

.news-card {
  grid-column: span 4;
}

@media (max-width: 900px) {
  .news-grid {
    grid-template-columns: repeat(2, 1fr);
    gap: 32px;
  }

  .news-card {
    grid-column: auto;
  }
}

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

.news-card {
  display: flex;
  flex-direction: column;
  /* Starts hidden/offset; news-render.js's IntersectionObserver adds
     .is-visible once the card is actually scrolled into view (each card's
     transition-delay is staggered there, per column). */
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.news-card.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* One photo renders as a plain image; more than one gets this same frame
   with a swipeable strip inside (news-render.js's initCarousel wires the
   interactive bits once this markup exists — see its own comment for why
   native scroll-snap drives the actual dragging instead of custom pointer
   tracking). position: relative anchors the arrows/counter/dots, which are
   absolutely positioned overlays on top of the track. */
.news-carousel {
  position: relative;
  overflow: hidden;
  border-radius: 4px;
  background: var(--gray-4);
}

.news-card__frame {
  aspect-ratio: 3 / 2;
}

.news-carousel__track {
  display: flex;
  width: 100%;
  height: 100%;
  overflow-x: auto;
  /* proximity, not mandatory: mandatory forces a hard snap the instant you
     let go anywhere mid-drag, which reads as stiff/forceful — proximity
     only pulls the slide into place once you're already nearly there, so a
     drag feels free right up until it settles. Programmatic moves (the
     arrows/dots, see news-render.js's animateScrollTo) don't rely on this
     at all — they drive scrollLeft with their own eased animation, so they
     land precisely regardless of which snap type is set here. */
  scroll-snap-type: x proximity;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.news-carousel__track::-webkit-scrollbar {
  display: none;
}

/* overflow: hidden here (not just on the outer .news-carousel frame) is
   load-bearing: each slide sits edge-to-edge with its neighbors inside the
   track, so the hover-zoom below — scale(1.06) on the image that fills
   this slide — would otherwise bleed a few percent of its width sideways
   into the ADJACENT slide's spot (the outer frame's own overflow: hidden
   only clips at the far left/right of the whole track, not at each
   slide's own boundary). Clipping per-slide keeps the zoom contained to
   the one photo actually being hovered. */
.news-carousel__slide {
  position: relative;
  flex: 0 0 100%;
  width: 100%;
  height: 100%;
  overflow: hidden;
  scroll-snap-align: start;
}

.news-card__image {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
  transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}

/* The slide clips the scale so the zoom stays inside this one photo
   instead of growing over its neighbors or the title/text below. */
.news-card:hover .news-card__image {
  transform: scale(1.06);
}

/* Photo credit — a small caption over the image itself (bottom-left, on a
   dark scrim) rather than a separate line of text below it, since it's
   only ever a few words and most photos won't have one at all. */
.news-carousel__credit {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 20px 12px 8px;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent);
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 12px;
  color: rgba(255, 255, 255, 0.85);
  pointer-events: none;
}

/* The click target is the WHOLE side of the frame, not just the small
   round icon — each button spans the full height and half the width (so
   the two together tile the entire frame, left half = prev, right half =
   next), with the visible circle+chevron just a centered, purely visual
   span (.news-carousel__arrow-icon) inside it. A plain <button> with no
   touch-action/preventDefault here doesn't block the native swipe this
   frame already supports: a touch that moves is a scroll gesture and never
   reaches this button's own click handling, browser-side, regardless of
   what's visually stacked on top. */
.news-carousel__arrow {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 42%;
  display: flex;
  align-items: center;
  border: none;
  background: transparent;
  color: #fff;
  cursor: pointer;
}

/* Whole-side hover tint — a soft directional gradient, not a flat fill, so
   it reads as "this side is active" without a hard block of color cutting
   the photo in half. Darker than a bare hover tint would need on its own
   (0.32, not ~0.18) because it's now the ONLY thing giving the arrow glyph
   itself contrast — see .news-carousel__arrow-icon below for why that
   glyph no longer carries its own dark circle backdrop. */
.news-carousel__arrow--prev {
  left: 0;
  justify-content: flex-start;
  background: linear-gradient(to right, rgba(0, 0, 0, 0.32), transparent);
  opacity: 0;
  transition: opacity 0.2s ease;
}

.news-carousel__arrow--next {
  right: 0;
  justify-content: flex-end;
  background: linear-gradient(to left, rgba(0, 0, 0, 0.32), transparent);
  opacity: 0;
  transition: opacity 0.2s ease;
}

.news-carousel__arrow:hover {
  opacity: 1;
}

/* Just the glyph — no circular dark backdrop behind it. The gradient above
   already darkens this whole side enough on its own for a plain white
   chevron to read against any photo, so the extra circle was a redundant
   (and, per feedback, fussier-looking) second layer of contrast. Hidden
   until the frame (not just this one side) is hovered — same "swipe
   already works, this is just a desktop affordance" reasoning as before —
   but always shown at a lower opacity on touch/coarse-pointer devices,
   where there's no hover to reveal it. */
.news-carousel__arrow-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  margin: 0 8px;
  flex: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}

.news-carousel:hover .news-carousel__arrow-icon {
  opacity: 1;
}

@media (hover: none) {
  .news-carousel__arrow-icon {
    opacity: 0.75;
  }
}

.news-carousel__counter {
  position: absolute;
  top: 8px;
  right: 8px;
  padding: 3px 8px;
  border-radius: 999px;
  background: rgba(16, 16, 20, 0.5);
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 12px;
  color: #fff;
  letter-spacing: 0.2px;
}

.news-carousel__dots {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}

.news-carousel__dot {
  width: 6px;
  height: 6px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.5);
  cursor: pointer;
  transition: background 0.15s ease, transform 0.15s ease;
}

.news-carousel__dot.is-active {
  background: #fff;
  transform: scale(1.3);
}

/* Centered magnifying-glass affordance — hovering the slide (anywhere on
   the photo, not some precise center-only hit zone) reveals it sitting at
   the slide's actual center; clicking it (news-render.js's initZoom) opens
   the full photo in .news-lightbox below. Sits at z-index-less default
   stacking but wins hit-testing over the arrow zones beneath it simply by
   being later in the DOM (see newsCarouselMarkup) — the arrows cover the
   left/right 42% each, this sits in the untouched middle 16%, so there's
   no overlap to arbitrate in practice. */
.news-carousel__zoom {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(0.85);
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: transparent;
  color: #fff;
  cursor: pointer;
  opacity: 0;
  /* A drop-shadow on the glyph itself, not a solid circle behind it — per
     feedback, the circle read as one visual layer too many. filter (not
     box-shadow/text-shadow) is what lets the shadow trace the actual
     magnifying-glass stroke shape rather than the button's own square/
     round box, which is what a plain box-shadow would silhouette instead. */
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.65));
  transition: opacity 0.2s ease, transform 0.2s ease;
  /* Hidden means truly inert while invisible, not just see-through — a
     touch tap over this circle before it's revealed (see .is-zoom-visible
     below) needs to fall through to the slide underneath instead of
     silently landing on an invisible button. */
  pointer-events: none;
}

.news-carousel__slide:hover .news-carousel__zoom,
.news-carousel__slide.is-zoom-visible .news-carousel__zoom {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
  pointer-events: auto;
}

/* Full photo, centered on screen — one shared overlay for every carousel
   on the page (news-render.js's getNewsLightbox builds it once, lazily).
   visibility + a delayed transition (only on the way IN, not out — see the
   two separate transition lines) keeps it out of the tab order and
   unclickable while closed without an abrupt disappear/appear: closing
   fades out first and only then leaves the layout, opening does the
   reverse, so it's never briefly "invisible but still there" for the
   image scale below to jump against. */
.news-lightbox {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px;
  background: rgba(10, 10, 12, 0);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), background 0.4s ease, visibility 0s linear 0.4s;
}

.news-lightbox.is-open {
  opacity: 1;
  visibility: visible;
  background: rgba(10, 10, 12, 0.85);
  transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), background 0.4s ease, visibility 0s linear 0s;
}

/* html, not body: freezing scroll via a class on the root avoids fighting
   .page-container's own load-in animation/scroll listeners, which read
   window.scrollY rather than any one element's own scroll offset. */
html.news-lightbox-open {
  overflow: hidden;
}

/* "원본 크기" reads here as "the whole, uncropped photo" rather than the
   card/post frame's own cover-cropped view — scaled to fit the viewport
   (max-width/height: 90vw/90vh) since several of these source photos are
   4000px+ on their long edge and would only ever show one corner of
   themselves at true native size on an actual screen. object-fit: contain
   is what lets a portrait photo and a landscape one both just fit, at
   whatever size that is, without either cropping or distorting. */
.news-lightbox__image {
  max-width: 90vw;
  max-height: 90vh;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 4px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
  transform: scale(0.94);
  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}

.news-lightbox.is-open .news-lightbox__image {
  transform: scale(1);
}

.news-lightbox__close {
  position: absolute;
  top: 24px;
  right: 24px;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  cursor: pointer;
  transition: background 0.15s ease;
}

.news-lightbox__close:hover {
  background: rgba(255, 255, 255, 0.2);
}

/* The whole content box is the click target (not just the toggle), so it's
   a <button> — reset to look like the plain block it visually is. */
.news-card__body {
  width: 100%;
  padding: 16px 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  background: transparent;
  border: none;
  text-align: left;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

.news-card__title {
  margin: 0;
  font-family: var(--font-body);
  font-weight: var(--news-card-title-weight);
  font-size: 24px;
  color: var(--gray-1);
}

/* Two different chip styles — a colored dot for "what kind of post"
   (news-tag--category, one of the four NEWS_CATEGORIES) and a plain
   outline-free fill for "who it's about" (news-tag--people, a name or
   "APMD" for the whole lab) — so the two kinds of information read as
   visually distinct at a glance instead of a row of identical pills. */
.news-tags {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

.news-tag {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border-radius: 999px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 13px;
  line-height: 1.2;
  white-space: nowrap;
}

.news-tag--category {
  border: 1px solid var(--gray-4);
  color: var(--gray-2);
}

.news-tag__dot {
  width: 6px;
  height: 6px;
  flex: none;
  border-radius: 50%;
}

.news-tag--people {
  background: var(--gray-6);
  color: var(--gray-3);
}

.news-card__meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.news-card__date {
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

.news-card__toggle {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--gray-3);
  transition: color 0.15s ease;
}

/* Hover (not yet clicked): accent, as an affordance the card is
   clickable. Open (after the click, icon rotated + "more" showing): gray-4
   instead — same specificity as the :hover rule above, so source order
   after it is what makes it win throughout the open state, even though the
   pointer is still over the card the whole time (open only ever happens
   while hovered — see news-render.js's mouseleave auto-collapse). */
.news-card:hover .news-card__toggle {
  color: var(--accent);
}

.news-card.is-open .news-card__toggle {
  color: var(--gray-4);
}

/* Two SEPARATE SVGs crossfading, not one down-chevron rotated -90° and
   scaled down into a ">" via CSS transform. That transform approach kept
   reading as misaligned/too far from "more" no matter how the scale
   factor or gap was tuned, because rotating+scaling a 16x10 box down
   leaves an invisible margin INSIDE that box around wherever the visible
   glyph actually lands — align-items: center on .news-card__toggle only
   centers the boxes, and a box bigger than its own visible content isn't
   centered where it looks like it should be. Each chevron here is drawn
   at its own real final size (16x10 down-arrow at rest, a separately
   authored 7x11 right-arrow — same path shape as
   .research-card__more-chevron — once open), so each one's box IS its
   visible content and centers exactly. .news-card__chevron-stack is
   sized to the larger (down) chevron's footprint so the toggle's own
   layout width never jumps; the two chevrons sit absolutely positioned
   on top of each other within it and simply swap opacity. */
/* translateY(2.4px): align-items: center on .news-card__toggle centers
   this stack against "more"'s LINE BOX, not its visible ink — a 16px/500
   line-height box runs ~23px tall while "more" (no ascenders/descenders)
   only draws ~10px of actual ink, sitting a bit below that box's center.
   2.4px is that gap, measured directly for this exact font/weight/size
   (getBoundingClientRect vs. Canvas measureText's
   actualBoundingBoxAscent/Descent) — see .research-card__more-chevron's
   own comment, same text style, same fix. */
.news-card__chevron-stack {
  position: relative;
  width: 16px;
  height: 11px;
  flex: none;
  transform: translateY(2.4px);
}

.news-card__chevron {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  transition: opacity 0.25s ease;
}

.news-card__chevron--down {
  opacity: 1;
}

.news-card__chevron--right {
  left: 4.5px; /* (16 - 7) / 2 — centered within the stack's 16px width */
  opacity: 0;
}

.news-card.is-open .news-card__chevron--down {
  opacity: 0;
}

.news-card.is-open .news-card__chevron--right {
  opacity: 1;
}

.news-card__more {
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  white-space: nowrap;
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  transform: translateX(10px);
  transition: max-width 0.3s ease 0.1s, opacity 0.3s ease 0.1s, transform 0.3s ease 0.1s;
}

.news-card.is-open .news-card__more {
  max-width: 60px;
  opacity: 1;
  transform: translateX(0);
}

.news-card__excerpt-wrap {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.35s ease;
}

.news-card.is-open .news-card__excerpt-wrap {
  max-height: 6em;
}

.news-card__excerpt {
  margin: 8px 0 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  line-height: 1.45;
  color: var(--gray-3);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Placeholder tone behind news images so a missing file reads as an empty
   slot rather than a broken-image icon. */
.news-card__image,
.news-post__image {
  background: var(--gray-4);
}

/* News page — full post list */
.news-list {
  display: flex;
  flex-direction: column;
  gap: 64px;
}

/* Photo on the left, text on the right — a full-width photo banner (the
   previous layout) read as enormous stacked on top of the text, especially
   once a post's photo was a tall/portrait one; a fixed-width side column
   keeps every post's photo the same reasonable size regardless of its own
   aspect ratio, and keeps the text from ever sitting underneath a huge
   image. align-items: flex-start (not stretch) so a short post's photo
   doesn't get stretched tall to match a long one's text column, and a long
   post's text isn't clipped to a short photo's height either — each side
   just sizes to its own content/aspect-ratio. */
.news-post {
  display: flex;
  align-items: flex-start;
  gap: 40px;
  padding-bottom: 64px;
  border-bottom: var(--divider-width) solid var(--divider-color);
  /* Keeps the fixed header from covering the top of the post when a
     Home NEWS card links straight to news.html#<id>. */
  scroll-margin-top: 84px;
}

.news-post:last-child {
  border-bottom: none;
  padding-bottom: 0;
}

/* Pinned to the true grid, not a round px guess: column 1's left edge is
   the content edge itself (.news-post carries no extra inset the way
   .professor does), so this only needs to reach column 5's right edge —
   right edge of column N = N * col-w + (N-1) * gap (the same formula
   --side-padding's own comment documents, here with N=5). max(506px, …)
   keeps it flat at/below 1440 and lets it scale past that, the same
   "unchanged below 1440, scales past it" convention used everywhere else
   this pattern appears (--side-padding, .student-card, .professor). */
.news-post__frame {
  flex: 0 0 max(506px, calc(5 * var(--col-w) + 4 * var(--grid-gap)));
  width: max(506px, calc(5 * var(--col-w) + 4 * var(--grid-gap)));
  aspect-ratio: 4 / 3;
  border-radius: 8px;
}

.news-post__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.news-post__body {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

@media (max-width: 900px) {
  .news-post {
    flex-direction: column;
  }

  .news-post__frame {
    width: 100%;
    aspect-ratio: 3 / 2;
  }
}

.news-post__title {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 32px;
  color: var(--gray-1);
}

.news-post__date {
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

.news-post__paragraph {
  margin: 8px 0 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  line-height: 1.7;
  color: var(--gray-2);
  /* body's array already splits on blank lines into separate paragraphs
     (see news-render.js) — this only matters for a single \n a paragraph
     itself contains (e.g. the admin tool's plain textarea preserving a
     line break the user typed without a full blank line after it), which
     HTML otherwise collapses to a space by default. */
  white-space: pre-line;
}

/* Header — fixed, 1440x64, glass background */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: var(--header-height);
  z-index: 1000;

  /* No blur/tint directly on .site-header itself. That lives on
     .site-header__glass-blur instead — a REAL sibling element (added
     right after </header> in components.js), not a ::before/::after
     pseudo-element of this one. That's load-bearing, not stylistic: this
     element carries an entrance animation (see "animation:" below), and
     confirmed by isolated testing, an animated ancestor — even a plain
     transform/opacity one, fill-mode: both — silently breaks a
     descendant pseudo-element's backdrop-filter. This is the exact same
     category of bug already documented for .site-header__logo-invert and
     .publication-year-nav elsewhere in this file (an animated ancestor
     changing how a descendant's effect resolves) — and the fix is the
     same one already used there: make the affected piece a sibling of
     the animated element instead of living inside it. */
  background: transparent;
}

/* The header's glass is a single REAL sibling element (see .site-header's
   own comment above for why not ::before/::after): a constant, weak
   backdrop blur across the whole bar, fading to fully transparent over
   the last 22px via mask-image — nothing else.

   An SVG feDisplacementMap "lens bend" layer was tried on top of this
   three separate times, each with a differently-shaped bump (a sin(pi*u)
   dome peaking at the band's center; a monotonic ease peaking at the
   bottom edge instead; and finally that same monotonic curve made the
   exact mathematical complement of this mask's own fade, plus its own
   feGaussianBlur so it didn't depend on sampling .glass-blur's output
   through element stacking). All three were reported back as showing the
   same symmetric "blurry → clear → blurry" artifact — the last one
   reported as worse, with an additional stray band beneath it. Three
   materially different implementations producing the identical complaint
   points at the bend mechanism itself being the wrong tool here, not any
   particular curve or wiring choice — so it's gone, permanently, rather
   than attempted a fourth way. This is deliberately just the one, simple,
   monotonic fade: guaranteed not to have a second feature (a peak, a
   slope change, a second element) anywhere inside it for anything to
   collide with.

   The fade itself is still not linear: the stops below sample
   f(t) = sqrt(1 - t²) at t = 0, .2, .4, .6, .8, .9, .95, 1 (t = 0 at
   42px, t = 1 at 64px — only the header's bottom 22px fades at all) —
   the "Convex Circle" surface function kube.io/blog/liquid-glass-css-svg
   describes for a rounded glass edge in cross-section. It has zero slope
   at t = 0, so the flat, fully-opaque top 42px meets the start of the
   fade with no kink, and it stays close to full strength for most of the
   curve before collapsing quickly right at the very end (t = .8 is still
   60%; t = 1 is 0) — a round edge stays close to full thickness until
   right at its outer rim, then thins out fast, rather than at a constant
   rate the whole way down. Blur itself: a constant, weak 4px (matched to
   kube.io's own "search box" preset at Blur Level 4, i.e. feGaussianBlur
   stdDeviation="4" — inspected live through Chrome DevTools protocol).
   No tint, no saturate boost: a flat/solid backdrop gives blur nothing to
   visibly act on, so it stays genuinely invisible over a plain
   background — the actual requirement here, not just an aesthetic
   default. z-index: 999, one below .site-header's own 1000 — sits behind
   the real header bar (so its blur never touches the nav text) but above
   ordinary page content. */
.site-header__glass-blur {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: var(--header-height);
  z-index: 999;
  pointer-events: none;
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  -webkit-mask-image: linear-gradient(
    to bottom,
    black 0,
    black 42px,
    rgba(0, 0, 0, 0.98) 46.4px,
    rgba(0, 0, 0, 0.917) 50.8px,
    rgba(0, 0, 0, 0.8) 55.2px,
    rgba(0, 0, 0, 0.6) 59.6px,
    rgba(0, 0, 0, 0.436) 61.8px,
    rgba(0, 0, 0, 0.312) 62.9px,
    transparent 64px
  );
  mask-image: linear-gradient(
    to bottom,
    black 0,
    black 42px,
    rgba(0, 0, 0, 0.98) 46.4px,
    rgba(0, 0, 0, 0.917) 50.8px,
    rgba(0, 0, 0, 0.8) 55.2px,
    rgba(0, 0, 0, 0.6) 59.6px,
    rgba(0, 0, 0, 0.436) 61.8px,
    rgba(0, 0, 0, 0.312) 62.9px,
    transparent 64px
  );
}


/* position: relative + z-index: 1 — now mostly redundant now that the
   glass haze/edge are separate sibling elements at z-index: 999 (see
   .site-header's own comment for why), since .site-header's own 1000
   already outranks them regardless of what this element's z-index is.
   Left in as a harmless safety net rather than removed. */
.site-header__inner {
  height: 100%;
  margin: 0 auto;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding: 20px var(--side-padding);
  position: relative;
  z-index: 1;
}

/* The logo lives OUTSIDE .site-header so it isn't blurred by the header's
   own backdrop-filter. Its left offset just matches .site-header__inner's
   own --side-padding so it lines up with the header content at any
   viewport width. */
.site-header__logo {
  position: fixed;
  top: 20px;
  /* Simply --side-padding: with .page-container no longer capped at
     --page-max, its content's left edge always sits exactly
     --side-padding in from the real viewport edge — there's no more
     "centered content, dead margin outside it" gap to add back in. */
  left: var(--side-padding);
  z-index: 1001;
  display: flex;
  align-items: center;
  height: 24px;
  text-decoration: none;
}

.site-header__logo-mark {
  height: 24px;
  width: auto;
  display: block;
}

/* The A/M/D paths inside .site-header__logo-mark stay `fill: none` (see
   components.js's inline `.apmd-blend { fill: none }`) — they're kept only
   as the accessible/static shape, not painted. The real A/M/D contrast is
   .site-header__logo-invert, a *sibling* of .site-header__logo (not a
   descendant — see below for why) clipped to the exact same A/M/D outline
   via the `amd-clip` clipPath (defined once in components.js's shared defs
   block) and left to blend for real.

   Blending inline inside the <svg> was tried first and doesn't work: an
   <svg> root always establishes its own isolated stacking context (SVG2
   spec), so anything blended inside it can only blend against other shapes
   in that same <svg>, never the real page — with nothing else behind them
   there, the letters just came out flat white.

   Making it a plain HTML div nested *inside* .site-header__logo (position:
   absolute, inset: 0) was tried second and also doesn't work, for an
   unrelated reason confirmed by testing: a mix-blend-mode element that is a
   DESCENDANT of a position:fixed (or sticky) ancestor doesn't blend with the
   real page at all, regardless of animation/filter/clip-path/anything else
   on that ancestor — Chromium composites fixed/sticky subtrees on their own
   scroll-independent layer that doesn't correctly blend against the
   underlying content. A mix-blend-mode element blends fine when it is
   ITSELF position:fixed with no fixed/sticky ancestor of its own, which is
   why this is a top-level sibling of .site-header__logo (also fixed) rather
   than a child of it — same coordinates, independently fixed. */
.site-header__logo-invert {
  position: fixed;
  top: 20px;
  /* Simply --side-padding: with .page-container no longer capped at
     --page-max, its content's left edge always sits exactly
     --side-padding in from the real viewport edge — there's no more
     "centered content, dead margin outside it" gap to add back in. */
  left: var(--side-padding);
  z-index: 1001;
  width: 112px;
  height: 24px;
  background: #ffffff;
  clip-path: url(#amd-clip);
  mix-blend-mode: difference;
  pointer-events: none;
}

.site-header__nav {
  display: flex;
  align-items: center;
  /* Hug-sized (not a fixed 613px box): the gap is the real constraint, and it
     never drops below 128px no matter how narrow the header gets. */
  gap: 128px;
}

.site-header__links {
  display: flex;
  align-items: center;
  gap: 32px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.site-header__links a,
.site-header__contact {
  font-family: var(--font-body);
  font-weight: var(--text-base-weight);
  font-size: var(--text-base-size);
  line-height: var(--text-base-line);
  color: var(--text-base-color);
  text-decoration: none;
  white-space: nowrap;
  transition: color 0.15s ease;
}

/* Resting state for the page you're currently on: accent color, no
   underline. */
.site-header__links a[aria-current="page"],
.site-header__contact[aria-current="page"] {
  color: var(--accent);
}

.site-header__links a {
  position: relative;
}

.site-header__links a::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -4px;
  width: 100%;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.25s ease;
}

.site-header__links a:hover,
.site-header__links a:focus-visible {
  color: var(--accent);
}

.site-header__links a:hover::after,
.site-header__links a:focus-visible::after {
  transform: scaleX(1);
}

/* Plain inline, not inline-flex: Chromium renders text inside a flex
   formatting context with noticeably softer antialiasing than a plain
   inline run (confirmed by isolated pixel comparison — same font/color/
   size, only the flex display differed) — with every other nav item being
   a plain <a>, this was the one link that looked "blurry" next to them.
   The icon's gap is now a margin instead of a flex gap. */
.site-header__contact {
  display: inline;
}

.site-header__contact-plus {
  display: inline-block;
  position: relative;
  vertical-align: middle;
  margin-left: 6px;
  width: 13px;
  height: 13px;
  opacity: 0;
  transform: rotate(-35deg) scale(0.4);
  transition: opacity 0.25s ease-out, transform 0.25s ease-out;
}

.site-header__contact-plus::before,
.site-header__contact-plus::after {
  content: "";
  position: absolute;
  background: currentColor;
}

.site-header__contact-plus::before {
  top: 6px;
  left: 0;
  width: 13px;
  height: 1px;
}

.site-header__contact-plus::after {
  top: 0;
  left: 6px;
  width: 1px;
  height: 13px;
}

.site-header__contact:hover,
.site-header__contact:focus-visible {
  color: var(--accent);
}

.site-header__contact:hover .site-header__contact-plus,
.site-header__contact:focus-visible .site-header__contact-plus {
  opacity: 1;
  transform: rotate(0deg) scale(1);
}

.site-header__toggle {
  display: none;
}

/* Footer — one of .page-container's stacked content containers, so it
   already sits inside the page's 96 side padding; it only adds its own
   64px top/bottom padding on top of that. */
.site-footer__inner {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 24px;
  padding: 64px 0;
}

.site-footer__partner-link {
  display: block;
  line-height: 0;
}

.site-footer__col-left {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.site-footer__brand-logo {
  height: 64px;
  width: auto;
  display: block;
}

.site-footer__partner-row {
  display: flex;
  /* Both logos' "KAIST" glyph is anchored near the top of its own viewBox,
     so top-aligning the images keeps the glyphs level with each other —
     center-aligning would line up the boxes, not the glyphs, since
     KAIST_Original.svg carries extra tagline text below its logo mark
     that KAIST_DMSE.svg doesn't have. */
  align-items: flex-start;
  gap: 16px;
}

.site-footer__kaist-dmse {
  height: 20px;
  width: auto;
  display: block;
}

.site-footer__kaist-original {
  height: 32px;
  width: auto;
  display: block;
}

.site-footer__copyright {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

.site-footer__col-right {
  display: flex;
  gap: 64px;
}

.site-footer__info-block {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.site-footer__info-title {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 16px;
  color: var(--gray-2);
}

.site-footer__info-content {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  line-height: 1.45;
  color: var(--gray-3);
}

@media (max-width: 900px) {
  .site-footer__inner {
    flex-direction: column;
  }

  /* The 128px min-gap needs real room next to the logo; below 900px the
     nav collapses behind a toggle rather than let that gap get squeezed. */
  .site-header__nav {
    display: none;
  }

  .site-header__nav.is-open {
    display: flex;
    position: absolute;
    top: var(--header-height);
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: flex-start;
    gap: 16px;
    padding: 20px 16px 24px;
    background: rgba(255, 255, 255, 0.9);
    backdrop-filter: blur(20px);
    -webkit-backdrop-filter: blur(20px);
    border-bottom: 1px solid rgba(255, 255, 255, 0.5);
  }

  .site-header__nav.is-open .site-header__links {
    flex-direction: column;
    align-items: flex-start;
    gap: 16px;
  }

  .site-header__toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 24px;
    height: 24px;
    background: none;
    border: none;
    color: var(--gray-1);
    cursor: pointer;
  }
}

@media (min-width: 901px) {
  .site-header__toggle {
    display: none;
  }
}

@media (max-width: 640px) {
  .page-title {
    font-size: 2.35rem;
  }
}

/* People page — 7 stacked sections (Professor, Post Doctoral Researchers,
   Ph.D./M.S./Integrated Candidates, Staff, Alumni), each carrying its own
   title in place of a single page-level one (reusing the same
   .page-title-container/.page-title/.page-title__dot as Home's NEWS
   heading). Every section gets 32px top/bottom padding and a 16px gap
   between its title and its content. */
.people-section {
  padding: 32px 0;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* Cancels just the leading section's own top padding — .page-container's
   64px is already the same baseline offset News/Publication/Contact use,
   so this section's own 32px on top of that was pushing People's first
   title lower than every other page's. Later sections keep the normal 32px
   between them. */
.people-section:first-child {
  padding-top: 0;
}

/* Professor — the one section with fully bespoke content: a photo +
   info column, then a Research Focus accordion below a dividing line.
   This inset sits on top of .page-container's own side-padding, narrowing
   this block relative to the other 6 sections. Pinned to the true grid
   now — exactly column 3's left edge on both sides, i.e. this row's outer
   edges land on column 3 (left) and column 10 (right) — via the same
   calc((var(--col-w) + var(--grid-gap)) * 2) pattern .student-card uses
   for its own column-4 inset (2, not 3, columns in: column 3's left edge
   is 2 column-widths + 2 gutters in from the content edge). max(212px, …)
   keeps it flat at/below 1440 and lets it keep pace with the grid above
   that, the same "unchanged below 1440, scales past it" convention
   --side-padding itself uses.

   This exact 212px inset was tried once before and walked back to 184px
   because .professor__info-main was left with almost no slack at 556px —
   a degree line measuring ~485px only had ~71px of margin, and started
   wrapping at the slightest narrowing. Going back to 212px now is
   intentional despite that: .professor__photo below grows to fill the
   width this gives back to it rather than handing it to the text column,
   so info-main ends up narrower still (not wider) than it was at 184px —
   if a degree line wraps to a second line here, that's the expected
   tradeoff of this layout, not the same bug as before. */
.professor {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 0 max(212px, calc((var(--col-w) + var(--grid-gap)) * 2));
}

.professor__info {
  display: flex;
  gap: 24px;
}

/* 360x576 (was 300x480 — same 5:8 ratio, 20% bigger in both dimensions):
   the text column routinely runs taller than a 480px-tall photo, which
   left the photo looking short/small next to it — this closes most of
   that gap. With .professor__info's 824px total width (1248 content
   width minus .professor's 212px inset on each side) and 24px gap here,
   .professor__info-main (flex: 1) works out to 440px. */
.professor__photo {
  width: 360px;
  height: 576px;
  flex: none;
  object-fit: cover;
  border-radius: 4px;
  box-shadow: 0 0 16px rgba(0, 0, 0, 0.15);
}

/* line-height: 1.3 overrides the body's 1.45 base for this whole block —
   at these smaller/denser text sizes (labels, dates, position titles) the
   base paragraph line-height reads as loose, uneven leading between rows
   that otherwise sit right on top of each other via 0/8px flex gaps. */
.professor__info-main {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 16px;
  line-height: 1.3;
}

.professor__name {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 32px;
  color: var(--gray-1);
}

.professor__contact-degree {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.professor__contact {
  display: flex;
  flex-direction: column;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

.professor__contact p {
  margin: 0;
}

.professor__contact a {
  color: inherit;
  text-decoration: none;
}

.professor__degree {
  display: flex;
  flex-direction: column;
}

/* Grid (not flex) so an optional second line — .professor__degree-note,
   e.g. a double major — can sit on its own row underneath the main
   major/university/year line while staying indented to align with it
   (grid-column: 2), instead of sitting under the "B.S." label. Visually
   identical to the old flex row for entries with no note: 48px label
   column + the rest, same 8px gap. */
.professor__degree-row {
  display: grid;
  grid-template-columns: 48px 1fr;
  column-gap: 8px;
  row-gap: 2px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  line-height: 1.2;
  color: var(--gray-3);
}

.professor__degree-note {
  grid-column: 2;
}

/* Only the degree label lines up in a fixed column (long enough for "Ph.D."
   without wrapping) — the rest ("나머지") is one hugging block: major,
   university and year flow as ordinary text so the comma/parens read
   naturally attached to the word before them, not floating in their own
   gapped column. Each piece (major/university/year) still gets its own
   element — see .professor__degree-detail below — so updating one later is
   a straight text swap, no punctuation to rebuild around it. */
.professor__degree-label {
  width: 48px;
  flex: none;
}

.professor__degree-detail {
  flex: 1;
  min-width: 0;
}

.professor__history {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* Shared by "Current & Previous Appointments" and "Research Focus" — both
   spec'd as the same Geist Bold/24/Gray-1. */
.professor__section-title {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 24px;
  color: var(--gray-1);
}

.professor__appointment {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Middle tier between the 24px Gray-1 section title above and the 14px
   Gray-3 position/year rows below — Gray-2 (not Gray-3) so the bold weight
   actually reads as "more prominent," not bold-but-still-faint against the
   title above it. */
.professor__appointment-dept {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 16px;
  color: var(--gray-2);
}

/* One row per actual appointment (title + its own year range) — was
   previously two parallel columns (all titles stacked in one flex column,
   all year-rows stacked in a separate one) that only *looked* like paired
   rows because every title and every year-row happened to be exactly one
   line tall. The moment either wrapped to a second line (a long title, or
   just a narrower viewport) the two columns fell out of sync with each
   other, pairing the wrong title with the wrong year. Grouping each title
   with its own year-row in one real row fixes that structurally instead
   of relying on both columns staying line-for-line identical. */
.professor__appointment-list {
  display: flex;
  flex-direction: column;
  /* No gap — matches the old columns' own rhythm (plain margin: 0 <p>s
     with no separation beyond their line-height), not introducing extra
     space between rows that wasn't there before. */
  gap: 0;
}

/* justify-content: space-between (title hugs the row's left edge, year
   hugs its right edge) with gap as a floor — same pattern as
   .professor__research-focus-content. Since every appointment row shares
   the same row width (.professor__info-main), pinning both ends like this
   also has the side effect of lining every row's year column up at the
   same x, without needing a fixed-width title column. */
.professor__appointment-row {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 32px;
}

.professor__appointment-title {
  margin: 0;
  flex: 1;
  min-width: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

/* Year row: start | dash | end, both value columns the same fixed width
   (64px) — equal widths on both sides keep the dash column at the same x
   on every row regardless of digit count, so the row reads as a symmetric
   table instead of the previous 48/64 asymmetric split that made the dash
   look skewed to one side. Start left-aligned, end right-aligned (each
   hugging its box's outer edge). */
.professor__year-row {
  display: grid;
  flex: none;
  grid-template-columns: 64px 16px 64px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

.professor__year-row span:first-child {
  text-align: left;
}

.professor__year-row span:nth-child(2) {
  text-align: center;
}

.professor__year-row span:last-child {
  text-align: right;
}

/* Plain proportional digits render at slightly different widths per year
   (e.g. "2017" vs "2012"), which reads as jitter once several rows are
   stacked — tabular-nums pins every digit to the same advance width so any
   4-digit year lines up identically. The added letter-spacing opens the
   digits up a little further, closer to (not exactly matching — an exact
   match needs ~5-6px and makes the digits look broken apart) "Present"'s
   width, so the two columns read as comparable weight rather than the
   numbers looking cramped next to the word. */
.professor__year-num {
  font-variant-numeric: tabular-nums;
  letter-spacing: 1.5px;
}

.professor__research-focus {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.professor__research-focus-toggle {
  display: flex;
  align-items: center;
  gap: 24px;
  background: transparent;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

/* Reuses the same down-chevron shape as .news-card__chevron for visual
   consistency between the site's two expand/collapse affordances, including
   the same hover-to-accent affordance (news-render.js's auto-collapse on
   pointerleave is mirrored here too — see the research-focus-toggle handler
   in script.js). */
.professor__research-focus-chevron {
  flex: none;
  color: var(--gray-3);
  transition: transform 0.3s ease, color 0.15s ease;
}

.professor__research-focus-toggle:hover .professor__research-focus-chevron {
  color: var(--accent);
}

.professor__research-focus.is-open .professor__research-focus-chevron {
  transform: rotate(-180deg);
}

/* justify-content: space-between + gap together, not either alone: gap sets
   the floor between the two columns, space-between pushes them out to the
   container's edges with whatever room is left over — so the columns hug
   their own content (see .professor__focus-col's flex: none) and only
   the width beyond the 64px minimum goes to the outer edges, not between
   them. */
.professor__research-focus-content {
  display: flex;
  justify-content: space-between;
  gap: 64px;
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.35s ease;
}

.professor__research-focus.is-open .professor__research-focus-content {
  max-height: 1000px;
}

.professor__focus-col {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.professor__focus-heading {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 16px;
  color: var(--gray-3);
}

.professor__focus-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* A small hollow-free dot (not the native disc marker, for size/position
   control) stands in for the "table of contents" bullet look asked for. */
.professor__focus-list li {
  position: relative;
  padding-left: 16px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

.professor__focus-list li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.55em;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
}

@media (max-width: 900px) {
  /* .professor's own 212px-per-side inset (on top of .page-container's
     --side-padding) is a desktop-only grid alignment, not a flat value —
     nothing here shrinks it back down below 1440px, so at mobile widths it
     was left eating 424px combined out of viewports far narrower than
     that, squeezing (often past zero) the actual content width. Collapsing
     it to 0 here matches .student-card's own mobile reset just below. */
  .professor {
    padding: 0;
  }

  .professor__info {
    flex-direction: column;
  }

  .professor__photo {
    width: 100%;
    max-width: 360px;
    height: auto;
    /* 5:8, matching the real 360x576 photo box above — was still 360/480
       (the old 3:4 box) here, which made object-fit: cover crop every
       mobile photo against the wrong ratio. */
    aspect-ratio: 5 / 8;
  }

  .professor__name {
    font-size: 26px;
  }

  .professor__section-title {
    font-size: 20px;
  }

  /* align-items defaults to flex-start on .professor__appointment-row —
     fine at desktop width where it's a row, but once this stacks into a
     column here, flex-start on the cross axis means .professor__appointment-title
     hugs its own content width instead of the row's full width, so a long
     title ran out of room to wrap into and overflowed instead. stretch
     gives it back the full width to wrap against. */
  .professor__appointment-row {
    flex-direction: column;
    align-items: stretch;
    gap: 4px;
  }

  .professor__appointment-list {
    gap: 12px;
  }

  /* Same issue as .professor__appointment-row above, one level up: two
     columns pinned apart by justify-content: space-between + a 64px gap
     floor, with nothing here to stack them, so on any viewport narrower
     than "both columns' content + 64px" they overflowed instead of
     wrapping. */
  .professor__research-focus-content {
    flex-direction: column;
    gap: 16px;
  }
}

@media (max-width: 640px) {
  .professor__photo {
    max-width: 280px;
  }

  .professor__name {
    font-size: 22px;
  }
}

/* People page — student/researcher cards (Post Doctoral Researchers through
   Integrated M.S. - Ph.D. Candidates share this same format). One
   .dash-line between each card, both spaced by the grid's own 8px gap. */
.people-grid {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* 318px left only (not right) — content stays left-aligned, it's just
   inset further than the section title above it. 318px === exactly the
   left edge of column 4 (3 column-widths + 3 gutters) at the 1440
   reference width, so this is expressed as that instead of a flat ratio
   of 318/1440: a plain ratio only approximates the true column edge near
   1440 and drifts increasingly wrong the further the viewport grows
   past it, since --grid-gap doesn't scale but the ratio approach implies
   it does. calc((var(--col-w) + var(--grid-gap)) * 3) stays exactly on
   the column-4 edge at any width. max() keeps it flat at/below 1440,
   same "unchanged below 1440, scales past it" pattern as --side-padding
   above. */
.student-card {
  padding-left: max(318px, calc((var(--col-w) + var(--grid-gap)) * 3));
}

.student-card__row {
  display: flex;
  align-items: flex-start;
  gap: 32px;
  width: 100%;
  background: transparent;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}


/* Fixed 180x240 box, object-fit: cover so any source photo — whatever its
   own ratio — scales up to fill it completely and gets clipped to exactly
   that box, never distorted and never leaving a gap on any side. */
.student-card__photo-wrap {
  width: 180px;
  height: 240px;
  flex: none;
  overflow: hidden;
  border-radius: 4px;
  box-shadow: 0 0 16px rgba(0, 0, 0, 0.15);
}

.student-card__photo {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.35s ease;
}

.student-card:hover .student-card__photo {
  transform: scale(1.06);
}

/* flex: none (not flex: 1) — this column hugs its own content width
   instead of stretching to fill the rest of the row, so
   .student-card__divider's width: 100% matches the actual info content
   (name/degree lines) instead of stretching out to the section's far edge.

   align-items: center on .student-card__row would look right at rest, but
   it can't animate — switching it to flex-start on click is an instant
   snap, not a rise. So .student-card__row stays flex-start always, and the
   "centered at rest" look instead comes from padding-top here: 72px
   approximates centering .student-card__primary's ~93px of content (name +
   role + admission, 0 gap between them) inside the 240px-tall photo next to
   it. Since padding-top IS animatable, transitioning it down to 16px on
   open is what actually reads as the info "rising" into its open position,
   instead of jumping there. */
.student-card__info {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-top: 72px;
  transition: padding-top 0.4s ease;
}

.student-card.is-open .student-card__info {
  padding-top: 16px;
}

.student-card__primary {
  display: flex;
  flex-direction: column;
}

.student-card__name {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 32px;
  color: var(--gray-1);
  transition: color 0.15s ease;
}

.student-card:hover .student-card__name {
  color: var(--accent);
}

.student-card__role {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

.student-card__admission {
  display: flex;
  gap: 8px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

/* Invisible at rest (scaleX(0)), draws itself left-to-right on hover —
   independent of .is-open, so it animates in on hover alone even before a
   click ever reveals .student-card__extra-wrap below it. Always reserves
   its own layout space (unlike the max-height-collapsed extra-wrap), it
   just has nothing visible in it until hovered. */
.student-card__divider {
  height: 1px;
  width: 100%;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.4s ease;
}

.student-card:hover .student-card__divider {
  transform: scaleX(1);
}

/* grid-template-rows: 0fr -> 1fr (not max-height) grows the real content
   height smoothly regardless of how tall it actually is — a fixed
   max-height has to guess a cap far above the real content, which makes
   the transition look uneven (fast growth, then a long dead stretch to
   reach the cap). The single grid child needs min-height: 0 to actually be
   allowed to shrink to the 0fr track. */
.student-card__extra-wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.4s ease;
}

.student-card.is-open .student-card__extra-wrap {
  grid-template-rows: 1fr;
}

/* padding-bottom is a small buffer against a real rendering quirk of the
   grid-template-rows: 0fr -> 1fr accordion trick above: the track's "1fr"
   height resolves to this element's content height, but that measurement
   doesn't reliably leave room for the last line's descenders (the tails on
   g/y/p) before overflow: hidden clips at exactly that height — so without
   this, a bottom line ending in one of those letters gets its tail shaved
   off. The buffer gives overflow:hidden a few extra px to clip into
   instead of the glyph itself. */
.student-card__extra {
  min-height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-bottom: 4px;
  opacity: 0;
  transition: opacity 0.25s ease;
}

.student-card.is-open .student-card__extra {
  opacity: 1;
  transition: opacity 0.3s ease 0.15s;
}

.student-card__email {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

/* Staff reuses the exact same student-card markup/CSS (photo + name/role/
   admission), just with no button, no divider, and no extra-wrap in its
   markup at all — nothing to expand into. This modifier only has to cancel
   the hover payoffs (:hover on .student-card fires from real mouse
   position regardless of markup), so it doesn't look interactive when nothing
   happens on click. */
.student-card--static .student-card__row {
  cursor: default;
}

.student-card--static:hover .student-card__name {
  color: var(--gray-1);
}

.student-card--static:hover .student-card__photo {
  transform: none;
}

@media (max-width: 900px) {
  .student-card {
    padding-left: 0;
  }

  /* Was a plain flex row (photo + text side by side) at every width —
     .student-card__photo-wrap's fixed 180px + this row's 32px gap left
     barely 100-150px for name/role/admission text on an actual phone, with
     .student-card__info's flex: none meaning it wouldn't shrink into that
     space either — text just overflowed instead of wrapping. Stacking
     photo above content here is what the "사진이 위로, 내용이 아래로" ask
     needs, and align-items: stretch gives .student-card__info the row's
     full width to actually wrap its text into instead of hugging its own
     (too-wide) content width. */
  .student-card__row {
    flex-direction: column;
    align-items: stretch;
    gap: 16px;
  }

  /* This 72px top padding exists to visually center the text block next to
     a 240px-tall photo in the side-by-side layout — with the photo now
     sitting above instead of beside it, that offset is just dead space. */
  .student-card__info,
  .student-card.is-open .student-card__info {
    padding-top: 0;
  }

  .student-card__name {
    font-size: 26px;
  }

  /* Two fixed-content spans in a nowrap flex row — fine at full width, but
     with nothing to shrink into on a narrow phone this let long
     admission-season/year text overflow instead of dropping to a second
     line. */
  .student-card__admission {
    flex-wrap: wrap;
  }
}

@media (max-width: 640px) {
  .student-card__name {
    font-size: 22px;
  }
}

/* Alumni — same collapse/expand interaction as .professor__research-focus
   (hover-tints the chevron, click rotates it and reveals the panel, leaving
   the whole area closes it again), just applied to the section's own
   title instead of a sub-heading. */
.alumni {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.alumni-toggle {
  display: flex;
  align-items: center;
  gap: 24px;
  background: transparent;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

.alumni-chevron {
  flex: none;
  color: var(--gray-3);
  transition: transform 0.3s ease, color 0.15s ease;
}

.alumni-toggle:hover .alumni-chevron {
  color: var(--accent);
}

.alumni.is-open .alumni-chevron {
  transform: rotate(-180deg);
}

.alumni-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.4s ease;
}

.alumni.is-open .alumni-panel {
  grid-template-rows: 1fr;
}

.alumni-panel > .people-grid {
  min-height: 0;
  overflow: hidden;
}

.alumni-card {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.alumni-card__name {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 24px;
  color: var(--gray-1);
}

/* The comma is baked into the program label's own text (see
   people-render.js), not a separate gapped span — "Ph.D. Candidate," reads
   as one attached unit before the 8px gap to the year. */
.alumni-card__program,
.alumni-card__current {
  display: flex;
  gap: 8px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-2);
}

/* Publication page — a 64px-wide left gutter reserved for the fixed year
   sidebar, then the year-grouped publication list. */
/* gap: var(--grid-gap), not a flat 32px — the gutter's own width plus this
   gap needs to add up to exactly one column + one gutter (the left edge
   of column 2), which .publication-year-label then sits flush against.
   See .publication-sidebar-gutter below for the rest of that math. */
.publication-layout {
  display: flex;
  gap: var(--grid-gap);
  align-items: flex-start;
}

/* Just a spacer now — the actual nav lives outside .page-container (see
   below) and is visually positioned to line up with this reserved gap, not
   rendered inside it. pointer-events: none is load-bearing: this div sits
   later in paint order than the fixed nav (it's inside <main>, which comes
   after <nav> in the DOM), so without this, its own transparent box was
   sitting on top of the nav wherever they overlap and swallowing every
   hover/wheel/click before it ever reached the real sidebar.
   width: var(--col-w), not a flat 64px — combined with .publication-layout's
   own gap (var(--grid-gap)), that puts .publication-year-label's left edge
   at exactly column 2's left edge (1 * (col-w + gutter)) instead of an
   approximate fixed offset that only looked right at 1440px. */
.publication-sidebar-gutter {
  width: var(--col-w);
  flex: none;
  pointer-events: none;
}

/* Lives in publication.html as a sibling of <main class="page-container">,
   not nested inside it — .page-container has a filter-animation
   (reveal-fade-blur) attached via animation-fill-mode: both, and browsers
   keep treating an element as filtered for as long as that kind of
   animation stays attached, even resting at filter: none (same discovery
   already documented for .site-header__logo-invert and position: fixed
   descendants). A filtered ancestor becomes the containing block for any
   position: fixed descendant, so this nav was scrolling with the page
   instead of staying pinned to the actual viewport center — being a
   .page-container sibling instead fixes it to the real viewport, "화면의
   가운데" not "페이지의 중심."

   `left` just matches --side-padding, the same as .site-header__logo, so
   it still lines up with the 64px gutter reserved in the page's own
   padded layout, not the raw viewport edge. Liquid-glass styling here is a first
   pass (the design isn't finalized): same blur/saturate approach as the
   header, plus a soft tint since it floats over page content rather than a
   photo. */
/* height: 5 * 36px — exactly 5 item-slots tall, clipped (overflow: hidden)
   so .publication-year-nav__list can slide within it. This is what keeps
   the current year pinned at the exact same visual slot (the middle one)
   at all times, including at the very first/last year: the list still
   only has real items on one side, so the other 1-2 slots of the window
   are simply blank glass, not squeezed content — see the transform math in
   publication-render.js's render(). */
/* No border/stroke at all now — at rest and on hover, the "wheel-to-jump-
   year interaction is live right now" affordance is a soft drop shadow
   instead, giving the glass pill a lifted, elevated look on hover rather
   than an outline tinting in. The hover shadow (0 0 16px, 15% black) is
   the exact same value used for every photo's shadow on the site
   (.professor__photo, .student-card__photo-wrap,
   .publication-card__image, .research-card__image) rather than its own
   one-off — same "soft glow" family, just triggered by hover instead of
   always on. border-radius: 32px is exactly half the
   64px width — with the top/bottom padding giving it real vertical room,
   that's what makes the two ends read as true semicircles (a capsule/
   pill) instead of a rounded-corner rectangle. This outer element does
   NOT clip (no overflow: hidden) — the padding needs to be real
   breathing space around the 5-slot window, not inside it, so the actual
   clipping happens one layer in, on .publication-year-nav__viewport,
   which stays exactly 180px (5 * 36) regardless of this padding. */
.publication-year-nav {
  position: fixed;
  top: 50%;
  /* Simply --side-padding: with .page-container no longer capped at
     --page-max, its content's left edge always sits exactly
     --side-padding in from the real viewport edge — there's no more
     "centered content, dead margin outside it" gap to add back in. */
  left: var(--side-padding);
  transform: translateY(-50%);
  z-index: 500;
  width: 64px;
  padding: 24px 0;
  display: flex;
  justify-content: center;
  border: none;
  border-radius: 32px;
  background: rgba(255, 255, 255, 0.5);
  -webkit-backdrop-filter: blur(20px) saturate(150%);
  backdrop-filter: blur(20px) saturate(150%);
  box-shadow: 0 0 16px rgba(0, 0, 0, 0);
  transition: box-shadow 0.25s ease;
}

.publication-year-nav:hover {
  box-shadow: 0 0 16px rgba(0, 0, 0, 0.15);
}

.publication-year-nav__viewport {
  width: 64px;
  height: 180px;
  overflow: hidden;
  display: flex;
  justify-content: center;
}

/* Positioned purely via transform: translateY (set in JS) — no gap here,
   since the slide math assumes each item is exactly ITEM_HEIGHT (36px)
   tall with nothing between them. The transition is the "딸깍" snap when
   the current year changes, whether that's from a click, a wheel notch
   over the sidebar, or the main page scrolling a new year to the top. */
.publication-year-nav__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}

/* Geist's digits aren't tabular/fixed-width, so "2026" and "2021" naturally
   render at slightly different widths — enough to make the whole column
   look unevenly centered. Fixed widths per state (32px normal, 40px
   current) fix the box; the actual stretch-to-fill (letter-spacing
   computed per item so its digits span the box exactly) is set inline by
   fitTextWidth() in publication-render.js rather than CSS text-justify —
   text-justify: inter-character is a CJK-justification feature and several
   engines don't extend its stretching to plain Latin/digit runs, so it was
   silently doing nothing here. Computing letter-spacing from the actual
   measured width works regardless of font metrics or browser support. */
/* Only `color` transitions here — width, font-size, and font-weight all
   affect the glyphs' own rendered size, and fitTextWidth() measures that
   size synchronously right after the class change that would trigger any
   of those to animate. A transitioning value read mid-animation is a
   moving target, not the real resting size, which is what kept shrinking
   the letter-spacing further with every year change: each render measured
   a slightly-still-transitioning font-size/weight and computed spacing
   for a width that hadn't actually been reached yet. Snapping size/weight
   instantly (only fading the color) is what makes fitTextWidth's
   measurement reliable. */
.publication-year-nav__item {
  display: block;
  width: 32px;
  height: 36px;
  line-height: 36px;
  text-align: center;
  white-space: nowrap;
  background: transparent;
  border: none;
  padding: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 14px;
  color: var(--gray-4);
  cursor: pointer;
  transition: color 0.2s ease;
}

.publication-year-nav__item.is-near {
  color: var(--gray-3);
}

.publication-year-nav__item.is-current {
  width: 40px;
  color: var(--accent);
  font-weight: 700;
  font-size: 16px;
}

.publication-years {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

/* gap: var(--grid-gap) (was a flat 24px, same value at 1440 but now scales
   with the grid) — .publication-year-label's width plus this gap needs to
   add up to exactly one more column + gutter so .publication-card__image
   lands on column 3's left edge, the same reasoning as .publication-layout's
   own gap above. */
.publication-year-block {
  display: flex;
  align-items: flex-start;
  gap: var(--grid-gap);
}

/* width: var(--col-w), not a flat 96px — makes this exactly column 2's own
   width, so .publication-year-list starts flush at column 3's left edge
   (2 * (col-w + gutter) from the content edge) instead of an approximate
   fixed offset. */
.publication-year-label {
  width: var(--col-w);
  flex: none;
  text-align: left;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 24px;
  color: var(--gray-3);
}

.publication-year-list {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 64px;
}

.publication-card {
  display: flex;
  align-items: flex-start;
  gap: 24px;
  border-radius: 8px;
}

/* Flashes once when this card is the one a ?paper=<slug> deep link (a
   research topic's "Representative Publications" citation, see
   research-detail-render.js) pointed at — scrollToRequestedPublication in
   publication-render.js adds this class and removes it again once the
   animation ends, so it doesn't linger as a permanent visual state. */
@keyframes publication-card-highlight-flash {
  0%,
  40% {
    box-shadow: 0 0 0 3px var(--accent);
  }
  100% {
    box-shadow: 0 0 0 3px transparent;
  }
}

.publication-card--highlight {
  animation: publication-card-highlight-flash 1.8s ease-out;
}

/* object-fit: contain (not cover) — these are real paper figures, often
   not square, and cover was cropping content off the edges to fill the
   box. contain shrinks the whole image to fit inside instead, letterboxed
   if needed, so nothing gets cut off. */
.publication-card__image {
  width: 188px;
  height: 188px;
  flex: none;
  object-fit: contain;
  display: block;
  box-shadow: 0 0 16px rgba(0, 0, 0, 0.15);
}

/* Stand-in for an entry added before its real figure exists yet (see
   publication-render.js) — same box, no shadow (nothing to lift off the
   page yet) so it reads as empty rather than as a real photo that failed
   to load. */
.publication-card__image--empty {
  background: var(--gray-6);
  box-shadow: none;
}

.publication-card__info {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.publication-card__title {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 24px;
  color: var(--gray-2);
}

.publication-card__authors {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 16px;
  color: var(--gray-3);
}

/* Anyone not on this lab's own People page (see publication-render.js's
   OUR_NAMES) — faded a step lighter (gray-4, unchanged) AND dropped to
   the journal name's own Regular/400 weight, instead of staying at the
   authors line's bolder 600 — between color and weight together this
   reads as visually secondary to this lab's own people, closer to the
   citation's supporting details than to the "who worked on this" names. */
.publication-card__author--external {
  color: var(--gray-4);
  font-weight: 400;
}

.publication-card__journal-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.publication-card__journal {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 16px;
  color: var(--gray-3);
}

/* Stroke only (no fill), fully rounded left/right ends — reuses the site's
   divider color for the line itself since nothing else was specified. */
.publication-card__doi {
  flex: none;
  width: 44px;
  height: 20px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--divider-color);
  border-radius: 999px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 14px;
  color: var(--gray-3);
  text-decoration: none;
  transition: border-color 0.15s ease;
}

/* Hovering anywhere on the card highlights its DOI badge's border, not
   just hovering the badge itself — a way of saying "this card has a DOI"
   as soon as you're looking at it. */
.publication-card:hover .publication-card__doi {
  border-color: var(--accent);
}

/* No DOI yet (a <span>, not a link — see publication-render.js) — dashed
   instead of solid so it reads as "not filled in yet" rather than a real,
   currently-inactive link. */
.publication-card__doi--pending {
  border-style: dashed;
  color: var(--gray-4);
}

.publication-card:hover .publication-card__doi--pending {
  border-color: var(--divider-color);
}

@media (max-width: 900px) {
  .publication-sidebar-gutter,
  .publication-year-nav {
    display: none;
  }

  .publication-year-block {
    flex-direction: column;
    gap: 8px;
  }

  /* var(--col-w) shrinks along with the rest of the 12-column grid below
     1440px — fine for the desktop sidebar layout it's meant to align to,
     but with the sidebar gone and the year stacked above its own list
     here, that column-width constraint has nothing left to align to and
     just clips the year text. auto lets it size to its own content. */
  .publication-year-label {
    width: auto;
  }

  .publication-card {
    flex-direction: column;
  }
}

/* Research page — full-bleed hero (same left:50%/100vw/-50vw break-out and
   -64px top-margin trick as .hero, so it also sits flush behind the fixed
   header), background image instead of a flat color, centered. z-index: 1
   for the same reason as .hero: .research-topics right after it is a
   plain non-positioned box by default, and a positioned sibling (this)
   would otherwise paint over it regardless of DOM order — see .hero's own
   comment. script.js applies the same slower-than-page parallax transform
   to this element as it does to .hero (selector covers both). */
.research-hero {
  position: relative;
  left: 50%;
  right: 50%;
  width: 100vw;
  margin-left: -50vw;
  margin-right: -50vw;
  margin-top: -64px;
  /* See .hero's own comment on height: 100svh — same fix, same reason. */
  height: 100vh;
  height: 100svh;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1;
}

/* Two stacked photos (top/bottom half each) instead of one full-bleed
   background — position: absolute + top/bottom: 0 (not a flex split)
   because both need to independently object-fit their own half
   regardless of the hero's own flex layout for .research-hero__content. */
.research-hero__image {
  position: absolute;
  left: 0;
  right: 0;
  height: 50%;
  background-size: cover;
  background-repeat: no-repeat;
}

.research-hero__image--top {
  top: 0;
  background-image: url("assets/Research_1.jpg");
  /* bottom: the seam-adjacent edge is the one that actually needs to line
     up with its other half — center would crop whichever edge happens to
     overflow first instead of reliably keeping this one. */
  background-position: center bottom;
}

.research-hero__image--bottom {
  bottom: 0;
  background-image: url("assets/Research_2.jpg");
  background-position: center top;
}

/* Solid black exactly on the seam between the two photos (50%), fading to
   a light wash (not fully transparent) by each end — one shared gradient
   spanning the whole hero (not a separate one per photo half) lifts
   whatever sits on top (the text/arrows) off both busy, differently-lit
   photos at once. The 0%/100% stops carry a little opacity of their own
   (not 0) so the hero reads less bright everywhere, not just at the seam;
   the small blur softens the gradient's own banding between stops into a
   smoother falloff.

   The vertical gradient alone leaves the far left/right edges completely
   untouched at any height where it's weak (top/bottom thirds, ~0.28) —
   both photos happen to have their brightest content (the prism's light
   beams, the butterfly wing's teal) running right along those edges, so
   those corners read as barely darkened at all next to the seam. A
   second, horizontal gradient layered on top darkens just the outer ~12%
   on each side, fading to nothing by the time it reaches the middle,
   independent of the vertical one.

   inset: -20px (not 0) — filter: blur on an element sized exactly to its
   clipped container dilutes the effect right at the visible edge itself:
   there's nothing beyond the box to blend with, so blur pulls each edge
   toward transparent exactly where the gradient is supposed to be
   darkest. Extending the layer 20px past every side (and letting
   .research-hero's own overflow: hidden clip the excess) moves that
   dilution into the part that never gets seen, so the true visible edge
   reads at full, undiluted strength. */
.research-hero__seam {
  position: absolute;
  inset: -20px;
  z-index: 1;
  filter: blur(6px);
  background:
    linear-gradient(
      to right,
      rgba(0, 0, 0, 0.4) 0%,
      transparent 12%,
      transparent 88%,
      rgba(0, 0, 0, 0.4) 100%
    ),
    linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0.28) 0%,
      rgba(0, 0, 0, 0.92) 38%,
      #000 50%,
      rgba(0, 0, 0, 0.92) 62%,
      rgba(0, 0, 0, 0.28) 100%
    );
}

/* Text/arrows sit in their own full-hero-sized layer (not confined to a
   small centered box like the old .research-hero__diagram) so "Wave" and
   "Structured Material" can anchor to the hero's own corners. Needs its
   own stacking context: the two image layers above are position: absolute,
   and a plain non-positioned flex child always loses to ANY positioned
   sibling regardless of DOM order — without this the text/arrows would
   paint underneath them. */
.research-hero__content {
  position: absolute;
  inset: 0;
  z-index: 1;
  /* Shared boundary: the right edge of column 5 (counting from the left)
     and the left edge of column 5 (counting from the right) — the same
     distance in from either side, per --col-w's own left/right-edge-of-
     column-N formulas. Wave + the down arrow anchor to the left one,
     Structured Material + the up arrow to the right one, so all four
     actually land on the same grid line instead of each being tuned to
     merely look close. */
  --research-hero-col-inset: calc(var(--side-padding) + 5 * var(--col-w) + 4 * var(--grid-gap));
}

.research-hero__node {
  position: absolute;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(40px, 7vw, 96px);
  line-height: 1.08;
  letter-spacing: 0.01em;
  color: var(--gray-6);
  text-shadow: 0 2px 14px rgba(0, 0, 0, 0.7);
  /* Shared by both .research-hero__node--wave/--structured below, so the
     two stay in sync by construction — see their own comment. */
  --research-hero-node-gap: clamp(56px, 7vw, 110px);
}

/* Anchored via the FAR side (right for the left-hand pair, left for the
   right-hand pair) at the shared --research-hero-col-inset boundary (see
   .research-hero__content) — not the near side. Wave and the down arrow
   sit entirely to the LEFT of that grid line (its right edge touches the
   line), Structured Material and the up arrow sit entirely to the RIGHT
   of the mirrored line (its left edge touches it) — the reverse of an
   earlier version that anchored each one's near edge and let it grow
   away from the line instead of stopping right at it. calc(100% -
   var(...)) converts a "distance from the near edge" into the matching
   "distance from the far edge" needed to land the far property on the
   exact same boundary.

   Vertically, each is positioned from its NEAR edge relative to the seam
   (50%) by the same --research-hero-node-gap, rather than a flat top/
   bottom % from the container edge — with "Structured Material" wrapping
   to 2 lines against "Wave"'s 1, matching top/bottom %s only keeps their
   OUTER edges symmetric, not the inner ones actually facing each other
   across the seam, since the boxes are different heights. Anchoring
   bottom/top directly off 50% makes Wave's own bottom edge and
   Structured Material's own top edge equidistant from the seam no matter
   how many lines either one wraps to. */
.research-hero__node--wave {
  bottom: calc(50% + var(--research-hero-node-gap));
  right: calc(100% - var(--research-hero-col-inset));
}

.research-hero__node--structured {
  top: calc(50% + var(--research-hero-node-gap));
  left: calc(100% - var(--research-hero-col-inset));
  text-align: left;
}

/* Each arrow's "attachment" edge sits exactly on the seam (the hero's own
   50% line, which is also where the two photos meet) — .research-hero__
   arrow--down's top edge and .research-hero__arrow--up's bottom edge are
   both set to the same 50%, so the tail of one and the tail of the other
   both start flush against the photo boundary, growing away from it in
   opposite directions. Horizontally, same far-side anchor as the text
   above (see its own comment) — no flex row anymore (label + svg used to
   share one flex container anchored as a unit, which meant the LABEL's
   edge sat on the grid line, not the arrow's own edge); the label is now
   positioned independently off the arrow's own box (see
   .research-hero__arrow-label below) so the anchor here lands exactly on
   the arrow shape itself. */
.research-hero__arrow {
  position: absolute;
}

.research-hero__arrow--down {
  top: 50%;
  right: calc(100% - var(--research-hero-col-inset));
}

.research-hero__arrow--up {
  bottom: 50%;
  left: calc(100% - var(--research-hero-col-inset));
}

/* aspect-ratio (matching the viewBox) instead of a fixed height — lets the
   arrow scale uniformly with its clamp()-driven width at any viewport
   size/aspect ratio instead of stretching. */
.research-hero__arrow-svg {
  width: clamp(64px, 7vw, 108px);
  aspect-ratio: 623.08 / 635.58;
  overflow: visible;
  display: block;
}

/* Positioned off the arrow's own box (not a flex sibling) so the arrow
   box's own left/right edge — what .research-hero__arrow--down/--up
   anchor to the grid — stays exactly the svg's edge, unaffected by
   however wide this label happens to be. */
.research-hero__arrow-label {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(16px, 1.8vw, 22px);
  letter-spacing: 0.02em;
  color: var(--accent);
  white-space: nowrap;
}

.research-hero__arrow--down .research-hero__arrow-label {
  right: calc(100% + 12px);
}

.research-hero__arrow--up .research-hero__arrow-label {
  left: calc(100% + 12px);
}

@media (max-width: 900px) {
  .research-hero__node {
    /* Bigger than the arrow's own mobile height below (~65px at the 64px
       min width) — otherwise the text's near edge and the arrow's far tip
       land on top of each other instead of just meeting at the seam. */
    --research-hero-node-gap: clamp(84px, 15vw, 120px);
  }

  /* Desktop anchors Wave/Structured Material off the grid (see their own
     comment) — mobile drops that entirely and just centers both, since
     there's no real 12-column grid left to match at this width anyway (5
     columns' worth of --col-w has collapsed to a few px here). */
  .research-hero__node--wave,
  .research-hero__node--structured {
    left: 50%;
    right: auto;
    transform: translateX(-50%);
    text-align: center;
  }

  /* The arrows can't both sit dead-center like the text above (they'd
     overlap), so instead they're placed symmetrically just off-center —
     wide enough apart (60/40, not 55/45) that the labels below, now
     stacked right at the seam on both sides (see .research-hero__arrow--
     down/--up .research-hero__arrow-label below), have room without
     touching each other. */
  .research-hero__arrow--down {
    right: 60%;
    left: auto;
  }

  .research-hero__arrow--up {
    left: 60%;
    right: auto;
  }

  .research-hero__arrow-svg {
    width: clamp(48px, 10vw, 72px);
  }

  /* Beside the arrow (the desktop layout) needs more clear horizontal
     room than an 8%-inset phone viewport has — labels were clipping
     against the hero's own edge (overflow: hidden). Centered under/over
     the arrow instead, in the direction each one points, needs only
     vertical room, which --research-hero-node-gap already keeps clear.

     Overridden on the same .research-hero__arrow--down/--up-qualified
     selectors as the desktop rule (not the bare .research-hero__arrow-
     label alone) — that compound selector is just as specific whether or
     not it's inside this media query, so a plain .research-hero__arrow-
     label { right: auto } here would lose to it regardless of source
     order, leaving BOTH left and right set and the label's auto width
     resolving to 0 instead of shrink-wrapping the text. */
  .research-hero__arrow-label {
    font-size: clamp(13px, 3.2vw, 17px);
  }

  /* At the START of each arrow (its tail, right at the seam) rather than
     the tip — .research-hero__arrow--down's own box top edge IS the seam
     (it grows downward from there), so its label sits just ABOVE the box;
     .research-hero__arrow--up's box BOTTOM edge is the seam (it grows
     upward), so its label sits just BELOW. That puts both labels close
     to the same horizontal band straddling the seam, which is exactly
     why the arrows themselves needed the wider 60/40 split above — with
     the old 55/45 split the two labels' shrink-wrapped boxes would have
     overlapped there. */
  .research-hero__arrow--down .research-hero__arrow-label {
    left: 50%;
    right: auto;
    top: auto;
    bottom: calc(100% + 8px);
    transform: translateX(-50%);
  }

  .research-hero__arrow--up .research-hero__arrow-label {
    left: 50%;
    right: auto;
    top: calc(100% + 8px);
    bottom: auto;
    transform: translateX(-50%);
  }
}

/* .research-topics is .home-news's exact counterpart: an opaque, full-
   bleed sheet (same left:50%/100vw/-50vw break-out) that slides up and
   fully covers the slower-parallaxed .research-hero behind it, elevated by
   the same top-edge shadow trick — see .home-news's own comment for why
   both the explicit z-index and the zero-height shadow pseudo-element are
   built the way they are. .research-topics__inner re-applies the normal
   max-width/side-padding for the actual list content, same relationship as
   .hero/.hero__content or .home-news/.home-news__inner. */
.research-topics {
  position: relative;
  left: 50%;
  right: 50%;
  width: 100vw;
  margin-left: -50vw;
  margin-right: -50vw;
  z-index: 2;
  /* Same image-not-color trick as .home-news (its exact counterpart) and
     body — see body's own comment on why an image resists forced-dark
     repainting where a flat background-color doesn't. */
  background-color: var(--bg);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3Crect width='1' height='1' fill='%23fff'/%3E%3C/svg%3E");
  background-repeat: repeat;
  background-size: 1px 1px;
}

.research-topics::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  pointer-events: none;
  box-shadow:
    0 -20px 28px -12px rgba(0, 0, 0, 0.6),
    0 -48px 80px -36px rgba(0, 0, 0, 0.4);
}

.research-topics__inner {
  margin: 0 auto;
  padding: 64px var(--side-padding) 0;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

/* Research topic list — same reveal-on-scroll pattern as .news-card (see
   research-render.js), topic/line/topic/line at a 64px gap. */
.research-list {
  display: flex;
  flex-direction: column;
  gap: 64px;
}

.research-card {
  display: flex;
  gap: 64px;
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.research-card.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* "more >" is the only clickable part of the card (see research-render.js)
   — hovering it is what lights it up, and that same element is the real
   <a>, so the click target matches the hover affordance exactly instead of
   the whole card silently being a link underneath it. */
.research-card__more:hover {
  color: var(--accent);
}

/* Zigzag: every other card reverses which side the image/info sit on.
   Only the visual order flips (row-reverse) — the markup order (image
   first, then info) stays the same either way, which is what keeps this a
   one-line class toggle in research-render.js instead of swapping DOM
   order per card. */
.research-card--reverse {
  flex-direction: row-reverse;
}

/* object-fit: contain (not cover) — these are real figures (diagrams,
   labeled panels), often not matching the card's aspect ratio, and cover
   was cropping content off the edges to fill the box. contain shrinks the
   whole image to fit inside instead, so nothing gets cut off. */
.research-card__image {
  flex: 1;
  min-width: 0;
  height: 432px;
  object-fit: contain;
  display: block;
  border-radius: 4px;
  box-shadow: 0 0 16px rgba(0, 0, 0, 0.15);
}

.research-card__info {
  flex: 1;
  min-width: 0;
  height: 432px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 24px;
}

.research-card__topic {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 36px;
  color: var(--gray-1);
}

.research-card__summary {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.research-card__excerpt {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
}

/* Same visual as .news-card__more/.news-card__chevron in their "open"
   resting state (text visible, chevron pointing right). A real <a> (see
   research-render.js) — text-decoration/color reset so it still reads as
   this same static affordance, not a text link, until it's hovered. */
.research-card__more {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--gray-4);
  text-decoration: none;
  transition: color 0.25s ease;
}

.research-card__more-text {
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
}

/* A dedicated right-pointing chevron (its own path, own tightly-fit 7x11
   viewBox — see research-render.js), not the down-chevron rotated via
   CSS transform: rotating/scaling a 16x10 box down to this size left an
   invisible margin inside that box wherever the visible glyph ended up
   short of its edges, which is what actually made the icon read as
   misaligned/too far from "more" — align-items: center only centers
   BOXES, and that box was bigger than what was visibly drawn in it.
   Drawing the arrow at its real final size and orientation means the box
   IS the glyph, so centering it against the text is exact — exact
   against .research-card__more-text's own LINE BOX, that is, which is
   NOT the same as centered against the "more" glyphs' visible ink: a
   16px/500 line-height box is ~23px tall while "more"'s actual ink (no
   ascenders/descenders) is only ~10px, sitting slightly below that box's
   center rather than dead-center — measured directly (getBoundingClientRect
   of the text box vs. Canvas measureText's actualBoundingBoxAscent/Descent
   for this exact font/weight/size) at a consistent 2.4px. This nudges the
   chevron down by that exact, measured amount so it centers against the
   visible "more" ink instead of the invisible box around it. */
.research-card__more-chevron {
  flex: none;
  transform: translateY(2.4px);
}

@media (max-width: 900px) {
  .research-card {
    flex-direction: column;
  }

  .research-card__image,
  .research-card__info {
    height: auto;
  }

  .research-card__image {
    aspect-ratio: 4 / 3;
  }
}

/* Research topic detail page (research-detail.html) — one topic's own
   gallery + full text, reached by clicking its card in the list above. */
.research-detail {
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.research-detail__back {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  align-self: flex-start;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-3);
  text-decoration: none;
  transition: color 0.25s ease;
}

.research-detail__back:hover {
  color: var(--accent);
}

/* Photo column on the left, text on the right — same arrangement as
   .news-post (news.html), not the old top-photo/bottom-text stack. The
   fixed column width is the exact same formula as .news-post__frame (5
   grid columns, or 506px flat below 1440) so both pages' photo columns
   line up at the same size. align-items: flex-start for the same reason
   as .news-post: a short topic's gallery shouldn't stretch tall to match
   a long one's text, nor vice versa. */
.research-detail__layout {
  display: flex;
  align-items: flex-start;
  gap: 40px;
}

/* More than one image wraps into an even grid, each a fixed-ratio box
   (object-fit: contain, same as the list card's own image — these are
   figures, not photos, so nothing should get cropped to fill a box). The
   column's own fixed width (above) already keeps a single image from
   ballooning oversized, so unlike the old top-of-page layout this needs no
   separate single-image size cap. */
.research-detail__gallery {
  flex: 0 0 max(506px, calc(5 * var(--col-w) + 4 * var(--grid-gap)));
  width: max(506px, calc(5 * var(--col-w) + 4 * var(--grid-gap)));
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 24px;
}

.research-detail__image {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: contain;
  display: block;
  border-radius: 4px;
  box-shadow: 0 0 16px rgba(0, 0, 0, 0.15);
}

.research-detail__content {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

@media (max-width: 900px) {
  .research-detail__layout {
    flex-direction: column;
  }

  .research-detail__gallery {
    width: 100%;
  }
}

.research-detail__body {
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 800px;
}

.research-detail__paragraph {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  line-height: 1.7;
  color: var(--gray-2);
}

/* Related publications listed under a topic's own body text (see
   research-data.js's detail.publications and research-detail-render.js) —
   plain citation lines, no figure thumbnails needed since publication.html
   already carries each paper's image. */
.research-detail__publications {
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 800px;
}

.research-detail__publications-heading {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 20px;
  color: var(--gray-2);
}

.research-detail__publications-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* The whole citation is the hover target — clicking it deep-links to that
   exact paper's own highlighted spot on publication.html (see
   publication-render.js's scrollToRequestedPublication). */
.research-detail__publication-link {
  display: block;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 15px;
  line-height: 1.7;
  color: var(--gray-3);
  text-decoration: none;
  transition: color 0.2s ease;
}

.research-detail__publication-link:hover {
  color: var(--accent);
}

.research-detail__publication-journal {
  font-style: italic;
}

/* ============================================================
   TEMP DEBUG — delete this whole block (and its companion block
   in components.js) once the responsive column scaling above 1440px is
   confirmed to line up correctly. Not meant to look polished.

   Two layers:
   - __frame: dashed outline at the literal 1440px reference width the
     site was designed at, centered, so it's obvious how far past the
     original design the viewport has stretched.
   - __inner: the live 96px-margin / 12-column / 24px-gutter grid, built
     from the same --side-padding/--grid-columns/--grid-gap variables the
     real layout uses — so if --side-padding's formula changes, this
     overlay's margins move with it automatically. */
.design-grid-overlay {
  position: fixed;
  inset: 0;
  z-index: 99999;
  pointer-events: none;
}

.design-grid-overlay__frame {
  position: absolute;
  inset: 0;
  width: var(--page-max);
  margin: 0 auto;
  border-left: 1px dashed rgba(255, 140, 0, 0.6);
  border-right: 1px dashed rgba(255, 140, 0, 0.6);
}

.design-grid-overlay__inner {
  position: absolute;
  inset: 0;
  padding: 0 var(--side-padding);
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: var(--grid-gap);
}

.design-grid-overlay__col {
  height: 100%;
  background: rgba(255, 0, 0, 0.08);
  outline: 1px solid rgba(255, 0, 0, 0.35);
}

.design-grid-overlay-toggle {
  position: fixed;
  bottom: 16px;
  right: 16px;
  z-index: 100000;
  padding: 8px 14px;
  background: black;
  color: lime;
  border: 1px solid rgba(0, 255, 0, 0.5);
  border-radius: 4px;
  font: 12px monospace;
  cursor: pointer;
}
/* ============================================================ */

/* Contact page — no page title, just the two columns. 104px left/right is
   on top of .page-container's own side-padding (same "additional inset"
   pattern as the Professor section's 212px), 64px bottom is this
   container's own (.page-container itself has none). No top padding here —
   .page-container's own 64px is the only top offset, matching News/
   Publication's baseline instead of sitting further down than them. The
   104px horizontal inset has no upper cap, same "unchanged at/below 1440,
   scales past it" pattern as --side-padding above. Unlike .student-card's
   318px, 104px doesn't land on any column-N edge in the 12-col/24-gutter
   system (it's a custom-tuned value, not grid-derived), so this stays a
   flat ratio of the reference width rather than a column formula. */
/* justify-content: center — the image column (377px, fixed) and form
   (max-width: 640px, so it stops growing well before the row does) add
   up to a fixed natural width. Without this the pair just sat at the
   row's start, so on wide screens the whole thing read as pinned to the
   left edge instead of sitting in the middle of the page; centering the
   flex children (rather than fussing with the row's own width/margin)
   keeps it centered regardless of how wide the row itself gets. */
.contact-layout {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 48px;
  padding: 0 max(104px, calc(104 / 1440 * 100vw)) 64px;
}

.contact-image {
  flex: none;
  width: 377px;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.contact-image__text {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.contact-image__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 56px;
  line-height: 1.1;
  color: var(--gray-1);
}

.contact-image__title-dot {
  color: var(--accent);
}

.contact-image__subtitle {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 24px;
  color: var(--gray-1);
}

.contact-image__gif {
  width: 377px;
  height: 377px;
  display: block;
  object-fit: cover;
}

/* max-width keeps the inputs/textarea at a comfortable reading width even
   as .contact-layout keeps growing on wide screens — flex:1 still lets it
   shrink normally on narrower ones. */
.contact-form {
  flex: 1;
  min-width: 0;
  max-width: 640px;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.contact-form__section {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.contact-form__label {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 16px;
  color: var(--gray-2);
}

/* Stroke only (same line color as everywhere else that borrows it — the
   Publication DOI badge, the Alumni/Research dash-lines) — no fill, these
   are plain input chrome, not buttons. */
.contact-form__input {
  width: 100%;
  height: 48px;
  padding: 0 16px;
  border: 2px solid var(--divider-color);
  border-radius: 4px;
  background: transparent;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-1);
  transition: border-color 0.15s ease;
}

.contact-form__input::placeholder {
  color: var(--gray-4);
}

.contact-form__input:focus {
  outline: none;
  border-color: var(--accent);
}

.contact-form__input--message {
  height: 296px;
  padding: 12px 16px;
  resize: none;
}

/* min-width: 0 — without it, this row's own min-content width (the two
   fixed-144px recipient buttons plus the submit button's own nowrap
   "Send message to <name>" text) became a hard floor .contact-form
   couldn't shrink below, so on any viewport narrower than that floor the
   whole form rendered far wider than the page and sat shifted/cut off on
   both sides instead of actually shrinking to fit — the same min-width:
   auto flex default already worked around everywhere else in this
   codebase (.professor__info-main, .news-post__body, etc.). */
.contact-form__actions {
  display: flex;
  align-items: center;
  min-width: 0;
  gap: 8px;
}

/* The wide button gets its 24px separation from the pair before it via
   margin-left (not the row's own gap, which is 8 — the spec gives the two
   fixed buttons and the wide one different gaps from each other). Fill is
   Gray6 (#f2f2f2), not Gray4 — with Gray4 text (Professor/Lab manager) or
   Gray2 text (Send Message) on top, that's what actually keeps them
   readable at rest instead of text and fill matching. */
.contact-form__button {
  flex: none;
  width: 144px;
  height: 48px;
  border: none;
  border-radius: 4px;
  background: var(--gray-6);
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 16px;
  color: var(--gray-4);
  cursor: pointer;
  transition: color 0.15s ease;
}

.contact-form__button.is-selected {
  color: var(--accent);
}

/* display: flex + gap, not the plain <button> default (inline content,
   center text-align): a bare text node ("Send message to") next to
   .contact-form__recipient-slot still each become their own flex item
   (anonymous box, per spec), but align-items: center here centers them
   against each other by their actual box heights — vertical-align:middle
   on an inline-block (the alternative) centers against the surrounding
   line's font metrics instead, which visibly sat the recipient name a
   few px lower than "Send message to". justify-content: center keeps the
   "whole label re-centers as the name's width changes" behavior a plain
   centered button had. */
.contact-form__button--submit {
  flex: 1;
  min-width: 0;
  width: auto;
  margin-left: 24px;
  color: var(--gray-2);
  white-space: nowrap;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
}

/* Holds whichever recipient name is current ("Lab manager" / "Professor")
   so the button can read "Send message to <name>" instead of a flat
   "Send Message" that never says who it's actually going to. Sized in JS
   (contact-form__recipient-slot's own width, set from the active name's
   scrollWidth — see script.js) rather than left to shrink-wrap, because
   the two names are different widths: animating this box's width is what
   makes "Send message to" itself glide over to re-center on the button
   (a plain <button>'s text is center-aligned by default) instead of
   snapping to a new position the instant the name changes.
   display: inline-block + overflow: hidden: this is what turns the two
   stacked names below into a small clipped "window" exactly as wide as
   the visible one, instead of a wrapper as wide as both combined. */
.contact-form__recipient-slot {
  position: relative;
  display: inline-block;
  overflow: hidden;
  height: 1.2em;
  transition: width 0.3s ease;
}

/* Both names always sit stacked in the same spot (absolute, same
   top/left) — only one is ever opacity: 1 at a time. Swapping is a
   crossfade-and-slide rather than an instant text swap: the outgoing name
   drifts down and fades while the incoming one rises in from the same
   offset below, so the two cross paths instead of one flatly replacing
   the other. */
.contact-form__recipient-name {
  position: absolute;
  left: 0;
  top: 0;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.25s ease, transform 0.25s ease;
}

.contact-form__recipient-name.is-active {
  opacity: 1;
  transform: translateY(0);
}

.contact-form__button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

/* Empty at rest — takes up no space until the submit handler (script.js)
   fills it in with a sending/success/error message. */
.contact-form__status {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 14px;
  color: var(--gray-3);
}

.contact-form__status[data-state="error"] {
  color: #d33;
}

.contact-form__status[data-state="success"] {
  color: #2a8a4a;
}

@media (max-width: 900px) {
  .contact-layout {
    flex-direction: column;
    padding: 0 var(--side-padding) 64px;
  }

  .contact-image {
    width: 100%;
  }

  .contact-image__gif {
    width: 100%;
    height: auto;
    aspect-ratio: 1 / 1;
  }

  .contact-image__title {
    font-size: clamp(32px, 9vw, 56px);
  }

  .contact-image__subtitle {
    font-size: 18px;
  }

  /* The two recipient buttons keep their fixed 144px (they still fit two
     to a row down to fairly narrow phones) but wrap onto their own line
     from the submit button once the row as a whole runs out of room,
     rather than everyone staying on one line and forcing .contact-form
     wider than the page (see .contact-form__actions's own comment).
     flex-basis: 100% is what actually forces that break — a flex item
     wider than the remaining space on a wrap line drops to the next one
     on its own, but nothing here is that wide by itself, so the row just
     unevenly cramped itself instead of ever wrapping without this. */
  .contact-form__actions {
    flex-wrap: wrap;
    row-gap: 12px;
  }

  .contact-form__button--submit {
    flex-basis: 100%;
    margin-left: 0;
  }
}
