/* ===================================================================
   Word for Word — design tokens
   Palette is faithful to the brand swatch (hex codes as supplied).
=================================================================== */
:root {
  --color-dark:  #60362A; /* backdrop / footer / deep text */
  --color-tan:   #F1D7BC; /* warm cream square */
  --color-cream: #F9F5EC; /* lightest square / page background */
  --color-red:   #B93E2D; /* brand accent square */
  --color-grey:  #9A9EAA; /* muted blue-grey square */
  /* One-off, explicitly requested addition — NOT part of the 5-color
     brand swatch above. Used only by the intro splash's cream-bg
     variant (screens 01-04), for asterisk/caption 03 ("darker beige,"
     between --color-tan and --color-dark). Darkened ~10% from the
     original #DBB388 per explicit follow-up request — safe to darken
     directly (not via a scoped override) since this variable is a
     one-off used ONLY by splash screen 03, nowhere else on the site. */
  --color-tan-dark: #C5A17A;
  /* One-off, explicitly requested — the backdrop behind section 3's
     illustration (.split-cta__illustration-wrap) only. */
  --color-illustration-bg: #0e1316;
  /* One-off — matches the baked-in color of "Asterisk 2 (nav).png"
     exactly (sampled from the asset itself), used only for screen 02's
     .page-flash caption text. Lighter/warmer than --color-tan, so kept
     separate rather than reusing that variable. */
  --color-beige-nav: #FFE3C6;
  /* Splash screen 02's own darkened variant of --color-grey (~10%
     darker), per explicit request. NOT a replacement for --color-grey
     itself — that variable is shared across ~10 other things sitewide
     (squares, page-flash, publication cards, etc.), so darkening it
     globally would have darkened all of those too. Scoped to just the
     2 splash-02 rules that use it (see .intro-splash__caption--2/
     .intro-splash__asterisk--2 below) instead. */
  --color-grey-splash-dark: #8B8E99;

  --font-display: "Instrument Serif", Georgia, "Times New Roman", serif;
  --font-body: "Newsreader", Georgia, serif;

  /* The only 2 sizes body/UI text should ever use (display/headline
     text — section labels, titles, quote blocks — is its own thing and
     intentionally varies per section for hierarchy, not part of this
     scale). Anywhere text needs to be smaller than paragraph copy
     (nav, captions, meta, footer, card sub-text), use --text-small —
     never a one-off rem value or an opacity fade on --text-body. */
  /* clamp()'d, not a fixed px value: the min end is what these actually
     shrink to on a real phone (checked against 320-430px viewports),
     the preferred vw component is what makes them keep scaling smoothly
     in between rather than jumping only at breakpoints, and the max end
     pins them back to the original desktop value so nothing above
     720px changes. */
  /* clamp()'d per explicit request, matching --text-small's own pattern
     right below — the comment above already documented this as the
     intent ("checked against 320-430px viewports") but this was left as
     a flat value. 17px floor holds through small phones (up to ~378px,
     where 4.5vw crosses it), then grows back to the original 22px by
     ~489px — bigger phones/phablets read at the same size as desktop;
     only genuinely narrow screens get the reduction. */
  --text-body: clamp(17px, 4.5vw, 22px);
  --text-small: clamp(12.5px, 3.2vw, 15px);
  /* The marquee strips' own unique size (see .marquee-banner) — a
     deliberate third value roughly midway between --text-body and the
     marquees' old 40px display size, promoted to a shared root token so
     other spots (e.g. .square__role, the Get Involved roles grid's
     default role-name text) can reuse the exact same size on request,
     not just the marquees themselves. */
  --marquee-text-size: clamp(21px, 6.5vw, 32px);

  --page-margin: 10px;
  /* Logo is 52px tall, centered 38px from the top (see --nav-item-center
     below) — so it needs at least 64px of bar before it'd start
     clipping. 70px keeps a small 6px breathing gap below it (down from
     the old symmetric 12px/12px at 76px total). */
  --nav-height: 70px;
  /* Where the logo/nav text sit, measured from the bar's top edge —
     kept as its own fixed value (half of the OLD 76px height) rather
     than 50% of --nav-height, specifically so shrinking the bar (to
     tighten the gap before .viewport-frame takes over) doesn't also
     drag the nav items up with it. The bar shrinks from the BOTTOM
     only: top of items unchanged, less empty space below before the
     frame begins. */
  --nav-item-center: 40px;
  /* How tall .site-header grows to while the Publications dropdown is
     open — tall enough to fit .nav__dropdown's own 5-item vertical list
     (see that rule for its own sizing/padding) below the main nav row.
     Value confirmed against the list's actual rendered height via
     Playwright, not just estimated from font metrics. */
  --nav-height-expanded: 312px;

  /* Shared by the hero asterisk (13% of .hero__wordmark-wrap's own
     clamp(220px,22vw,380px)) and the intro splash's cream asterisk —
     computed out to a fixed clamp so both are guaranteed the exact
     same size rather than two independently-derived percentages that
     could drift apart. */
  --hero-asterisk-size: clamp(28.6px, 2.86vw, 49.4px);
}

/* Newsreader (swapped in for a previously self-hosted font whose
   license didn't actually cover web serving — see the Kapakana
   @font-face below for the same reasoning). Both files are the real
   variable fonts (wght 200-800,
   plus an opsz axis left on its automatic default), not static
   instances, so one upright + one italic file covers every weight the
   site uses — 300 (body default), 400, 700, and the splash caption's
   360 — via plain font-weight, no font-variation-settings needed. */
@font-face {
  font-family: "Newsreader";
  src: url("../assets/Newsreader-Variable.woff2") format("woff2-variations");
  font-weight: 200 800;
  font-style: normal;
}
@font-face {
  font-family: "Newsreader";
  src: url("../assets/Newsreader-Italic-Variable.woff2") format("woff2-variations");
  font-weight: 200 800;
  font-style: italic;
}
@font-face {
  font-family: "Kapakana";
  src: url("../assets/Kapakana-Regular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
}

/* ===================================================================
   Reset
=================================================================== */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html {
  background: var(--color-cream);
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* legacy Edge */
}
/* overscroll-behavior-y:none (both here and on body below) kills the
   rubber-band/bounce overshoot at the top/bottom of the page — per
   explicit request, wanted on DESKTOP only, where it read as fighting
   Lenis's own smoothed wheel scroll (see initLuxuryScroll() in main.js).
   Mobile never actually goes through Lenis in the first place — Lenis's
   smoothTouch defaults to false, so touch scrolling is already 100%
   native there — meaning this rule was the ONLY thing suppressing the
   bounce on phones too, not a deliberate mobile choice. (hover: hover),
   not a max-width breakpoint: this is genuinely about which input the
   device has, same reasoning as .applications-toast::before's own
   hover-only reveal elsewhere in this file — a touch tablet at desktop
   width should still get the native bounce back, same as a phone. */
@media (hover: hover) {
  html {
    overscroll-behavior-y: none;
  }
}
html::-webkit-scrollbar {
  display: none; /* Chrome / Safari */
}
html.lenis, html.lenis body { height: auto; }
html.lenis.lenis-smooth { scroll-behavior: auto; }
/* Set synchronously in index.html's <head>, before the intro splash's
   own screen-01 animation starts covering the page (see that inline
   script) — removed by initIntroScrollLock() in main.js once the
   splash has genuinely finished sliding away. Plain overflow:hidden is
   enough to block scroll regardless of Lenis: Lenis (initLuxuryScroll()
   in main.js) drives the browser's own native scroll position under the
   hood (just smoothed), so there's no separate transform-based scroll
   layer here for this to fight — no scrollable overflow means Lenis has
   nothing to move either. */
html.intro-scroll-locked,
html.intro-scroll-locked body {
  overflow: hidden;
}
body {
  background: var(--color-cream);
  color: var(--color-dark);
  font-family: var(--font-body);
  font-size: var(--text-body);
  /* Newsreader Light (300) — the lighter weight, alongside 400/700
     still available for anything that explicitly asks for them (e.g.
     none currently). */
  font-weight: 300;
  line-height: 1.4;
  padding: var(--page-margin);
}
/* See html's own (hover: hover)-scoped override above — same reasoning,
   just the body side of the same property. */
@media (hover: hover) {
  body {
    overscroll-behavior-y: none;
  }
}
/* Per explicit request: body text "the slightest bit thicker" on
   mobile. Newsreader is a real variable font (weight axis 200-800, see
   the @font-face comment above), so this isn't limited to jumping to
   the next named weight (400/Regular, a much bigger jump from 300) —
   360 is a small step up, already used elsewhere on this site (the
   intro splash caption) as an established "a bit heavier than the
   default Light" value, reused here rather than inventing a new
   one-off number. */
@media (max-width: 720px) {
  body {
    font-weight: 360;
  }
}
a { color: inherit; text-decoration: none; }
h1, h2, h3, p { margin: 0; }
img { max-width: 100%; display: block; }
ul { list-style: none; margin: 0; padding: 0; }

/* ===================================================================
   Intro splash — full-viewport asterisk flip-through, covering
   everything on load. Flips through 4 placeholder asterisks with hard
   cuts at a CONSTANT pace (350ms hold each — was an accelerating
   600/350/200/150ms cadence, per feedback that it should stay steady
   instead of speeding up), each hold followed by a tiny 70ms blank gap
   before the next one cuts in — a beat between swaps rather than one
   appearing the instant the other disappears. The background cuts
   through a color per asterisk too (see the intro-splash-bg-*
   animations below) — deliberately paired with a DIFFERENT palette
   color than the asterisk's own, Monogatari-title-card style, not a
   single flat color or a same-family "wash": cream bg/red asterisk
   (01) / grey bg/tan asterisk (02) / tan bg/dark asterisk (03) / dark
   bg/grey asterisk (04) / red bg/cream asterisk (00, the real logo).
   Lands on the real one at 1680ms, lingers 250ms, then the whole
   screen slides up (opacity untouched — no fade) over 1s starting at
   1930ms, same easeOutCubic curve as the hero slide-ins
   (cubic-bezier(0.33,1,0.68,1)). That reveals a brown layer sitting
   right behind it (see .intro-splash-layer), which itself slides up
   over 0.4s starting at 2430ms — halfway through red's own slide — to
   reveal a blue-grey layer behind THAT (2630ms, 0.4s, halfway through
   brown's) — a rapidfire cascade rather than a single reveal. Every
   other intro-delay on the page is set relative to the LAST layer
   finishing — see the HTML comment above <header>.
=================================================================== */
.intro-splash {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background: var(--color-cream);
  /* Lets the cream->red flip below (splash.style.backgroundColor, set by
     initSplashScreens() in main.js) crossfade instead of snapping — a
     `transition` reacts to that exact synchronous style change rather
     than running on its own separately-scheduled clock, so unlike the
     old @keyframes-based crossfade this removed (see comment below), it
     can't drift out of sync with the final screen's own cut-in. */
  transition: background-color 0.45s ease-in-out;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 30px;
  pointer-events: none;
  /* Cream for the entire 01-04 run (a trial variant — was a different
     bg color per screen) — nothing to crossfade between phases 1-4
     since the color never actually changes there, so only 2 bg
     animations total: cream at 0ms (steps(1) would be equally correct
     here since there's nothing to fade between, but 0.15s ease costs
     nothing and stays consistent with the red one below) and red at
     1680ms, crossfading into the final/00 logo screen. Screens 01-04
     vary by asterisk/caption COLOR instead (see
     intro-splash__asterisk--N below), red/blue/darker-beige/brown in
     that order.
     Slide delay bumped 1930->2180ms (+250) — lingers on 00 a bit longer
     before clearing. .intro-splash-layer--brown/--grey below (the
     rapidfire cascade this reveals) are shifted by that SAME +250ms to
     preserve their existing relative timing, and so are the hero
     title's own --intro-delay values in index.html (they're timed to
     start right as this cascade clears). initIntroReveal()'s final
     anchor timeout in main.js is NOT shifted — it's anchored to the
     eyebrow lines finishing (unaffected by any of this), not to the
     splash/title timeline, and stays comfortably later than both either
     way.

     intro-splash-slide is NOT in this unconditional list — see
     .intro-splash.is-ready-to-slide below. The 01-04 asterisk flip-
     through and landing on 00 are driven by initSplashScreens() in
     main.js, not CSS — see that function's own comment for why. It's
     only the SLIDE-AWAY that's held back until initIntroSplashHold() in
     main.js confirms the page is genuinely ready, so a slow connection
     lingers on the "00" logo screen instead of clearing to reveal a
     still-blank page underneath (see that function's own comment).
     The cream->red background change used to be driven by a separate
     CSS animation timed against the document timeline (intro-splash-bg-
     cream/-red, now removed) — a SECOND independent thing racing the
     same clock initSplashScreens() reads, which could drift from it
     under load the exact same way screens 1-4 could once drift from
     each other (see that function's comment): reported live as a
     "flash of nothing" right before screen 00, i.e. the background
     already having turned red while the final asterisk/caption hadn't
     cut in yet, or vice versa. initSplashScreens() now sets
     splash.style.backgroundColor directly, in the SAME synchronous
     callback as the final screen's own cut-in — one JS-owned state
     change instead of two independently-timed ones. Per follow-up
     feedback that the resulting flip still read as too abrupt, the
     `transition` above now crossfades it — safe against the same drift
     bug because a transition fires off that one synchronous assignment
     itself rather than running on its own separately-scheduled clock. */
}
/* Added by initIntroSplashHold() once BOTH the minimum visual time
   (2480ms — matching the original fixed slide delay this replaces) AND
   real readiness (fonts loaded, DOM parsed) are satisfied, whichever is
   later — see that function for the exact race/timeout. */
.intro-splash.is-ready-to-slide {
  animation: intro-splash-slide 1s cubic-bezier(0.33, 1, 0.68, 1) 0ms forwards;
}
/* Smaller than the hero asterisk (0.65x) — this one's just a splash
   decoration, not meant to read at full hero size. Buzzes continuously
   through ALL FOUR numbered asterisks (1/2/3/4 — this is one shared
   wrapper, so whichever child asterisk is currently cut in rides the
   same wiggle), then DECAYS to a smooth, still stop in the brief 70ms
   gap between asterisk--4 hiding (1910ms) and the final/00 asterisk
   landing (1980ms — see .intro-splash__asterisk--final's own cut
   delay), so there's a genuine settled beat before .intro-splash itself
   slides away (2480ms). Buzz must stay at full amplitude all the way to
   ~96% (not decay gradually from 70% as an earlier pass did) — with a
   270-1980ms window and the four numbered screens' visible windows
   scattered across the FULL span (asterisk--4's is 1560-1910ms, deep
   into what used to be the decay tail), a gradual decay starting too
   early left the later asterisks looking like they'd already gone
   still. Confining the decay to the invisible 1910-1980ms gap means
   every numbered asterisk gets the same lively wiggle, and the decay
   itself is never actually seen (nothing is on-screen during that gap)
   — only its result, a fully-settled final asterisk. One single,
   self-contained, non-infinite animation spanning that whole 1980ms
   window (not "infinite jitter forever, then separately triggered
   settle") deliberately avoids layering two named animations on the
   same element/property: a same-class-of-bug investigation elsewhere in
   this file (see the reset-timing work on the hero asterisk) found that
   when two animations both end up simultaneously active/forwards-filled
   on the same property, which one wins is NOT reliably "whichever is
   listed last" — a real browser quirk, not just a theoretical risk. A
   single keyframe list sidesteps that ambiguity entirely. Every offset
   stays small (max ~0.6px — an earlier ~1.5px read as too intense). */
.intro-splash__asterisk-wrap {
  position: relative;
  width: calc(var(--hero-asterisk-size) * 0.65);
  aspect-ratio: 184 / 200;
  /* 1900ms matches the final/00 screen's real landing time now that
     01-04 were sped up (see SPLASH_SCREEN_CUT_DELAYS_MS in main.js,
     was 1980ms) — keyframe percentages below are unchanged, so the decay
     tail still lands right as "00" cuts in. */
  animation: intro-splash-asterisk-jitter-settle 1900ms linear forwards;
}
@keyframes intro-splash-asterisk-jitter-settle {
  0%   { transform: translate(0, 0); }
  5%   { transform: translate(-0.6px, 0.4px); }
  10%  { transform: translate(0.4px, -0.6px); }
  15%  { transform: translate(-0.4px, -0.4px); }
  20%  { transform: translate(0.6px, 0.3px); }
  25%  { transform: translate(-0.5px, 0.5px); }
  30%  { transform: translate(0.3px, -0.5px); }
  35%  { transform: translate(-0.6px, 0.2px); }
  40%  { transform: translate(0.5px, -0.3px); }
  45%  { transform: translate(-0.3px, 0.6px); }
  50%  { transform: translate(0.4px, -0.4px); }
  55%  { transform: translate(-0.5px, -0.3px); }
  60%  { transform: translate(0.6px, 0.4px); }
  65%  { transform: translate(-0.4px, 0.5px); }
  70%  { transform: translate(0.3px, -0.6px); }
  /* Same buzz cycle repeated (not decayed) — keeps asterisk--3's and
     asterisk--4's own visible windows (1140-1490ms and 1560-1910ms) just
     as lively as asterisk--1's. */
  75%  { transform: translate(-0.6px, 0.4px); }
  80%  { transform: translate(0.4px, -0.6px); }
  85%  { transform: translate(-0.4px, -0.4px); }
  90%  { transform: translate(0.6px, 0.3px); }
  95%  { transform: translate(-0.5px, 0.5px); }
  /* Decay tail, compressed into the invisible 1910-1980ms gap after
     asterisk--4 has already hidden — never actually seen mid-motion,
     only its settled result once the final asterisk cuts in. */
  97%   { transform: translate(0.15px, -0.15px); }
  98.5% { transform: translate(-0.05px, 0.05px); }
  100%  { transform: translate(0, 0); }
}
/* All 5 stacked in the same spot. Each one's own hide fires 70ms
   BEFORE the next one's appear (not simultaneously) — that gap is
   what reads as a beat of plain background between swaps rather than
   an instant replace. Two comma-separated animations per element (cut,
   then hide) — later-listed animation wins once both are in their
   forwards-fill period. object-fit:contain handles the 4 placeholder
   images' slightly different aspect ratios without distorting them. */
.intro-splash__asterisk {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  opacity: 0;
}
/* Each asterisk's caption (see .intro-splash__caption-wrap below) cuts
   in/out at the EXACT same timestamps as its own asterisk — sharing one
   selector list per phase keeps the two locked together with no risk
   of drifting apart if the timing ever changes. */
/* All 5 screens (1/2/3/4/final) are driven by ONE unified JS mechanism —
   initSplashScreens() in main.js — via a plain .is-cut opacity toggle,
   not animation-delay/steps(1) per screen. That WAS the system here
   (each screen's own cut/hide scheduled against the document timeline),
   and it held up fine for the general "connection is slow" case this
   whole file's comments describe at length. It did NOT hold up under
   real, sustained CPU throttling: confirmed live (6x CPU throttle via
   CDP) that a LATER screen's own cut-in could render AFTER an EARLIER
   screen's hide had already been superseded by the (JS-driven, elapsed-
   corrected) final screen's own cut — i.e. document.timeline.currentTime
   already reported "we're well past everything," while the renderer
   itself hadn't yet caught up repainting an EARLIER screen's own CSS
   animation to match that same current time. Two independent screens
   each racing the SAME document timeline can drift from each other
   under exactly the load conditions ("my computer is slow") this was
   supposed to survive.
   initSplashScreens() schedules every screen's cut AND hide via
   setTimeout, all read against the SAME document.timeline.currentTime
   snapshot at once, each one flipping its own is-cut in a single
   synchronous statement — there's no remaining path for two screens to
   disagree about where "now" is, because there's only one JS-owned
   answer to that question. Trade-off, deliberately accepted: screen 1
   no longer appears before DOMContentLoaded (previously guaranteed by
   being pure CSS, independent of JS entirely) — on a connection so slow
   that DOMContentLoaded itself is meaningfully delayed, the cream
   background now shows a bit longer before the sequence starts, rather
   than screen 1 alone appearing early. Chosen over the alternative
   (visibly overlapping/glitching screens under load) per explicit
   feedback that the latter is the higher-priority failure mode. */
.intro-splash__asterisk--1,
.intro-splash__asterisk--2,
.intro-splash__asterisk--3,
.intro-splash__asterisk--4,
.intro-splash__asterisk--final,
.intro-splash__caption--1,
.intro-splash__caption--2,
.intro-splash__caption--3,
.intro-splash__caption--4,
.intro-splash__caption--final {
  opacity: 0;
}
.intro-splash__asterisk--1.is-cut,
.intro-splash__asterisk--2.is-cut,
.intro-splash__asterisk--3.is-cut,
.intro-splash__asterisk--4.is-cut,
.intro-splash__asterisk--final.is-cut,
.intro-splash__caption--1.is-cut,
.intro-splash__caption--2.is-cut,
.intro-splash__caption--3.is-cut,
.intro-splash__caption--4.is-cut,
.intro-splash__caption--final.is-cut {
  opacity: 1;
}
/* All 5 numbered/final screens share this now (trying out the bounce on
   02/03/04 too, not just 01 and 00/final) — each stays blank a split-
   second longer, then blinks in with a quick vertical bounce, instead of
   the plain hard cut. animation (not transition) is what makes the
   "blank pause" possible in the first place — 120ms delay with fill mode
   `both` holds the keyframes' 0% state (opacity:0) for that whole
   window before the animation itself starts, where a transition has no
   equivalent concept of a pre-start hold. This wins over the shared
   opacity:1 rule just above for these same selectors regardless of
   source order/specificity — a running (or delayed-but-pending, via
   `backwards` fill) CSS animation always overrides a plain property
   declaration for the properties it animates. Each screen's own visible
   window (720/420/420/420ms for 1/2/3/4, per SPLASH_SCREEN_CUT_DELAYS_MS
   in main.js) comfortably outlasts this animation's 360ms total (120ms
   delay + 240ms run), so the bounce always finishes settling before the
   next screen cuts in. Composes fine with
   .intro-splash__asterisk-wrap's own continuous jitter-settle animation
   (a DIFFERENT element — the wrapper around all 5 numbered asterisks,
   see that class below) since nested elements' own `transform`s stack
   independently rather than fighting over the same property.

   The bounce itself animates the standalone `translate` property, NOT
   `transform` — .intro-splash__caption itself already carries its own
   static `transform: translateX(-50%)` for horizontal centering (the
   classic "center an unsized absolutely-positioned box" trick), and a
   CSS animation touching `transform` REPLACES that property's value
   outright rather than merging with it, silently discarding the
   centering the instant this animation applied (confirmed live: the
   caption text drifted off-center while the asterisk — which has no
   transform-based centering of its own to lose — stayed put). `translate`
   is its own independent property (composes with `transform` rather than
   overwriting it, applied before it per spec), so animating THAT instead
   leaves the caption's centering transform completely untouched. */
/* 1-4's own blank pause shortened to 40ms (was 120ms, still shared with
   00/final below) per explicit request — that delay is the actual
   "blank space" between transitions here (the bounce itself doesn't
   start until it elapses), and 4/5 screens paying the longer 120ms
   pause added up to feel sluggish even after the per-screen hold times
   themselves got faster. 00/final keeps the original 120ms (see its own
   rule below) — same "mic drop shouldn't feel rushed" reasoning as
   SPLASH_SCREEN_MIN_HOLD_MS's own last entry in main.js. 40ms + this
   animation's 240ms run = 280ms total, still comfortably inside every
   screen's own 450ms visible window (per SPLASH_SCREEN_CUT_DELAYS_MS in
   main.js) with room to spare. */
.intro-splash__asterisk--1.is-cut,
.intro-splash__caption--1.is-cut,
.intro-splash__asterisk--2.is-cut,
.intro-splash__caption--2.is-cut,
.intro-splash__asterisk--3.is-cut,
.intro-splash__caption--3.is-cut,
.intro-splash__asterisk--4.is-cut,
.intro-splash__caption--4.is-cut {
  animation: intro-splash-flicker-in 240ms cubic-bezier(0.34, 1.8, 0.64, 1) 40ms both;
}
/* Screen 00/final gets the same bounce, per explicit follow-up request —
   reuses the identical animation/keyframes verbatim, just its own
   original 120ms delay (not 1-4's shortened one just above) — see that
   rule's own comment for why. Nothing else on this screen depends on its
   own cut-in being instantaneous: the cream->red background crossfade is
   a separate, JS-driven inline style set in the SAME callback that adds
   is-cut (see initSplashScreens() in main.js), so it still flips right
   on schedule regardless of how long this element's own blank-pause-
   then-bounce takes to visually finish. */
.intro-splash__asterisk--final.is-cut,
.intro-splash__caption--final.is-cut {
  animation: intro-splash-flicker-in 240ms cubic-bezier(0.34, 1.8, 0.64, 1) 120ms both;
}
@keyframes intro-splash-flicker-in {
  0%   { opacity: 0; translate: 0 0; }
  /* Snap to visible almost instantly once the animation itself starts —
     an abrupt opacity jump (not a fade) is what reads as "blink in"
     rather than a soft fade-in. */
  1%   { opacity: 1; translate: 0 -4px; }
  40%  { translate: 0 1.5px; }
  70%  { translate: 0 -0.6px; }
  100% { opacity: 1; translate: 0 0; }
}
/* Trial variant: screens 01-04 all sit on cream now (see .intro-splash
   above), varying by asterisk/caption color instead — red / blue
   (--color-grey, this project's informal name for it) / darker beige
   (--color-tan-dark, DBB388) / brown (--color-dark). Asterisk 1 and the
   final/00 asterisk keep plain <img> tags (their own native PNG color
   already matches what's needed — red and cream respectively). 2/3/4
   are <div>s instead (see index.html) — mask-image (the same PNG, used
   purely as an alpha stencil) over an explicit background-color is the
   only reliable way to recolor a raster asset to an ARBITRARY exact
   color; a plain <img>'s own pixel colors can't be overridden by CSS.
   mask-size/position/repeat below stand in for object-fit:contain,
   which only applies to actual replaced elements (img/video), not divs. */
.intro-splash__asterisk--2,
.intro-splash__asterisk--3,
.intro-splash__asterisk--4 {
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
}
.intro-splash__asterisk--2 {
  -webkit-mask-image: url("../assets/images/Asterisk 2.png");
  mask-image: url("../assets/images/Asterisk 2.png");
  background-color: var(--color-grey-splash-dark);
}
.intro-splash__asterisk--3 {
  -webkit-mask-image: url("../assets/images/Asterisk 3.png");
  mask-image: url("../assets/images/Asterisk 3.png");
  background-color: var(--color-tan-dark);
}
.intro-splash__asterisk--4 {
  -webkit-mask-image: url("../assets/images/Asterisk 4.png");
  mask-image: url("../assets/images/Asterisk 4.png");
  background-color: var(--color-dark);
}
@keyframes intro-splash-slide {
  to { transform: translateY(-100%); }
}

/* Caption: 2 lines (an italic word/phrase, then its "(0N)" tag) below
   the asterisk — Monogatari-style title-card text, not a caption in the
   nav/footer sense. The 30px gap above it comes from .intro-splash's
   own flex `gap` (it's the 2nd of 2 flex children, asterisk-wrap being
   the 1st) — just needs position:relative here as the containing block
   for its own stacked, individually absolutely-positioned captions
   below (same reason .intro-splash__asterisk-wrap needs it for its
   stacked asterisk images). No explicit height: with every
   .intro-splash__caption child absolutely positioned, this collapses to
   0 height, which is fine — the flex gap only cares about this box's
   own (collapsed) edge, not its overflowing content. */
.intro-splash__caption-wrap {
  position: relative;
}
.intro-splash__caption {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 15px;
  margin: 0;
  white-space: nowrap;
  text-align: center;
  opacity: 0;
}
.intro-splash__caption-line1 {
  font-family: var(--font-body);
  /* 360, not a standard static weight (300 Light/400 Regular) — sits
     deliberately between them on Newsreader's own variable wght axis,
     per explicit request for this one spot. */
  font-weight: 360;
  font-style: italic;
  font-size: var(--text-body);
}
.intro-splash__caption-line2 {
  font-family: var(--font-display);
  font-size: var(--text-body);
}
/* "1" renders narrower than "0"/"2"/"3"/"4" in Instrument Serif's
   proportional digit widths — since each caption's line2 is
   independently centered (see .intro-splash__caption's
   align-items:center), "(01)"'s narrower digits leave less space
   between its parens than "(02)"/"(03)"/"(04)" do, which reads as the
   parens themselves shifting/breathing between screens even though
   they're centered the same way. Padding added specifically INSIDE
   "(01)"'s own parens (not a change to the parens or the container)
   widens just its digit pair to match how far apart "0"+"2"/"0"+"3"/
   "0"+"4" naturally push the others' parens — see the wrapping span in
   index.html. */
.intro-splash__caption--1-digits {
  padding: 0 1.7px;
}
.intro-splash__caption--1 { color: var(--color-red); }
.intro-splash__caption--2 { color: var(--color-grey-splash-dark); }
.intro-splash__caption--3 { color: var(--color-tan-dark); }
.intro-splash__caption--4 { color: var(--color-dark); }
.intro-splash__caption--final { color: var(--color-cream); }

/* Rapidfire cascade behind the red splash — see HTML comment. Each
   layer starts its own quick slide once the one ABOVE it is ~80%
   through its own slide (same "don't wait for the sub-pixel tail"
   convention used throughout the intro). Reuses intro-splash-slide
   (translateY only, no fade) since these are also just quick slides,
   not fades.

   JS-triggered now (.is-triggered, added by initIntroSplashHold() in
   main.js), not fixed/absolute CSS delays like this used to be — those
   old delays (2980ms/3180ms) were guesses tied to .intro-splash's own
   ONCE-fixed slide-away timing; once that became real-readiness-based
   instead (.intro-splash.is-ready-to-slide, same function), a
   connection slow enough to actually trigger the hold left this
   cascade's own fixed delays already elapsed by the time the splash
   FINALLY cleared — these layers had already finished sliding,
   invisible, stacked behind the still-present splash, so the whole
   rapidfire brown/grey flash silently never appeared at all (reported
   live on the deployed site). Hooking these off the SAME real moment
   the splash itself starts sliding — not a fixed guess — is what
   actually fixes that: no CSS-declared animation at rest, so there's
   nothing TO have already finished before the real trigger fires. */
.intro-splash-layer {
  position: fixed;
  inset: 0;
  pointer-events: none;
}
.intro-splash-layer--brown {
  background: var(--color-dark);
  z-index: 9998;
}
.intro-splash-layer--grey {
  background: var(--color-grey);
  z-index: 9997;
}
/* Delay is 0 on both — the "starts halfway through the previous layer's
   own slide" relationship (500ms into .intro-splash's 1s slide; 200ms
   into brown's own 0.4s slide) is baked into WHEN main.js adds these
   classes (via its own setTimeout pair, both anchored to is-ready-to-
   slide) rather than into a CSS animation-delay, so it stays correct
   however late that real moment ends up landing. */
.intro-splash-layer--brown.is-triggered {
  animation: intro-splash-slide 0.4s cubic-bezier(0.33, 1, 0.68, 1) 0ms forwards;
}
.intro-splash-layer--grey.is-triggered {
  animation: intro-splash-slide 0.4s cubic-bezier(0.33, 1, 0.68, 1) 0ms forwards;
}

/* Set synchronously in index.html's <head> (the inline script right
   after __pageLoadStart), not by any JS that runs later — the splash's
   own screen-01 animation starts from parse time with no JS gating at
   all, so anything added only on DOMContentLoaded would be too late to
   stop a brief flash of it. Set when the "Overview" nav link was
   clicked to get here (see initNavPageFlash() in main.js) rather than a
   fresh reload or the logo (initLogoSecretEntry()) — per explicit
   request, only that one link should skip the splash, showing the
   quick .page-flash (below) in its place instead. Hides all 3 stacked
   full-screen layers instantly; initIntroReveal() in main.js separately
   shifts the hero's own entrance to start right away instead of still
   waiting out the (now invisible) splash. */
html.skip-intro-splash .intro-splash,
html.skip-intro-splash .intro-splash__asterisk-wrap,
html.skip-intro-splash .intro-splash-layer--brown,
html.skip-intro-splash .intro-splash-layer--grey {
  animation: none;
  opacity: 0;
  visibility: hidden;
}

/* ===================================================================
   Page flash — a quick, un-choreographed flash shown on arrival at a
   page reached via the main nav (Overview/Publications/About Us/Get
   Involved — see initNavPageFlash() in main.js), instead of the
   elaborate homepage-only intro splash above. Same layout as that
   splash's own caption (reuses .intro-splash__caption-line1/-line2
   directly), but with each screen's colors INVERTED from the intro's
   own cream-bg/colored-text convention: here each screen is a solid
   colored bg with light/dark text on top (01 red bg/cream text, 02 blue
   bg/beige text, 03 beige bg/brown text, 04 brown bg/blue text) — see
   the 4 "Asterisk N (nav).png" assets, which come pre-colored to match
   (unlike the intro splash's own asterisks 2-4, no CSS mask/recolor
   needed here). html.show-page-flash is set synchronously by each
   page's own inline <head>/<body> script (same early-execution
   reasoning as html.skip-intro-splash above) reading the one-shot
   sessionStorage["wfw-page-flash"] flag — see that script on each page
   for which number it checks for.

   Two layers, not one: .page-flash itself is a plain, ALWAYS-opaque
   cream cover — shown instantly (not animated) the moment
   html.show-page-flash is set, so the real page underneath is never
   exposed even for a frame. .page-flash__color (its one child) is the
   actual colored screen (bg + asterisk + caption), which fades in ON
   TOP of that solid cream backdrop. Animating the OUTER element's own
   opacity instead (an earlier pass) meant the real page showed faintly
   through it during the fade — cream never being FULLY opaque until the
   fade finished.
=================================================================== */
.page-flash {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background: var(--color-cream);
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
}
/* Opacity/visibility set directly (not via `animation`) — instant, tied
   to html.show-page-flash ALONE (no .page-flash-ready requirement), so
   the cream cover is fully opaque from frame 1 regardless. Only the
   SLIDE is animated (and IS gated on .page-flash-ready — see that
   class's own comment on initPageFlashReady() in main.js for why);
   the fade-in lives on .page-flash__color below. Holds in place for the
   first 900ms (.page-flash__color's own fade + hold), then slides up
   and off over 900ms (900-1800ms) — same translateY(-100%) technique
   and easing as the intro splash's own .intro-splash-slide. The
   header's own drop-in below is timed to this animation's total 1.8s
   length, so it starts the instant this finishes sliding away. */
html.show-page-flash .page-flash {
  opacity: 1;
  visibility: visible;
}
html.show-page-flash.page-flash-ready .page-flash {
  animation: page-flash-slide 0.9s cubic-bezier(0.33, 1, 0.68, 1) 900ms forwards;
}
@keyframes page-flash-slide {
  to { transform: translateY(-100%); }
}
.page-flash__color {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 30px;
  opacity: 0;
}
/* 450ms ease-in-out, not the original 200ms/ease — per explicit
   follow-up feedback that the fade-in read as too abrupt. Softer/longer
   also means the hold below shrinks to 450ms so the total (fade+hold)
   still lands at 900ms, matching .page-flash's own slide delay above
   without needing to touch that number too. */
html.show-page-flash.page-flash-ready .page-flash__color {
  animation: page-flash-color-fade 0.45s ease-in-out forwards;
}
@keyframes page-flash-color-fade {
  to { opacity: 1; }
}
.page-flash__asterisk {
  height: calc(var(--hero-asterisk-size) * 0.65);
  width: auto;
  opacity: 0;
}
/* Continuous jitter, not the intro splash's one-shot settle-to-still
   (there's no "next screen" here to settle in time for) — reuses that
   same jitter's early values (see intro-splash-asterisk-jitter-settle
   above), just looped back to (0,0) for a seamless repeat. Comma'd
   alongside intro-splash-flicker-in (see .intro-splash__asterisk--1.is-cut
   above) so every page-flash screen pops in with the same blank-pause
   +bounce the intro splash's own screens 01/00 use, not just a plain
   fade — the two animations don't fight because they touch different
   properties (this one's `transform`, that one's `opacity`/`translate`). */
html.show-page-flash.page-flash-ready .page-flash__asterisk {
  animation:
    page-flash-asterisk-jitter 0.7s linear infinite,
    intro-splash-flicker-in 240ms cubic-bezier(0.34, 1.8, 0.64, 1) 120ms both;
}
@keyframes page-flash-asterisk-jitter {
  0%     { transform: translate(0, 0); }
  12.5%  { transform: translate(-0.6px, 0.4px); }
  25%    { transform: translate(0.4px, -0.6px); }
  37.5%  { transform: translate(-0.4px, -0.4px); }
  50%    { transform: translate(0.6px, 0.3px); }
  62.5%  { transform: translate(-0.5px, 0.5px); }
  75%    { transform: translate(0.3px, -0.5px); }
  87.5%  { transform: translate(-0.6px, 0.2px); }
  100%   { transform: translate(0, 0); }
}
.page-flash__caption {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 15px;
  text-align: center;
  opacity: 0;
}
/* Same intro-splash-flicker-in bounce as the asterisk above, on the
   caption too — matches the intro splash, where screen 01/00's asterisk
   AND caption both bounce in together. */
html.show-page-flash.page-flash-ready .page-flash__caption {
  animation: intro-splash-flicker-in 240ms cubic-bezier(0.34, 1.8, 0.64, 1) 120ms both;
}
.page-flash--01 { background: var(--color-red); }
.page-flash--02 { background: var(--color-grey); }
/* Default --color-tan, NOT --color-tan-dark — that darker one-off is
   reserved for the intro splash's own screen 03 (see its own comment),
   not this unrelated inverted scheme. */
.page-flash--03 { background: var(--color-tan); }
.page-flash--04 { background: var(--color-dark); }
.page-flash__caption--01 { color: var(--color-tan); }
.page-flash__caption--02 { color: var(--color-beige-nav); }
.page-flash__caption--03 { color: var(--color-dark); }
.page-flash__caption--04 { color: var(--color-grey); }

/* Nav bar: same fade+drop-in treatment as the homepage's own
   .intro-reveal--drop (16px translateY, 0.45s ease-out), timed to start
   the instant .page-flash above finishes sliding away (1.8s) rather
   than the header just sitting there statically this whole time. Hidden
   state stays tied to html.show-page-flash alone (so it can't flash
   visible before .page-flash-ready arrives); only the animation itself
   (and its 1.8s countdown) waits on that class — see its own comment. */
html.show-page-flash .site-header {
  opacity: 0;
  transform: translateY(-16px);
}
html.show-page-flash.page-flash-ready .site-header {
  animation: page-flash-header-drop 0.45s ease-out 1.8s forwards;
}
@keyframes page-flash-header-drop {
  to { opacity: 1; transform: none; }
}

/* ===================================================================
   Header / Nav
=================================================================== */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  /* Both children are positioned absolutely (see .nav__logo / .nav below)
     rather than flex-centered, so this needs an explicit height to
     center them against — with no in-flow content, height:auto here
     would collapse to 0. Both children share the exact same top:50%,
     which is what actually keeps them centered in the bar AND level
     with each other. */
  height: var(--nav-height);
  /* NOT rounding this element's own corners — that was tried and is a
     dead end. The nav bar occupies y:[0, nav-height] exclusively, a
     space scrolled content never renders into, so its own rounded
     corner can only ever reveal the STATIC page background sitting
     behind it — never the scrolled section. Since that background is
     the same cream as the bar, the "reveal" is perfectly camouflaged:
     it isn't a rendering bug, the curve genuinely is there, it's just
     invisible against a matching color (confirmed by isolating a
     minimal repro: identical bug appears with a plain solid-color
     fixed bar over a matching-color body, no other complexity
     involved). The illusion has to come from .viewport-frame instead,
     since IT overlaps the actual scrolled content. */
  /* NOT overflow:hidden here anymore — see .site-header__clip below for
     why that moved. */
}
/* Holds everything that should be VISUALLY clipped/revealed as the bar
   grows (logo, nav, dropdown) — split out from .site-header itself so
   .nav__dropdown-hover-zone (below) can be a plain sibling of this,
   still a real descendant of .site-header (so :has() below can see it),
   WITHOUT being clipped by this element's own overflow:hidden. Earlier,
   with overflow:hidden directly on .site-header, the hover-sustaining
   hit-area was clipped in lockstep with the CURRENT (still-animating)
   height, lagging behind a fast-moving cursor reaching for a link and
   momentarily leaving NEITHER the trigger NOR the (not-yet-revealed)
   hit-area hovered — verified via Playwright: a continuous mouse path
   from the trigger toward a link showed the header's own height
   visibly plateau mid-growth, reported as "jitter." inset:0 keeps this
   tracking .site-header's own (animating) box exactly, at all times,
   with zero extra logic. */
.site-header__clip {
  position: absolute;
  inset: 0;
  padding: 0 32px;
  background: var(--color-cream);
  overflow: hidden;
  /* Without this, this plain wrapper div — not just its actual visible
     content — is a solid, opaque hit-test target across its ENTIRE box,
     including empty space with nothing visibly in it. Since it comes
     AFTER .nav__dropdown-hover-zone in the HTML (so ITS real content
     paints on top of that zone, see the zone's own comment), that
     stacking order means this wrapper's own empty space was ALSO always
     winning over the zone underneath, everywhere — verified: even far
     from any actual link, elementFromPoint resolved to this wrapper, not
     the zone, so the zone could never register hover at all. .nav
     already uses this exact pattern for its own children (pointer-
     events:none here, re-enabled on .nav__logo and .nav__links a). */
  pointer-events: none;
}
/* Sustains the dropdown's open state across the FULL width of the screen
   and the full remaining height of the expanded bar — without this,
   only the narrow column actually containing the link text kept it
   open, so moving the cursor anywhere else in the (visually much
   larger) expanded header area closed the menu, which doesn't match how
   big that revealed region actually looks. A real element (not a
   .nav__dropdown pseudo) specifically so it can be .site-header's own
   DIRECT CHILD — a sibling of .site-header__clip, not nested inside
   it — meaning it's a real descendant of .site-header (so the :has()
   rules above can see it) while never being subject to .site-header
   __clip's own overflow:hidden, unlike the dropdown's actual visible
   content. That's what makes this always full-size the instant it
   becomes relevant, rather than clipped in lockstep with the still-
   animating height (see .site-header__clip's own comment for the
   Playwright-verified jitter this used to cause). pointer-events stays
   off by default — this sits BEFORE .site-header__clip in the HTML, so
   that element's own real content (including the actual clickable
   links) naturally paints on top of it, but with no hover of its own to
   report, this would otherwise just be dead space that's needlessly
   still watching for pointer input under the hero content whenever the
   menu isn't even open. Only switches on once the trigger (or this zone
   itself, to sustain) is already hovered. */
.nav__dropdown-hover-zone {
  position: absolute;
  /* top:0/height:100% of the WHOLE expanded bar (not just the sliver
     below the main nav row) — harmless, since this only ever becomes
     interactive (pointer-events:auto, below) once the dropdown is
     already open; hovering elsewhere up there (the logo, "About Us")
     while it's open just keeps it open, a reasonable trade for staying
     immune to the trigger <li>'s exact geometry entirely. */
  top: 0;
  left: 0;
  right: 0;
  height: var(--nav-height-expanded);
  pointer-events: none;
}
/* pointer-events keyed off the SAME .nav__dropdown-is-open class that
   drives height below — see initPublicationsDropdown() in main.js for
   why this is a JS-managed class rather than a live :has(...:hover)
   condition. A live CSS condition here was tried first and is a genuine
   dead end, not just a stylistic choice: pointer-events would need to
   already be "auto" for the cursor moving from the trigger toward this
   zone to ever register on it, but a :has(trigger:hover) condition
   reverts to false in the exact SAME instant the cursor leaves the
   trigger — i.e. exactly when this zone would need to already be
   listening. Verified via Playwright (instrumenting pointer-events and
   :hover directly) that this produces a real window where NEITHER the
   trigger NOR this zone can be hovered, which read as the reported
   "jitter." JS sidesteps this because entering the trigger sets the
   class (and this zone's pointer-events) BEFORE the cursor ever needs
   to reach the zone, and a real elapsed-time grace period (not a
   same-tick CSS recomputation) is what decides when it later reverts. */
.site-header.nav__dropdown-is-open .nav__dropdown-hover-zone {
  pointer-events: auto;
}
/* Grows the WHOLE bar's height to reveal .nav__dropdown underneath,
   rather than the dropdown popping open as its own floating panel on
   top of the page — explicitly requested. .nav__dropdown-is-open is
   added/removed by initPublicationsDropdown() in main.js (mouseenter/
   mouseleave + a grace-period timer on the trigger and the hover zone
   together), not a live :has(...:hover) condition — see that zone's own
   comment for exactly why a pure-CSS condition doesn't work for this. */
.site-header.nav__dropdown-is-open {
  height: var(--nav-height-expanded);
}
/* The height transition itself lives here, scoped to body.header-ready
   (set by initHeaderReady() in main.js once the header's own entrance
   has genuinely finished — NOT body.intro-finished, which is timed to
   protect the unrelated eyebrow lines' scroll-exit handoff and can stay
   unset for several seconds after the header itself is already sitting
   there fully visible; reusing it here left a real window where
   hovering "Publications" opened the dropdown with an instant height
   snap instead of animating), rather than on the base .site-header rule
   above — .site-header also carries .intro-reveal--drop (its one-time
   page-load fade+drop-in) OR the html.show-page-flash-scoped animation
   (arrival via nav click), either of which declares its OWN competing
   `transition`/`animation` at equal specificity; whichever rule is
   declared later in the file would silently win and fully discard the
   other's transition (shorthand properties replace, they don't merge).
   body.header-ready bumps this rule's specificity from (0,1,0) to
   (0,2,0), so it reliably wins regardless of file order — and since
   body.header-ready is only added once the header's entrance has
   genuinely finished, that entrance is completely unaffected. */
body.header-ready .site-header {
  transition: height 0.5s cubic-bezier(0.33, 1, 0.68, 1);
}
/* Retract gets its own, more dramatically-decelerating curve than the
   0.33,1,0.68,1 "quick start, slow finish" used for opening — this one
   ("easeOutExpo," a common, recognizable "soft landing" shape) starts
   with almost all its motion up front, then spends a long final
   stretch crawling the last few pixels into place, so closing reads as
   genuinely easing into a smooth stop rather than just decelerating a
   little. A touch longer than the 0.5s open (0.65s) gives that long
   tail enough room to actually be perceived. Only overriding duration/
   timing-function (longhands) here, not the whole `transition`
   shorthand — property (height) stays inherited from the base rule
   above unchanged. No close-debounce delay needed here anymore (an
   earlier version added one directly in CSS) — initPublicationsDropdown
   ()'s own grace-period timer in main.js already absorbs any brief
   flicker before .nav__dropdown-is-open is ever removed, so by the time
   this selector starts matching again, it's because the mouse genuinely,
   sustainedly left — no need for a second layer of debounce here too. */
body.header-ready .site-header:not(.nav__dropdown-is-open) {
  transition-duration: 0.65s;
  transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

.nav__logo {
  position: absolute;
  left: 32px;
  top: var(--nav-item-center);
  transform: translateY(-50%);
  /* .site-header__clip (its own parent, see that rule) sets
     pointer-events:none across its whole box specifically so its own
     empty space doesn't sit on top of .nav__dropdown-hover-zone
     underneath — re-enable it here, same pattern .nav already uses for
     its own children (.nav__links a). */
  pointer-events: auto;
}
.nav__logo img {
  height: 52px;
  width: auto;
  /* On the IMG, not .nav__logo itself — that <a> already carries its
     own permanent translateY(-50%) centering nudge, and a hover
     transform declared there would replace it outright (transform is
     never additive across the SAME element). easeOutBack-style overshoot
     (values >1 in the 2nd/4th slots) is what gives a plain transition a
     genuine bounce/wobble feel with no keyframes needed — it overshoots
     past the resting tilt, then settles back. */
  transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.nav__logo:hover img {
  transform: rotate(-14deg);
}

/* Same top:var(--nav-item-center) as .nav__logo — that shared value is
   what keeps the logo and nav text level with each other, pinned to a
   fixed position rather than the bar's shrinking 50% midpoint (see
   --nav-item-center comment in :root). */
.nav {
  position: absolute;
  left: 0;
  right: 0;
  top: var(--nav-item-center);
  transform: translateY(-50%);
  /* This box spans left:0 to right:0 (the full bar width) so its
     content can be centered, but that leaves its own hit-testable area
     stretching all the way to the left — silently sitting on top of
     .nav__logo and eating clicks meant for it, even though nothing is
     visibly there. Turned off here and re-enabled only on the actual
     links, so the empty space around them stops intercepting clicks. */
  pointer-events: none;
}
.nav__links a {
  pointer-events: auto;
}
.nav__links {
  position: relative;
  display: flex;
  justify-content: center;
  gap: 32px;
  /* Uses --text-small rather than its own one-off scaled value — see
     the "2-3 sizes" rule on --text-body/--text-small in :root. */
  font-size: var(--text-small);
  /* Book (400), not the site-wide Light (300) default — kept at the
     original weight rather than following body's drop to Light. (A
     thicker-weight test briefly lived here — that request was actually
     about the .section-eyebrow section titles on the publications
     pages, not this nav, so it's reverted.) */
  font-weight: 400;
  /* Matches .hero__eyebrow's letter-spacing — 0.12em (previous value)
     was much wider than any other tracked text on the page. */
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
/* Nothing here scales with viewport at all otherwise — 4 links at
   32px gaps plus the logo's own fixed 32px/52px (see .nav__logo/
   .nav__logo img) simply don't fit a narrow phone width, confirmed
   live: "OVERVIEW" overlapping the logo at 600px, clipped off the left
   edge entirely by 375px. Small enough to still read as the same nav,
   not a hamburger-menu redesign. */
@media (max-width: 720px) {
  .nav__links {
    gap: 6px;
    font-size: 10.5px;
    letter-spacing: 0.02em;
  }
  .site-header__clip {
    padding: 0 16px;
  }
  .nav__logo {
    left: 16px;
  }
  .nav__logo img {
    height: 36px;
  }
  /* .nav__links is centered within .nav's own FULL left:0/right:0 span
     (see .nav's own comment on why it can't just be narrowed), so
     "OVERVIEW" (the first, leftmost link) can still land under the
     logo even after the sizing fixes above — confirmed live: 38px of
     overlap remained at 375px. Reserving real space on the left (logo's
     own 16px position + 36px width + a small gap) shifts the centered
     group's available area to actually start clear of it. */
  .nav {
    padding-left: 60px;
  }
}
/* Solid palette color, no opacity dimming — hover swaps to the brand
   accent red instead of just fading, so the interaction stays on the
   5-color palette rather than inventing a faded shade. */
.nav__links a {
  color: var(--color-dark);
  transition: color 0.15s ease;
}
.nav__links a:hover { color: var(--color-red); }

/* Underline for whichever nav link is the current page — positioned via
   JS (left/width matched to the active link's own offsetLeft/
   offsetWidth) rather than a per-link ::after, since it lives in its own
   <li>. That <li> is taken out of the flex flow (position:absolute) so
   it doesn't consume a gap slot like a normal flex item would, but it
   still needs inset:0 to fully cover .nav__links — a bare top/left:0
   collapses it to a 0x0 box, which made the indicator's own bottom:0
   resolve against that empty box (rendering it at the TOP of the nav
   instead of under the text). See initNavHighlight() in main.js. */
.nav__indicator-slot {
  position: absolute;
  inset: 0;
  /* Covers the full .nav__links box (needed so the indicator's own
     bottom:0 has a real box to measure against — see comment above) —
     but that means it's painted on top of the actual <a> links (later
     in DOM order). Without this, it silently eats all hover/click
     events on the whole nav. */
  pointer-events: none;
}
.nav__indicator {
  position: absolute;
  bottom: -3px;
  height: 1px;
  /* Scaling a 1px box down renders as a true sub-pixel hairline on
     retina displays instead of a flat 1px bar — thinner than height
     alone can get you. */
  transform: scaleY(0.75);
  background: var(--color-dark);
  transition: background-color 0.15s ease;
  pointer-events: none;
}
/* Same red-on-hover convention as .nav__links a itself (color 0.15s
   ease, right above) — the underline only ever sits under the ACTIVE
   link (see initNavHighlight() in main.js), so :has() on that link's
   own <li> is what lets this plain sibling <li> (.nav__indicator-slot,
   later in the same <ul>) react to it, without needing JS to toggle a
   class just for this. */
.nav__links li:has(> a.is-active:hover) ~ .nav__indicator-slot .nav__indicator {
  background: var(--color-red);
}

/* .nav__item--publications is .nav__dropdown's own containing block (see
   the HTML comment above <header>) — position:relative here is what lets
   the dropdown's top:100%/left:0 resolve against the <li> itself rather
   than needing to bypass .nav's own transform:translateY(-50%). */
.nav__item--publications {
  position: relative;
}

/* Publications dropdown — a left-aligned vertical list revealed by
   .site-header growing taller (see that rule's own :has()), not a
   separate floating panel. top:100%/left:0 sit flush against the trigger
   <li>'s own box (nested inside it — see the HTML comment above
   <header>), so there's no gap between "Publications*" and the list
   below it: hovering anywhere in the list is hovering a descendant of
   the <li>, which is what keeps .site-header:has(.nav__item--
   publications:hover) matching continuously as the cursor moves from
   one into the other, no separate bridge element needed. */
.nav__dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 22px;
  padding: 32px 0 24px;
  pointer-events: auto;
}
.nav__dropdown a {
  color: var(--color-dark);
  /* Matches the main nav items' own size/weight/tracking exactly, per
     request — no longer a visibly "smaller/secondary" tier. */
  font-size: var(--text-small);
  font-weight: 400;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  white-space: nowrap;
  transition: color 0.15s ease;
}
.nav__dropdown a:hover {
  color: var(--color-red);
}
/* The dropdown item matching the CURRENT page (e.g. "Interviews" while
   on interviews.html — see initNavHighlight() in main.js) gets its own
   underline, in ADDITION to "Publications*"'s own .nav__indicator above
   (that one always points at "Publications" itself for this whole page
   family, category pages included — see that function's own comment).
   Applied to .nav__dropdown-text specifically, not the whole link, so
   the underline excludes the leading .nav__dropdown-asterisk span —
   same reasoning as .nav__link-asterisk being excluded from
   "Publications*"'s own indicator. A native text-decoration here
   (rather than a second JS-measured .nav__indicator-style element) —
   sizes to exactly the text with no measurement needed, and
   currentColor means it automatically follows this link's own existing
   color transition (dark -> red on hover), no separate hover rule
   required. */
.nav__dropdown a.is-active .nav__dropdown-text {
  text-decoration-line: underline;
  text-decoration-color: currentColor;
  text-underline-offset: 4px;
  text-decoration-thickness: 1px;
}

/* Dims the page behind the header while the dropdown is open — same
   .nav__dropdown-is-open class as .site-header's own height rule
   (repeated, not shared, since this is a separate sibling element
   reacting to the same trigger — see the HTML comment above
   .nav-dim-overlay). Sits below the header's own z-index:100 but above
   ordinary page content. */
.nav-dim-overlay {
  position: fixed;
  inset: 0;
  /* 95, not 90 — .viewport-frame (further down) also sits at z-index:90
     for its own rounded-corner illusion; tying with it would leave
     stacking order down to DOM position (this element happens to come
     first, which would put it BEHIND that frame) rather than a clear,
     intentional order. 95 keeps this unambiguously above that frame and
     ordinary content, still below the header itself (100). */
  z-index: 95;
  background: rgba(0, 0, 0, 0.35);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.5s cubic-bezier(0.33, 1, 0.68, 1);
}
.site-header.nav__dropdown-is-open ~ .nav-dim-overlay {
  opacity: 1;
}

/* Fixed viewport frame — starts essentially at the nav bar's bottom edge
   (2px overlap, see below) and runs to the bottom of the screen. Because
   it's fixed (not absolute), it holds at any scroll position. This is
   what actually owns the rounded-corner illusion (not the nav bar — see
   the comment on .site-header): this element overlaps real scrolled
   content, so its curve can genuinely reveal it.

   border-radius: 25px on all 4 corners + a 10px inset box-shadow ring
   is the same math as before: insetting a curve by the shadow's spread
   shrinks the VISIBLE inner curve by that same amount, so a 25px shape
   inset by 10px reads as a 15px curve where it meets the content,
   matching the sections' own radius.

   The unavoidable side effect of ANY rounded corner is that the small
   sliver just outside its curve, near the shape's true corner, is left
   completely unpainted (clipped away) — see .viewport-frame-patch below
   for the exact fill that closes it, worked out with real geometry
   this time rather than eyeballed. */
.viewport-frame {
  position: fixed;
  top: calc(var(--nav-height) - 2px);
  left: 0;
  right: 0;
  /* height instead of bottom:0 — per explicit report, the frame's own
     bottom edge visibly lagged/bounced during scroll on mobile,
     exposing real page content in the gap where the border should be.
     Root cause: mobile browsers resize the ACTUAL visible viewport as
     their address bar/toolbar shows and hides while scrolling, but a
     plain `bottom:0` on position:fixed doesn't reliably repaint in sync
     with that resize on every device — a well-documented mobile
     rendering quirk, not a bug in this layout. --real-vh (set by
     initViewportFrameSync() in main.js, via the visualViewport API,
     which fires live during exactly this kind of resize) is what
     actually tracks the CURRENT visible height smoothly; 100vh is a
     static no-JS/unsupported-browser fallback only, same idiom as this
     file's other JS-corrected-with-CSS-fallback values (e.g. the hero
     asterisk's position). Subtracting this element's own top offset is
     what makes an explicit height equivalent to the bottom:0 it
     replaces. */
  height: calc(var(--real-vh, 100vh) - (var(--nav-height) - 2px));
  border-radius: 25px;
  border: var(--page-margin) solid var(--color-cream);
  pointer-events: none;
  z-index: 90;
}

/* Fills the sliver .viewport-frame's rounding leaves unpainted near
   each of its own 4 true corners. A plain circle (border-radius: 50%)
   centered right on the true corner — not shaped to match the frame's
   own curve exactly, just small enough not to read as its own shape.

   (A single-corner border-radius on a small square, tried before this,
   is NOT the same as "a circle cut from that corner" — border-radius
   only carves the LOCAL region right at that one corner; the rest of
   the box stays fully solid. On a 40x40 box that meant a solid square
   with only a tiny 15px nick missing — a big rectangular tab, exactly
   the "jutting out" shape reported. A real circle has no such trap.)

   Sized to roughly match the frame's own 15px visible curve rather
   than the full ~25px needed to close the gap with zero residual —
   confirmed (by temporarily coloring these bright magenta) that a
   larger, mathematically-complete circle reads as its own visibly
   oversized bump, which was the actual complaint. This trades a
   possible sliver of residual gap for not looking like a separate
   shape stuck onto the corner. */
.viewport-frame-patch {
  position: fixed;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--color-cream);
  pointer-events: none;
  z-index: 89;
}
.viewport-frame-patch--top-left {
  top: calc(var(--nav-height) - 2px - 15px);
  left: -15px;
}
.viewport-frame-patch--top-right {
  top: calc(var(--nav-height) - 2px - 15px);
  right: -15px;
}
/* Bottom corners have no nearby same-color element (nav bar, at the
   top) to help hide a slightly-short patch, so the same 30px size that
   fully closed the top left a faint sliver visible here — sized up a
   bit on just these two rather than growing all four. */
/* top instead of bottom:-20px — same --real-vh reasoning as
   .viewport-frame's own height above: these need to stay locked to
   THAT element's bottom edge (which they're patching the corner
   rounding of), so they have to track the exact same live-corrected
   value or the two drift apart independently during the same mobile
   toolbar resize that motivated the switch in the first place.
   top = viewport bottom - 20px reproduces bottom:-20px on a 40px-tall
   box exactly (bottom edge lands 20px past the real viewport bottom,
   same as before). */
.viewport-frame-patch--bottom-left {
  width: 40px;
  height: 40px;
  top: calc(var(--real-vh, 100vh) - 20px);
  left: -20px;
}
.viewport-frame-patch--bottom-right {
  width: 40px;
  height: 40px;
  top: calc(var(--real-vh, 100vh) - 20px);
  right: -20px;
}

/* Small flat rectangle covering the last sliver at the bottom corners
   — sits right along the bottom edge, starting a little past where the
   circle patch's own edge ends, extending a bit further in. */
.viewport-frame-speck {
  position: fixed;
  /* Same --real-vh switch as .viewport-frame-patch--bottom-* just
     above, same reasoning — top = viewport bottom - own height
     reproduces the original bottom:0. */
  top: calc(var(--real-vh, 100vh) - var(--page-margin));
  width: 25px;
  height: var(--page-margin);
  background: var(--color-cream);
  pointer-events: none;
  z-index: 89;
}
.viewport-frame-speck--bottom-left { left: 15px; }
.viewport-frame-speck--bottom-right { right: 15px; }

/* ===================================================================
   Hero — wordmark flanked by the two eyebrow lines
=================================================================== */
.hero {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 16px;
  min-height: 100vh;
  padding: 0 100px;
  /* Consistent, generous 60px gap before the very first section in
     <main> — see main's own gap below for why every OTHER
     inter-section gap uses the same value. */
  margin-bottom: 60px;
}
/* 100px fixed padding leaves almost nothing for the 3-column grid at
   phone widths — confirmed live: at 320px, 100px+100px is already 62%
   of the entire viewport before the hero's own content starts. See the
   matching overrides on .hero__wordmark-wrap/.hero__wordmark-word below
   — the wordmark's own hard 220px/104px floors need to come down too,
   or this padding fix alone just moves the overflow instead of
   resolving it. */
@media (max-width: 720px) {
  .hero {
    padding: 0 24px;
  }
}
/* Every section-level child gets a uniform gap via flex's own gap
   property, rather than each section carrying its own one-off margin
   (they previously ranged from 32px to 64px depending on the section) —
   a single shared mechanism instead of auditing N different values.
   margin-bottom here (matching .hero's own margin-bottom above) is the
   gap before the footer, which sits outside <main> as its own sibling. */
main {
  display: flex;
  flex-direction: column;
  gap: 60px;
  margin-bottom: 60px;
}
.hero__wordmark-wrap {
  grid-column: 2;
  position: relative;
  width: clamp(220px, 22vw, 380px);
  /* Matches the original title image's own ratio (1328x1408) — needed
     now that nothing in-flow establishes this wrapper's height (the
     placeholder/for-wrap are both position:absolute, which don't
     contribute to a parent's size). Without this the wrap collapses to
     0 height. */
  aspect-ratio: 1328 / 1408;
}
/* The 220px floor above is wider than the ENTIRE remaining content
   width at phone sizes once .hero's own padding + the 2 eyebrow columns
   are accounted for — confirmed live: visibly clipped/overlapping the
   eyebrow text by 375px. */
@media (max-width: 720px) {
  .hero__wordmark-wrap {
    /* ~20% bigger than the previous clamp(140px, 45vw, 380px) — same
       1.2x factor applied to both this and .hero__wordmark-word's own
       mobile clamp below, so the wrap (which .hero__wordmark-for's
       width:48% sizes off) and the word text grow together as one
       cluster, keeping their existing proportions. Ceiling (380px)
       untouched — same shared desktop max as before, so this only
       affects sizes below that. The asterisk is DELIBERATELY not
       touched here: --hero-asterisk-size (see :root) is its own
       viewport-width-based clamp, entirely independent of this wrap's
       width, so growing the wrap doesn't grow it. Its POSITION still
       scales with the title regardless, since positionHeroAsterisk() in
       main.js measures the "d" glyph's real rendered position on every
       resize rather than assuming a fixed relationship. */
    width: clamp(168px, 54vw, 380px);
  }
}
/* Each word is two nested elements on purpose: the OUTER span (.hero__wordmark-word)
   holds the permanent resting-position nudge as a transform, and the
   INNER span holds .intro-reveal--slide-slow, which animates its OWN
   transform for the slide-in. Same property, two different elements,
   so the entrance animation's transform:none (once revealed) can't
   wipe out the permanent nudge — see the intro sequence comment above
   <header> for timing. */
.hero__wordmark-placeholder {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
  text-align: center;
  font-family: var(--font-display);
  color: var(--color-red);
  pointer-events: none;
  transform: translateY(-10px);
}
.hero__wordmark-word {
  /* Scaled from measuring actual rendered glyph height against the
     underlying image's own "Word" glyph height (~138.5px) vs. what an
     earlier attempt produced (~66px) — ~2.1x. */
  font-size: clamp(104px, 14.85vw, 192px);
  line-height: 1;
  letter-spacing: -0.02em;
  display: block;
}
/* Same fixed-floor problem as .hero__wordmark-wrap right above — a
   104px minimum doesn't actually shrink with the viewport until well
   past most phone widths, reported as "text sizes don't change to
   accommodate new screen sizes." */
@media (max-width: 720px) {
  .hero__wordmark-word {
    /* Another ~20% bump on top of the previous clamp(70px, 25vw, 192px)
       — same 1.2x factor as .hero__wordmark-wrap's own clamp just above,
       so the letters and the wrap (and the "for" image it sizes) keep
       growing together as one cluster. Ceiling stays 192px, matching
       desktop's own max. */
    font-size: clamp(84px, 30vw, 192px);
  }
}
.hero__wordmark-word .intro-reveal { display: inline-block; }
/* --squash-base mirrors this same element's own `transform` as a custom
   property, read by @keyframes hero-title-squash-stretch further down —
   see that keyframes block for why a plain `transform` here isn't enough
   on its own. */
.hero__wordmark-word--1 { transform: translateY(-6px); --squash-base: translateY(-6px); }
.hero__wordmark-word--2 { transform: translateY(-3px); --squash-base: translateY(-3px); }

/* "for" overlay — absolutely centered over both words (not a flex
   item), so it sits on top of/overlaps them like the original combined
   logotype instead of pushing them apart. */
.hero__wordmark-for-wrap {
  position: absolute;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  pointer-events: none;
  z-index: 1;
  /* Nudge lives here, not on .hero__wordmark-for itself — that img
     carries .intro-reveal--slide-slow, which animates its own
     transform for the slide-in.

     translate(5px, 7px) below is a no-JS fallback only, same idea as
     .hero__wordmark-asterisk-wrap's own top/right fallback (see that
     rule's comment) — positionHeroWordmarkFor() in main.js measures
     where the two words actually landed and overrides both this
     transform and --squash-base with the real midpoint between them,
     since a fixed guess drifts at viewport widths where the wrap's
     width and the words' font-size (two independent clamp()s) stop
     scaling together. It only shows if JS hasn't run yet. */
  transform: translate(5px, 7px);
  /* See .hero__wordmark-word--1's own --squash-base comment. */
  --squash-base: translate(5px, 7px);
}
.hero__wordmark-for {
  width: 48%;
  height: auto;
}
/* Sits near the top of the "d" in the second "Word", spilling past its
   right edge into the empty margin. The wrap holds position + the
   one-shot intro pop-in (opacity/scale/decelerating extra spin); the
   inner img holds only its own continuous constant-speed spin — see
   the HTML comment for why these are split across two elements.

   top/right below are a no-JS fallback only — .hero__wordmark-wrap's
   width (clamp(220px, 22vw, 380px)) and the word's own font-size
   (clamp(104px, 14.85vw, 192px)) are two INDEPENDENT clamp()s with
   different breakpoints (1000/1727px viewport width vs 700/1293px), so
   they only stay proportional to each other in the narrow band where
   both happen to be unclamped at once. Outside that band one of them
   is pinned while the other keeps scaling, so a position expressed as
   %-of-the-wrap drifts relative to the actual "d" glyph — confirmed by
   measurement, drifting from about -20px to +28px off the "d"'s own
   right edge across the viewport range. positionHeroAsterisk() in
   main.js measures the real .hero__wordmark-d-anchor glyph and
   overrides top/left with pixel values instead, so this never drifts;
   it only shows if JS hasn't run yet. */
.hero__wordmark-asterisk-wrap {
  position: absolute;
  top: 50%;
  right: -13%;
  width: var(--hero-asterisk-size);
  opacity: 0;
}
/* The intro pop-in only runs once initIntroReveal() in main.js has
   actually finished deriving this element's real --intro-delay (from
   word 2's own corrected delay — see that function's comment) and adds
   .is-timed — NOT unconditionally from first paint the way this used to
   be written (animation + animation-delay: var(--intro-delay) directly
   on the bare wrap above). That version worked on a fast/warm load, where
   JS runs (and corrects --intro-delay) well before the wrap's own static
   HTML-authored delay would have elapsed on its own. But on a genuinely
   slow first visit (uncached fonts/images — see whenIntroAssetsReady() in
   main.js), that correction can land AFTER the static delay already
   elapsed: a CSS animation already running (or already finished) when its
   animation-delay custom property changes does NOT reschedule itself —
   confirmed live via a minimal repro — so the asterisk would silently
   play its ENTIRE pop-in against the stale HTML-authored delay and just
   be sitting there, already fully landed, the instant the splash cleared.
   Gating on a JS-added class (same idiom as the squash-stretch title
   animation's own :has(...is-visible) gate below) sidesteps the whole
   problem: nothing can start animating before JS has both computed AND
   applied the real delay. */
.hero__wordmark-asterisk-wrap.is-timed {
  animation: hero-asterisk-intro 1.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
  animation-delay: var(--intro-delay, 0ms);
}
@keyframes hero-asterisk-intro {
  from { opacity: 0; transform: scale(0.25) rotate(650deg); }
  to { opacity: 1; transform: scale(1) rotate(0deg); }
}
.hero__wordmark-asterisk {
  display: block;
  width: 100%;
  height: auto;
  animation: hero-asterisk-spin 8s linear infinite;
}
@keyframes hero-asterisk-spin {
  to { transform: rotate(360deg); }
}
.hero__eyebrow {
  letter-spacing: 0.04em;
  max-width: 12em;
  /* Solid --color-dark (inherited from body) — no opacity dimming. A
     dimmed brand color reads as an off-palette color, not a real one. */
  /* Its own size — not tied to --text-small/--text-body, since it needs
     to sit bigger than nav/caption text but isn't part of the 2-size
     body/UI scale either. Sized up from 18px per explicit request. */
  font-size: 20px;
  /* No font-weight of its own anymore, per explicit request to match
     body text's own thickness exactly — this now just inherits body's
     (300 by default, 360 on mobile, see that rule) rather than carrying
     a separate hardcoded 300 that would silently drift out of sync the
     next time body's own weight changes. */
  /* Tighter than body's inherited 1.5 — these are 2 short fragments of
     one thought, not real paragraph copy, so they read better sitting
     close together. 1.2 (tried first) read as too cramped — the two
     lines needed a little more breathing room. */
  line-height: 1.35;
}
.hero__eyebrow--right { text-align: right; margin-left: auto; }
.hero__eyebrow-line { display: block; }
/* Mobile-only merged eyebrow block (see @media (max-width: 720px) below
   for where it's actually shown) — the two desktop blocks flank the
   wordmark left/right, but their line breaks ("The University of" /
   "Pennsylvania's only" and "undergraduate medical" / "humanities
   journal") don't line up with the 3-line breakdown wanted once
   everything stacks into one column on mobile ("The University of
   Pennsylvania's" / "only undergraduate" / "medical humanities
   journal") — the word groupings genuinely don't share span boundaries,
   so this is separate markup with the SAME full text re-wrapped, not a
   CSS reflow of the existing spans. Hidden by default (desktop); the
   two split blocks are hidden instead once the mobile query below takes
   over. display:none on whichever pair is inactive also means it's
   removed from the accessibility tree entirely, so nothing gets
   announced twice. */
.hero__eyebrow--stacked { display: none; }

/* ===================================================================
   Hero — "compact" variant: a shorter, smaller-scale hero for secondary
   pages that still want a hero moment without it dominating the whole
   viewport the way the homepage's own does. First built for
   publications.html's "Our Publications" title; saved here as a
   reusable style (not a publications-only one) for about.html/
   get-involved.html to adopt later — NOT overview/index.html, which
   keeps its own full-viewport treatment. Same wrap/placeholder/for/
   asterisk system as the homepage's hero above, just recomposed for a
   2-part title (script image + one serif word) instead of its 3-part
   "Word for Word" (word/script/word), at smaller scale throughout. See
   templates/publications.html's own HTML comment for the full
   reasoning on that recomposition.

   .hero__wordmark-word--outlined (further down) layers the stroke
   treatment on TOP of this — an opt-in per page (publications.html and
   get-involved.html both use it; about.html's own "About" doesn't), not
   part of this reusable style, so a future compact hero can pick up
   --compact's sizing without inheriting it automatically.
=================================================================== */
.hero--compact {
  /* Homepage's own .hero is min-height:100vh — deliberately shorter
     here so a bit of <main>'s own first section is always visible at
     the bottom of the viewport, rather than the hero alone filling it. */
  min-height: 65vh;
  /* align-items:center (inherited from the base .hero rule) only
     centers each child WITHIN the grid's single implicit row — it says
     nothing about where that row itself sits if the row's own content
     height is shorter than the container's min-height. Grid's default
     align-content leaves the row flush at the top with all the leftover
     space landing below it, which barely showed at the homepage's own
     min-height:100vh (its hero content is tall enough to fill most of
     that on its own) but read as badly off-center here — confirmed:
     the title sat near the top with a huge gap underneath before this.
     Centering the row itself within the extra space is what actually
     centers the whole hero block vertically. */
  align-content: center;
  /* align-content:center splits the leftover space evenly across the
     section's FULL height — but the fixed .site-header (--nav-height,
     70px) visually overlaps that top portion, eating into it, so the
     genuinely VISIBLE gap above the title ends up much smaller than the
     gap below (nothing overlaps that side). Reserving real space for
     the header up front, before the remaining height gets centered
     within, is what actually makes the two visible gaps comparable. */
  padding-top: calc(var(--nav-height) + 40px);
}
.hero__wordmark-wrap--compact {
  /* Wider than the homepage's own wrap (clamp(220px, 22vw, 380px)) —
     a long single word (e.g. "Publications") needs more room than
     either "Word" did, so the word's own font-size below is scaled
     down and the wrap widened to match, rather than letting it overflow
     a wrap sized for a 4-letter word. Scaled up 25% (clamp(280,40vw,560)
     -> clamp(350,50vw,700)) per explicit request that this whole title
     read too small — scaled by the SAME factor as the word's own
     font-size below, not just bumped independently: .hero__wordmark-for
     --compact's width and every position nudge in this system
     (translateY(13%)/--about's own -46%) are already percentages OF
     this wrap/its own box, so scaling wrap and text together keeps the
     image sized and positioned exactly as relative to the text as it
     was before, just bigger throughout — no separate re-tuning needed. */
  /* +10% (350/50vw/700 -> 385/55vw/770) per "make the hero titles
     slightly bigger" — scaled by the SAME factor as the word's own
     font-size below, for the same reason the earlier 25% bump was (see
     this rule's own comment above): every position nudge in this
     system is a percentage of this wrap/its own box, so scaling wrap
     and text together keeps everything sized and positioned exactly as
     relative to the text as before, just bigger throughout. */
  width: clamp(385px, 55vw, 770px);
  aspect-ratio: 560 / 340;
}
.hero__wordmark-placeholder--compact {
  /* The homepage's own space-between only makes sense splitting height
     between 2 words — a single word here just centers instead. Stays at
     that plain center (no extra translateY) so the word lines up with
     the eyebrows' own vertical middle, same as the homepage's title
     does with ITS eyebrows — overlap with the image above (see
     .hero__wordmark-for-wrap--compact) comes entirely from THAT moving
     down into it, not from shifting this word out of center. */
  justify-content: center;
  /* Sits ON TOP of the image above it (not underneath, the homepage's
     own for/word stacking order) — per explicit request for the
     publications composition; kept here since any 2-part compact title
     following the same "word overlapping an image above it" layout
     would want the same stacking. */
  z-index: 2;
}
.hero__wordmark-word--compact {
  /* Scaled down from the homepage's own clamp(104px, 14.85vw, 192px) —
     this variant's whole point is smaller hero text. Bumped 25%
     (56/8vw/104 -> 70/10vw/130) per explicit request that it read too
     small — see .hero__wordmark-wrap--compact's own comment for why the
     wrap above is scaled by this exact same factor alongside it. +10%
     (70/10vw/130 -> 77/11vw/143) per "make the hero titles slightly
     bigger". */
  font-size: clamp(77px, 11vw, 143px);
}
.hero__wordmark-for-wrap--compact {
  /* The homepage's own centers "for" through the middle of both words —
     here the image instead sits above the single word, overlapping
     down into it (per explicit request, for publications: into its
     bottom third) rather than centering through it. */
  align-items: flex-start;
  transform: translateY(13%);
  /* Overrides the base .hero__wordmark-for-wrap's own --squash-base
     the same way it overrides that rule's `transform` above. */
  --squash-base: translateY(13%);
  /* Sits BEHIND the word (z-index: 2 above) — homepage's own for-wrap
     is on top of the placeholder since "for" bridges/overlaps both
     words there; here it's the reverse per explicit request. */
  z-index: 1;
}
.hero__wordmark-for--compact {
  /* Smaller than the homepage's own 48% — this is a lead-in flourish
     above the headline word here, not a same-scale overlay bridging two
     words. */
  width: 30%;
}
.hero__wordmark-asterisk-wrap--compact {
  /* Homepage's own asterisk (var(--hero-asterisk-size)) is sized to
     balance against ITS much bigger hero text — proportionally too
     large next to this variant's smaller word, per explicit request.
     0.55 -> 0.7: scaled up alongside the word's own 25% size bump above
     (.hero__wordmark-word--compact) so the asterisk stays in the same
     visual proportion to the now-bigger text instead of reading
     comparatively small next to it. Position is unaffected — it's
     computed live off the actual glyph by positionHeroAsterisk() in
     main.js, not tied to this size. */
  width: calc(var(--hero-asterisk-size) * 0.7);
}
.hero__wordmark-placeholder--get-involved {
  /* Per explicit request: "Involved" sits lower than the shared
     --compact rule's plain center (see that rule's own comment) — a
     page-specific nudge, not an edit to the shared rule, since about.html
     /publications.html's own words still want the plain centered
     position. */
  transform: translateY(2.5%);
}
.hero__wordmark-word--outlined {
  /* Per explicit request: a stroke OUTSIDE each letter, not eating into
     the fill — paint-order draws the stroke first and the fill on top,
     so the fill covers the stroke's inner half entirely and only the
     outer half ever shows. Without this (the default paint order, fill
     then stroke), the stroke's inner half oversits the fill from
     outside in, reading as thin/hollowed-out letters instead of a solid
     letter with a clean outline. Split from .hero__wordmark-word
     --compact above (these pages also carry that class) so the stroke
     itself doesn't tag along automatically wherever --compact's sizing
     gets reused. Originally publications-only (was named --publications);
     get-involved.html's own "Involved" wanted the same outline, so this
     is now shared by name instead of duplicated. */
  -webkit-text-stroke: 8px var(--color-cream);
  paint-order: stroke;
}
/* about.html's own composition — page-specific modifiers (not edits to
   the shared --compact rules above) since get-involved.html/
   publications.html reuse those same classes for the opposite
   (image-on-top, text-below-behind-it) stacking. Per explicit request:
   text on top, image below, with the image's z-index ABOVE the text so
   where they overlap, the image slightly obscures the text — the exact
   reverse of the shared rules' own z-index (text above image) and
   vertical order (image flush-top nudged down, text centered). Text now
   flush-top (no nudge needed — the overlap comes entirely from the
   image below moving UP into it); image flush-bottom, nudged UP.
   -45% (not a straight mirror of the shared rule's own -13%-down nudge)
   — "About Us Title.png"'s own image is a different shape/size relative
   to "About" than "Our" was to "Publications", so the shared rule's
   exact magnitude read as barely-overlapping here; tuned up by eye until
   "Us" genuinely overlapped into "About"'s own bottom edge. */
/* Text flush-top + image flush-bottom-nudged-up leaves the combined
   text+image visual group sitting above the wrap's own (and the hero
   section's own) true vertical center — the shared --compact rules
   never needed correcting for this because their own centered-text/
   flush-top-image composition happens to balance out roughly even.
   A flat px value here was tried first (33px, tuned against ~1400px+
   viewports where .hero__wordmark-word--compact's font-size and
   .hero__wordmark-wrap--compact's own width — two independent clamp()s
   with different vw coefficients, same class of mismatch documented on
   the hero asterisk's own positioning — both happen to hit their max
   ceiling) — reported still off, since plenty of real browser windows
   sit below that width, where the two clamps are NOT proportional and
   a static value can't track both at once. Corrected the same way the
   asterisk is instead: measured live and set as an inline transform by
   positionAboutHeroTitle() in main.js, re-run on fonts.ready/resize/
   introfinished. translateY(0) here is just the pre-JS fallback. */
.hero__wordmark-wrap--about {
  transform: translateY(0);
}
.hero__wordmark-placeholder--about {
  justify-content: flex-start;
  z-index: 1;
}
.hero__wordmark-for-wrap--about {
  align-items: flex-end;
  /* -45% (up from the shared rule's own -13%-down) tuned by eye so "Us"
     genuinely overlaps "About"'s own bottom edge — see this rule's own
     earlier comment. -48% per explicit follow-up feedback: -53% (an
     earlier bump) moved it up too far. -44% per a later follow-up
     ("move it down a tiny bit" from -48%), then -46% per one more
     follow-up, then -49% per "move it up a little", then -47% per "move
     it down a little" once more, then -44% per "moved down more", then
     -46% per "moved a little too far down", then -47% per "move it up a
     tiny bit", then -45% per "move hero title img down a little", then
     -47% per "move the hero title img up a little".
     positionAboutHeroTitle()
     in main.js re-centers the whole group against this value automatically
     on every change, so tuning this alone is always safe — no need to
     re-tune the wrap-level centering correction to match (it only ever
     computes a Y correction on the OUTER wrap, never touches X). X was a
     flat 14px until "move it a tiny tiny tiny bit to the right" bumped it
     to 16px. */
  transform: translateY(-47%) translateX(16px);
  --squash-base: translateY(-47%) translateX(16px);
  z-index: 2;
}

/* ===================================================================
   Square grid — the site's signature motif: rows of perfect squares.
   The CONTAINER's aspect-ratio (columns : rows) sets its total height
   from its width, and grid-auto-rows: 1fr divides that height evenly —
   so every row is forced to equal the column width (a true square),
   regardless of what's inside any individual cell. This is what makes
   the section's height depend on its row count instead of an
   arbitrary/fixed value: add the matching --rows-N modifier whenever
   a grid's row count changes.
=================================================================== */
.square-grid {
  display: grid;
  gap: 0;
  border-radius: 15px;
  overflow: hidden;
}
.square-grid--4col {
  grid-template-columns: repeat(4, 1fr);
  grid-auto-rows: 1fr;
}
.square-grid--rows-1 { aspect-ratio: 4 / 1; }
.square-grid--rows-2 { aspect-ratio: 4 / 2; }
.square-grid--rows-3 { aspect-ratio: 4 / 3; }
.square-grid--rows-4 { aspect-ratio: 4 / 4; }
/* Section 4's grid sits flush against .featured-carousel directly below it
   (see index.html) — squares off just the bottom 2 corners (normally
   both rounded via the base rule above) so the carousel's OWN
   bottom-left/right corners are the ones that read as concluding the
   section, instead of a visible double-rounded seam between the two. */
.square-grid--flush-bottom {
  border-radius: 15px 15px 0 0;
}
.square {
  /* "Padding" is the term — space inside a box, between its content and
     its own edge (vs. "margin", which is space outside a box, between
     it and its neighbors). Bumped up for a more generous, breathing-room
     feel. */
  padding: 40px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  position: relative;
  /* Grid items default to min-width/min-height:auto — for a text-
     bearing cell, that resolves to the text's own intrinsic minimum
     size, which can force its ENTIRE grid track (shared by every other
     cell in that row/column) wider or taller than the aspect-ratio-
     driven "equal division" this whole system depends on, once a
     narrow viewport gives each 1fr share less room than the text needs
     (a well-known CSS grid "blowout" — confirmed live: at 320px, the
     image-mosaic's own 2-column track measured 220px wide instead of
     its fair 150px share, forced there by a sibling cell's text). This
     lets a cell's own overflow:hidden above actually clip long text
     instead of the shared track growing to avoid ever needing to. */
  min-width: 0;
  min-height: 0;
  filter: brightness(1);
  /* Quick-ish on the way out (leaving hover) — this is the transition
     used whenever filter changes AWAY from whatever :hover declares. */
  transition: filter 0.3s ease-out;
}
/* Faint hover glow — purely decorative feedback (not a click affordance,
   no cursor/pointer change). A flat white overlay was nearly invisible
   on the already-light tiles (tan/cream) since white-on-near-white barely
   registers — brightness() scales proportionally to each tile's own
   color instead, so it's visible on every tile, light or dark.

   The slower, gentler transition declared here (not on the base .square
   rule above) is what's used specifically when ENTERING hover — CSS
   picks the transition from whichever rule you're transitioning INTO —
   so hovering in is a slow floaty rise, while un-hovering is quicker.
   Must also re-list opacity here, not just filter: :hover has higher
   specificity than the base .square rule, so its `transition` shorthand
   otherwise REPLACES the base rule's opacity transition outright rather
   than adding to it. :hover is pure geometry — no mousemove required,
   just the pointer resting somewhere a tile scrolls into — so a tile
   revealed while already under a stationary cursor would silently skip
   its fade-in and snap straight to full opacity. transition-delay pairs
   positionally with the property list above: opacity keeps the real
   --reveal-delay stagger, filter stays immediate (0s) as before. */
.square:hover {
  /* 1.04 (an earlier, subtler value) made the effect feel laggy compared
     to .image-mosaic__hover-tile's hover (a much bigger +14% white
     overlay, same 600ms curve) — not an actual timing bug (verified: both
     genuinely start animating on the very first frame), just that a
     smaller target magnitude takes longer, in real time, to cross the
     threshold where the eye can actually register a change. Bumped up so
     it registers sooner without the curve/duration needing to change. */
  filter: brightness(1.07);
  transition: opacity 0.7s ease-out, filter 0.6s cubic-bezier(0.25, 1, 0.5, 1);
  transition-delay: var(--reveal-delay, 0ms), 0s;
}
/* Body text stays --color-tan (beige) even on dark/colored backgrounds —
   --color-cream is the page's own near-white background color, and using
   it for TEXT reads as washed-out/low-contrast rather than a deliberate
   accent. Reserve cream for backgrounds only. */
.square--red   { background: var(--color-red);   color: var(--color-tan); }
.square--tan   { background: var(--color-tan);    color: var(--color-dark); }
.square--cream { background: var(--color-cream);  color: var(--color-dark); }
/* --color-cream IS the page background (see the variable's own
   comment) — this tile is body copy, not a decorative accent, so
   .square:hover's brightness glow reads as a stray flash rather than
   real feedback. Same reasoning/pattern as .site-footer__center's own
   pointer-events:none: this is the one square tile people are meant to
   just read, not hover. */
.square--cream {
  pointer-events: none;
}
.square--grey  { background: var(--color-grey);   color: var(--color-tan); }
.square--dark  { background: var(--color-dark);   color: var(--color-tan); }

.square--deco { align-items: center; text-align: center; }
/* .square--deco's own 2 instances (section 1 square 11, section 4
   square 3) both stack an SVG arc above a rotated .diamond-cta — flex's
   own centering math only ever sees the diamond's UN-rotated 130x130
   layout box, but rotate(45deg) is a paint-only effect that visually
   expands its rendered bounding box to 130*sqrt(2)≈183.85px, bulging
   ~26.9px past the un-rotated box on every side. That extra bulge below
   the diamond eats into what would otherwise be bottom padding/gap,
   while the arc above it (not rotated, unaffected) still sits exactly
   where flex's un-rotated math puts it — so the whole block reads as
   sitting too low/tight against the bottom edge even though flexbox
   itself is centering it correctly on paper. Confirmed via direct
   measurement: an ~26.9px larger gap above the arc than below the
   diamond, in BOTH .square--deco instances (the same content, so the
   same bug in each).
   Fixed on a wrapper (.square__deco-content), NOT via transform on
   .square--deco itself — the square IS the flex box that also paints
   the tile's own background color; shifting IT would drag the
   background out of alignment with its own grid cell, leaving a gap at
   one edge and an overlap at the other. The wrapper carries none of
   that, so shifting it up by half the discrepancy (13.5px) evens the
   two gaps out to true visual center with no side effect on the tile
   itself. */
.square__deco-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  transform: translateY(-13.5px);
}
/* Top+left anchored instead of centered — the square's own existing
   padding (40px, see .square) already reads as "generous, equal
   padding" once content starts from the top-left corner instead of
   being centered as a block. Originally used on 2 adjacent squares
   specifically so their first lines land at the SAME height (both
   anchored to the same top edge + padding, rather than each being
   independently vertically centered, which would put their first lines
   at different heights whenever their content lengths differ). */
.square--content-left {
  align-items: flex-start;
  /* flex-start, not center — center was centering the WHOLE stack
     (text + gap + diamond) as one block, which meant square 7 (taller
     total content, since it also has a diamond below) had its TEXT
     specifically start lower than square 6's (text only) — they no
     longer matched. flex-start always places the FIRST child right
     after the top padding regardless of what follows it, so with
     identical padding on both squares their text starts at the exact
     same height no matter how much content trails after it. */
  justify-content: flex-start;
  text-align: left;
  padding: 70px;
}
/* The diamond specifically should be horizontally CENTERED, unlike the
   text above it which stays left — align-self overrides just this one
   child's own cross-axis position without touching its sibling <p>'s
   inherited flex-start. */
.square--content-left .diamond-cta {
  align-self: center;
}
/* Standardized padding for every tile with real body text, using
   section 2 squares 6/7 (.square--content-left, 70px) as the reference
   value — a separate modifier rather than reusing .square--content-left
   wholesale, since that one also brings align-items/justify-content/
   text-align that not every one of these tiles wants (the role-reveal
   tiles below keep their own centered heading + left-aligned hover
   description). Tiles that are decorative-only, button/CTA-only, or use
   specially centered short text (not body-text paragraphs) intentionally
   stay at the base .square 40px instead. */
.square--pad-lg {
  padding: 70px;
}
/* .square__body's shared flex: 1 (see that rule's own comment — it's
   there for multi-paragraph tiles where the LAST paragraph should fill
   whatever height its earlier siblings don't use) stretches a lone
   paragraph to fill 100% of the tile's available height. Text then
   flows from the TOP of that stretched box (plain block layout, no
   internal centering) — so with tile 2 needing a much shorter box for
   its own text than tile 3, the exact same amount of unused space below
   the text ends up wildly different per tile, reading as very much NOT
   vertically centered despite .square's own justify-content:center.
   flex: none here lets that centering genuinely apply to each
   paragraph's own natural height instead. IMPORTANT: needs the doubled-
   up selector (not just .square__body--natural-height alone) — that
   was equal specificity to .square__body's own flex:1 declared FURTHER
   DOWN this file, which silently won and made this whole rule a no-op
   for 3 rounds of edits (confirmed by re-measuring the paragraph's
   rendered height: it was still 205px, the fully-stretched value, the
   entire time). Doubling the selector raises specificity so this
   actually wins regardless of source order. */
.square__body.square__body--natural-height {
  flex: none;
}
/* Padding is a hard floor min-width:0 (see .square's own comment) can't
   shrink below — content can shrink to nothing, but padding always
   stays. 40px/70px (80px/140px total) already exceeds a 4-column
   grid's own fair share of a narrow viewport before any text even
   enters the picture (confirmed live: at 320px, a 300px-wide grid's
   1fr share is only 75px/column — .square--pad-lg's 140px floor alone
   is nearly double that), forcing the grid to blow out past its own
   container width rather than actually fitting 4 across. */
@media (max-width: 720px) {
  .square {
    padding: 16px;
  }
  .square--pad-lg {
    padding: 20px;
  }
}
/* Horizontal-only left-align — just align-items + text-align, NOT the
   rest of .square--content-left's bundle (justify-content:flex-start
   would also top-anchor the tile vertically instead of centering it,
   which isn't wanted here: section 4 square 2's Instagram handle should
   read left-aligned like normal text, not also lose its vertical
   centering). */
.square--align-left {
  align-items: flex-start;
  text-align: left;
}
/* A fixed, deliberately small gap between 2 stacked text blocks in a
   tile (section 1 tile 7's 2-line sentence; section 4 tile 2's body
   text + Instagram handle) — NOT the old .square--split's space-between
   (which stretched them to the tile's own top/bottom edges, however
   tall the tile happened to be). */
.square--text-gap-30 { gap: 30px; }

/* Shared face/size/weight for every real section title on the site —
   about.html section 1's "What is Word for Word?", index.html section
   3's "What else?", and about.html section 2's "Beyond the Page" all
   carry this class now (alongside their own layout-specific class, e.g.
   .square__intro-title/.split-cta__title, which still owns margin and
   any other per-context layout). Previously these lived as duplicated
   font-family/font-size/font-weight declarations across those 2
   classes, which had drifted out of sync (.square__intro-title at
   clamp(32px,9vw,48px), .split-cta__title still at the older
   clamp(26px,8vw,40px)) — consolidated per explicit request that all 3
   titles read as the same size, matching section 1's (the larger) one. */
.section-title {
  font-family: var(--font-display);
  font-size: clamp(32px, 9vw, 48px);
  font-weight: 400;
}
/* About page section 1's merged 2x2 "What is Word for Word?" tile —
   title + 3 paragraphs, left-aligned/justified (.square--align-left +
   this rule's own text-align:justify override on the paragraphs) and
   vertically centered as a block via .square's own base
   justify-content:center, matching the reference screenshot. */
.square__intro-title {
  margin: 0;
}
.square__intro-body {
  font-family: var(--font-body);
  font-size: var(--text-body);
  text-align: justify;
  margin: 0;
}
/* Same lighter 500 (not the browser's bold 700 default) as every other
   Newsreader body-copy context on the site — see the shared comment
   above .square__body strong. */
.square__intro-body strong {
  font-weight: 500;
}
/* Scoped to THIS tile only — not the shared .square--pad-lg/
   .square--text-gap-30 classes it also carries, which other pages
   still use at their original 70px/30px values. At those shared
   values the title + 3 paragraphs need more height than 2 true-square
   rows provide (690px) — which is what forces rows 1-2 to grow past
   what the OTHER (true-square) tiles sharing those rows need, leaving
   those tiles stranded with blank background below them once
   grid-auto-rows:auto stretches the rows to fit this text.
   Side padding is deliberately the larger axis (matches the shared
   70px value exactly) with top/bottom compressed instead of shrinking
   both evenly — per explicit request, side padding should always be
   the greater one, never top/bottom. Squeezing top/bottom (not sides)
   is also the more effective lever anyway: less side padding widens
   the line box and reflows into fewer, longer lines, which COSTS
   height rather than saving it — top/bottom padding is pure height
   with no such reflow tax. These values are intentionally the
   largest vertical padding/gap that still keeps the current copy
   (see about.html) at or under the 690px budget — re-measure and
   raise them again any time the copy gets shorter, per the ongoing
   copy-trim effort, but keep side > top/bottom when you do. */
#about-intro .square--cream {
  /* Top/bottom sum still 40px (unchanged from the even 20px/20px this
     replaced) — this is a pure REPOSITION, not a size change: .square's
     own justify-content:center centers content within the space left
     after padding, so shifting weight from top to bottom moves that
     centered block up by exactly the difference (here, 12px) without
     touching the tile's total height requirement or reopening the
     row-gap risk. Per explicit request to nudge the copy up within its
     box, copy left as-is. */
  padding: 8px 70px 32px;
  gap: 18px;
}
/* .square-grid--rows-3's shared aspect-ratio (all 3 rows locked to an
   equal 1/3 share of a fixed total height, same as #overview's own
   rows) works for photo/checker tiles, which have no real content
   demand of their own — but the title + 3 real paragraphs above
   genuinely need more height than 2 of those fixed rows have room for
   at normal viewport widths (confirmed live: content measured ~824px
   tall against a ~690px available box, silently clipping the title off
   the top and the last paragraph's final lines off the bottom, since
   the tile's own overflow:hidden + centered content hides an overrun
   symmetrically on both ends rather than visibly overflowing). Same
   fix as the 660px-breakpoint pattern below (aspect-ratio:auto +
   grid-auto-rows:auto lets each row size to its own tallest content)
   applied here at every width, not just narrow ones — with the
   decorative tiles individually pinned back to a real 1:1 square (via
   their own aspect-ratio, not a shared row height) so THEY don't
   inherit the text row's much taller height. */
#about-intro {
  aspect-ratio: auto;
  grid-auto-rows: auto;
}
#about-intro > .square:not(.square--cream) {
  aspect-ratio: 1 / 1;
}

/* Diamond-argyle checker filler tile. Colors are passed in per-instance
   via the --checker-a / --checker-b custom properties.

   Previously this was two 45deg-angled gradient layers on a 142%-
   oversized, rotated, overflow-clipped pseudo-element — mathematically
   correct (verified it really does yield 6 true diamonds), but a
   genuinely expensive thing to paint: a large rotated element with a
   computed multi-stop gradient. will-change:opacity alone didn't fix
   the on-scroll stutter because that hint only avoids *re*-paints on
   later opacity changes — the first paint (actually rasterizing the
   gradient) still has to happen at some point, and browsers often defer
   that until the element is about to become visible, i.e. exactly when
   it scrolls into view.

   Replaced with a static, pre-rasterized SVG used as a mask: the
   square's own background is --checker-b, and this ::before is a solid
   --checker-a fill clipped to one diamond per tile (see the mask-image
   comment below for the exact shape), tiled at 6x6. A tiny SVG tiled via
   mask-repeat is trivial for the browser to composite — there's no
   rotation, no gradient math, no oversized element — so there's nothing
   left to be expensive. */
/* --checker-a is the SQUARE'S OWN background (the color that reaches
   every edge/corner); --checker-b is the inscribed diamond shape drawn
   on top (see ::before below). Swapped from an earlier version of this
   rule (which had --checker-a as the masked diamond and --checker-b as
   the background) — per-instance color choices throughout the site
   (e.g. --checker-a: dark / --checker-b: tan) were picked expecting
   --checker-a to be the dominant base tone and --checker-b to be the
   lighter accent diamond, so the old mapping rendered every checker tile
   with its two colors' intended roles reversed. */
.square--checker {
  background: var(--checker-a);
}
/* Each tile's mask is a diamond touching its OWN tile's 4 edge-midpoints
   exactly (vertices at 50,0 / 100,50 / 50,100 / 0,50) — i.e. inscribed
   in, and never spanning past, a single tile. This is deliberately
   different from an earlier "bowtie" version (top/bottom triangles as
   one shape spanning two tiles vertically), which tessellated edge-to-
   edge with zero gap but had a real, unfixable problem: that
   construction's --checker-a diamonds are centered ON tile boundaries,
   so at the checker box's own outer edge they're always sliced through
   dead center — no mask-position shift can avoid cutting for both
   colors simultaneously (confirmed with pixel-level measurement, not
   just eyeballing).
   Because THIS diamond is centered on the tile itself (not on a tile
   boundary), every --checker-a diamond is always fully inscribed and
   whole — verified with a pixel scan that zero --checker-a pixels ever
   touch any of the checker box's 4 edges, at any tile count. Only
   --checker-b (the plain background, showing through each tile's 4
   corner gaps) shows half-diamonds at the border and quarter-diamonds
   at the box's own corners — but since that's just the background
   color reaching the edge, not a drawn accent shape, it doesn't read as
   a "clipped diamond" the way the old construction did. The overall
   look is visually the same dense, fully-tessellating checkerboard as
   before (each tile is exactly 50% --checker-a / 50% --checker-b, same
   as the old construction) — the fix is entirely about WHICH color's
   diamonds are the ones guaranteed never to be cut. */
.square--checker::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--checker-b);
  -webkit-mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxMDAgMTAwJz48cG9seWdvbiBwb2ludHM9JzUwLDAgMTAwLDUwIDUwLDEwMCAwLDUwJyBmaWxsPSd3aGl0ZScvPjwvc3ZnPg==");
  mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxMDAgMTAwJz48cG9seWdvbiBwb2ludHM9JzUwLDAgMTAwLDUwIDUwLDEwMCAwLDUwJyBmaWxsPSd3aGl0ZScvPjwvc3ZnPg==");
  -webkit-mask-size: calc(100% / 6) calc(100% / 6);
  mask-size: calc(100% / 6) calc(100% / 6);
  -webkit-mask-repeat: repeat;
  mask-repeat: repeat;
}

/* All text inside a square card — label/script/body/role/footnote —
   uses Newsreader at one consistent size (--text-body). Hierarchy within a
   card comes from weight/style/color now, not size. Doesn't apply to
   the diamond-outline text (.square__arc, .diamond-cta) or the big
   featured-image caption, which keep their own display treatment. */
.square__label {
  font-family: var(--font-body);
  font-size: var(--text-body);
}
.square__script {
  /* Newsreader (--font-body) is never italic — no italic here (or
     anywhere else Newsreader is used; see
     .publication-card__cover-caption). */
  font-family: var(--font-body);
  font-size: var(--text-body);
}
.square__script--sub { font-size: var(--text-body); }
.square__body { font-size: var(--text-body); flex: 1; }
/* A plain <strong> falls back to the browser default bold (700) —
   reported live as too heavy, twice over (800 first, then even the
   plain 700 default). Settled on 500 — enough weight to read as real
   emphasis against the surrounding Light (300) body copy without the
   much starker jump a true bold reads as on this particular serif at
   these sizes. Applied consistently across every Newsreader body-copy
   context (.square__footnote — section 1 tile 7's "word for word";
   .square__role-description — section 2's hover-revealed role
   descriptions; .square__script — section 2 tile 7's "spring"). */
.square__body strong,
.square__footnote strong,
.square__role-description strong,
.square__script strong {
  font-weight: 500;
}
/* flex:1 is meant for single-child tiles, where it just fills the tile
   with no visible effect. Whenever a sibling follows (section 1 tile 7's
   2-line sentence, section 4 tile 2's body + handle, section 4 tile 7's
   down-arrow), that flex-grow would instead inflate THIS element to fill
   all remaining vertical space, shoving the sibling down past whatever
   gap was actually intended (a fixed .square--text-gap-30, or the arrow's
   own 36px margin) — reset to natural height any time this isn't the
   last child, rather than special-casing each sibling one at a time. */
.square__body:not(:last-child) { flex: none; }
.square__body--small { font-size: var(--text-body); }
/* Section 4 square 2's Instagram handle mention — a deliberate one-off
   exception to "Newsreader is never italic" elsewhere on the page (see
   the comment above .square__script): a real italic face exists
   (Newsreader Italic, see the @font-face block), so this isn't the
   browser faux-slanting a face that has none. */
.square__handle { font-style: italic; }
.square__footnote {
  align-self: flex-end;
  font-family: var(--font-body);
  font-size: var(--text-body);
  opacity: 0.85;
}
/* Section 4 square 7 — points toward the carousel directly below this
   grid. The group sits in normal flow right after the text (not
   absolutely positioned — that was fighting the text's own variable
   height/width across breakpoints instead of just sitting relative to
   it). Centered under the left-aligned text via align-self, same as
   .square--content-left .diamond-cta above. margin-top: 36px matches
   .diamond-cta--tight's own gap from its text (section 2 square 7). */
.square__down-arrow-group {
  display: flex;
  flex-direction: column;
  align-items: center;
  align-self: center;
  gap: 14px;
  margin-top: 36px;
}
/* 3 identical arrows, "chasing" downward neon-sign-style: each runs the
   SAME 3-stop opacity loop (full -> faint -> gone -> back to full) but
   staggered a third of a cycle apart via negative animation-delay, so
   at any instant they read as full/faint/gone in sequence — and because
   that's a rotating loop rather than one arrow literally sliding down,
   the "full" position keeps advancing to the next arrow down, reading as
   continuous downward motion. Default source artwork points right,
   hence the 90deg rotate.

   Delay sign is counterintuitive and easy to get backwards (did, once):
   for animation-delay `d`, an element hits its opacity:1 peak whenever
   `t ≡ d (mod duration)` — so to make the peak visit top(0s) then
   middle(0.6s) then bottom(1.2s) in that order, middle needs d ≡ 0.6,
   i.e. d = -1.2 (not -0.6), and bottom needs d ≡ 1.2, i.e. d = -0.6
   (not -1.2). Swapping middle/bottom's delays, as an earlier version of
   this rule did, makes the peak visit top -> bottom -> middle instead —
   still cycles, but reads as moving the wrong way. */
.square__down-arrow {
  width: 22px;
  height: 14px;
  transform: rotate(90deg);
  animation: square-down-arrow-chase 1.8s ease-in-out infinite;
}
.square__down-arrow:nth-child(2) { animation-delay: -1.2s; }
.square__down-arrow:nth-child(3) { animation-delay: -0.6s; }
@keyframes square-down-arrow-chase {
  0% { opacity: 1; }
  33.33% { opacity: 0.4; }
  66.66% { opacity: 0; }
  100% { opacity: 1; }
}
.square__role {
  font-family: var(--font-body);
  /* Bumped to the marquee strips' own unique size per explicit request
     (was --text-body/24px, briefly was a one-off 40px before that) —
     .square__role-description below (the hover-revealed swap text) is
     untouched, still --text-body, per the same request. */
  font-size: var(--marquee-text-size);
  /* <h3> is bold by default in the browser's own UA stylesheet — left
     unset, every role rendered bold (worst on Instrument Serif/Kapakana,
     neither of which has a real bold face loaded, so the
     browser was synthetically/faux-bolding them, which looks chunky and
     wrong on a script face especially). */
  font-weight: 400;
}
/* --marquee-text-size is now itself a clamp() (see :root), so it already
   shrinks smoothly at phone widths — no separate fixed-px override
   needed here anymore (the old 22px pin is exactly this clamp's own
   minimum, so removing it changes nothing at 720px and below, just
   restores smooth scaling in between). */
.square__role--light { color: var(--color-tan); }
/* Font-treatment variants for the roles grid — each role in that section
   uses a different display face, not the shared --font-body default. */
.square__role--display-italic { font-family: var(--font-display); font-style: italic; }
.square__role--display { font-family: var(--font-display); font-style: normal; }
/* Newsreader Light (300, the site's own body-text default weight —
   see body's own font-weight in :root) rather than the 400 all the
   other roles use — explicitly requested lighter than the rest. */
.square__role--caps { text-transform: uppercase; font-weight: 300; }
/* Kapakana (swapped in for a previously self-hosted, non-OFL script
   font) still reads smaller than the other 3 roles' fonts at the
   same nominal size (compact cursive strokes vs. serif) — bumped past
   the shared body size to compensate, and further still past THAT so
   Designer reads deliberately a bit bigger than its 3 siblings, not
   just corrected up to parity with them.
   calc(var(--marquee-text-size) * 1.65), not 1.65em — em on font-size
   resolves against the PARENT's font-size, not against .square__role's
   own value on this SAME element (only one font-size declaration ever
   wins the cascade; em doesn't stack with it), so 1.65em silently stayed
   locked to the old body-text size even after .square__role above was
   bumped to --marquee-text-size. This keeps the same 1.65x ratio but
   chained off the value that's actually meant to scale it. */
.square__role--script { font-family: "Kapakana", cursive; font-size: calc(var(--marquee-text-size) * 1.65); }

/* Roles grid hover reveal (squares 1-4: Interviewer/Columnist/Editor/
   Designer) — the role name slides up and fades away as its description
   slides up and fades in from below, both absolutely-timed-together so
   they read as one swap rather than two independent fades; leaving
   hover runs the same transition in reverse (browser-native, no JS)
   since it's just the two properties returning to their resting
   values. Both stacked in the same spot (.square__role-description is
   absolutely positioned) so neither ever collides with or displaces the
   other. No explicit color on the description: it just inherits
   whatever color each square's own base rule already sets (--color-tan
   on red/grey/dark, --color-dark on tan) — the same color the role
   heading itself is already using, "matching the preceding text"
   automatically and staying in sync if those ever change.
   Opacity is intentionally faster than transform in both directions —
   each text is fully invisible well before it finishes its slide, so
   the outgoing and incoming text never sit onscreen semi-visible at the
   same time/position (which read as a visual overlap/collision).
   .is-tapped (added/removed by initRoleTapReveal() in main.js) drives
   the exact same reveal on devices with no real hover — see
   .square__role-tap-btn below. */
.square--role-reveal .square__role {
  transition: opacity 0.2s ease-out, transform 0.3s ease-out;
}
.square--role-reveal:hover .square__role,
.square--role-reveal.is-tapped .square__role {
  opacity: 0;
  transform: translateY(-20px);
  transition: opacity 0.35s cubic-bezier(0.25, 1, 0.5, 1), transform 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Un-bolds the <strong> portion of the hover-revealed description text
   below (see .square__role-description strong's own 500-weight rule
   near the top of this file, shared with a few other Newsreader body-
   copy spots) — per explicit request, just for this section, so this
   comes AFTER that shared rule and matches its exact selector to win on
   cascade order rather than touching the shared rule itself and
   affecting the other spots that still want the emphasis. */
.square__role-description strong {
  font-weight: inherit;
}
.square__role-description {
  position: absolute;
  inset: 0;
  /* Matches its own tile's padding (.square--pad-lg, 70px — see the
     role-reveal squares' own class list in index.html) rather than the
     base .square 40px: this overlay is absolutely positioned over the
     SAME tile, so a mismatched inset here would make the revealed
     description text jump to a different left/top edge than the
     heading it's replacing. */
  padding: 70px;
  display: flex;
  align-items: center;
  text-align: left;
  font-family: var(--font-body);
  font-size: var(--text-body);
  font-weight: 300;
  line-height: 1.4;
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.2s ease-out, transform 0.3s ease-out;
  pointer-events: none;
}
.square--role-reveal:hover .square__role-description,
.square--role-reveal.is-tapped .square__role-description {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.35s cubic-bezier(0.25, 1, 0.5, 1), transform 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}

/* Touch tap affordance: hidden entirely on hover-capable devices via
   (hover: none) rather than a screen-width breakpoint — this is a real
   input-capability distinction (a touchscreen at desktop width still
   needs it), not a viewport-size one. Deliberately NOT built on
   .diamond-cta — that class's hover-fill mechanics and its child span's
   hardcoded tan text color (built for one specific tile's background)
   don't fit a plain tap target that needs to inherit whichever of the
   4 role tiles' own colors it happens to sit on. Purely a visual
   affordance: the actual tap target is the whole tile (see
   initRoleTapReveal() in main.js, which listens on .square--role-reveal
   itself) — a bigger, easier target than this 32px diamond, which just
   signals "tap me" since a touch device can't discover the reveal via
   hover the way a mouse can. */
.square__role-tap-btn {
  display: none;
}
@media (hover: none) {
  .square--role-reveal {
    cursor: pointer;
  }
  .square__role-tap-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    margin-top: 14px;
    padding: 0;
    background: none;
    border: 1.25px solid currentColor;
    color: inherit;
    transform: rotate(45deg);
  }
  /* Counter-rotates the diamond's own 45deg so the asterisk itself
     reads upright — same trick as .diamond-cta > span/.arrow-glyph. */
  .square__role-tap-btn span {
    display: block;
    transform: rotate(-45deg);
    font-family: var(--font-display);
    font-style: italic;
    font-size: var(--text-body);
    line-height: 1;
  }
}

/* Featured artwork: a single uncut <img> spans the full 2x3 area (no
   seams possible), with 6 mask panels layered on top in a matching 2x3
   grid. Each mask starts opaque (hiding its slice of the photo behind
   the page's own background color) and fades out via .reveal--mask, so
   the picture still appears to assemble tile-by-tile on scroll without
   the photo itself ever being cut into pieces. */
.image-mosaic {
  position: relative;
  overflow: hidden;
  /* Same grid-blowout fix as .square's own comment — this cell's own
     caption/CTA content has an intrinsic minimum width that was forcing
     its 2-column track wider than its fair share at narrow viewports. */
  min-width: 0;
  min-height: 0;
}
.image-mosaic__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.image-mosaic__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent 45%);
  pointer-events: none;
}
.image-mosaic__masks {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-template-rows: repeat(3, 1fr);
}
.image-mosaic__mask {
  background: var(--color-cream);
}
.image-mosaic__caption {
  position: absolute;
  bottom: 24px;
  /* Higher than .image-mosaic__hover-tiles (z-index:2, no pointer-events:
     none — it needs to stay interactive for its own per-cell hover
     flash) — otherwise that layer sits on top here and silently
     swallows every hover/click meant for this caption's link, even
     though nothing is visibly on top of it. */
  z-index: 3;
  color: var(--color-tan);
}
.image-mosaic__caption--left { left: 24px; }
/* "Volume 2" — plain body text/size, not the headline's display
   italic treatment. */
.image-mosaic__caption--right {
  right: 24px;
  text-align: right;
  font-family: var(--font-body);
  font-size: var(--text-body);
  font-style: normal;
}
/* "Read our latest issue!" — its own class since nothing else on the
   page is this size; ~40px is a guess at reading as a real headline
   against the photo, not a rem-scaled body/display value. */
.image-mosaic__caption--headline {
  font-family: var(--font-display);
  font-style: italic;
  font-size: clamp(26px, 8vw, 40px);
  transition: color 0.3s ease-out;
  cursor: pointer;
}
/* Same red-on-hover convention as .nav__links a — this is a real link
   now (see index.html), so it needs its own hover affordance separate
   from .image-mosaic__hover-tile's generic per-cell photo flash
   underneath it. */
.image-mosaic__caption--headline:hover {
  color: var(--color-red);
  transition: color 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
/* This diamond is the only .diamond-cta--fill-on-hover instance on the
   page that sits INSIDE a larger clickable text link rather than being
   the whole clickable element itself, so "hover the diamond" and "hover
   the surrounding text" need to look identical — diamond, arrow, and
   text are one unit. Driven off this same ancestor :hover (already true
   whenever the pointer is anywhere in the whole link, diamond included,
   since it's one of this link's descendants), which is what makes both
   cases behave the same, unlike the shared .diamond-cta--fill-on-hover:
   hover rule below (only true when the pointer is directly over the
   diamond's own small box).
   Overrides that shared rule's fill color (tan) and the shared arrow
   crossfade (beige -> red) — every OTHER --red/--blue diamond on the
   page still fills tan with a red/grey arrow; here the fill goes red to
   match this link's own text color instead, which means the arrow has
   to STAY beige rather than also turning red (red-on-red would be
   invisible). Higher specificity than both shared rules (extra ".a"
   element + extra class) so this wins regardless of source order. */
a.image-mosaic__caption--headline .diamond-cta {
  transition: background-color 0.3s ease-out, border-color 0.3s ease-out;
}
a.image-mosaic__caption--headline:hover .diamond-cta {
  background-color: var(--color-red);
  border-color: var(--color-red);
  transition: background-color 0.6s cubic-bezier(0.25, 1, 0.5, 1), border-color 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
a.image-mosaic__caption--headline:hover .diamond-cta .arrow-glyph__hover {
  opacity: 0;
}
a.image-mosaic__caption--headline:hover .diamond-cta .arrow-glyph__default {
  opacity: 1;
}

/* Same faint hover glow as .square, applied to the photo instead: a
   white wash rather than filter:brightness, since these tiles have no
   painted content of their own for a filter to act on — they're windows
   onto the shared <img> beneath, and filter can't reach through a
   transparent element to whatever's underneath it. Sits above the
   scrim/masks/captions (z-index) so it's hoverable across the whole
   photo, including over the caption text. */
.image-mosaic__hover-tiles {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-template-rows: repeat(3, 1fr);
}
.image-mosaic__hover-tile {
  background: rgba(255, 255, 255, 0);
  transition: background-color 0.3s ease-out;
}
.image-mosaic__hover-tile:hover {
  background: rgba(255, 255, 255, 0.14);
  transition: background-color 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}

/* Sequenced reveal for the scrim + caption. Unlike .reveal's per-index
   --reveal-delay, there's no delay math here at all — main.js only adds
   "is-visible" once the actual event it depends on has genuinely
   finished (all masks' transitions, then the scrim's), with a small
   setTimeout pause at each handoff. See initMosaicReveal(). */
.mosaic-reveal {
  opacity: 0;
  transition: opacity 0.5s ease-out;
}
.mosaic-reveal.is-visible {
  opacity: 1;
}
.mosaic-reveal--slide {
  transform: translateY(20px);
  transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.mosaic-reveal--slide.is-visible {
  transform: none;
}
/* Squares 4 & 16: a pre-cropped photo inset smaller than the tile,
   centered, with the tile's brand color showing around it. */
.square--inset {
  align-items: center;
}
.square__inset-image {
  width: 68%;
  height: auto;
}
/* Decorative asterisk sitting in the open corner space beside the inset
   photo (NOT overlapping it) — sized and placed to match the reference
   screenshots: clearly its own element off to the side, not a small
   badge tucked onto the image's edge.
   Split across a wrap + inner element — same reasoning as
   .hero__wordmark-asterisk-wrap: a CSS `animation` that touches
   `transform` (the spin keyframe here) REPLACES the element's own static
   transform entirely rather than composing with it, so a positioning
   transform declared on the same element as the spin animation would get
   silently interpolated away instead of actually spinning. The wrap
   holds position/size; the inner element holds only the spin.
   .square already has position:relative, so the wrap positions directly
   against the SQUARE's own box. Sized as a % of the square (not
   --hero-asterisk-size) so it scales with the tile itself. */
.square__inset-asterisk-wrap {
  position: absolute;
  width: 15%;
  pointer-events: none;
}
.square__inset-asterisk-wrap--top-left {
  top: 23.5%;
  left: 23.5%;
}
.square__inset-asterisk-wrap--bottom-right {
  bottom: 25%;
  right: 25%;
}
/* Beige (--color-tan), not the literal cream color baked into the PNG
   asset — the asterisk PNG is used purely as a MASK here (its alpha
   channel defines the shape), with a solid --color-tan background-color
   supplying the actual color, the same masking technique as
   .square--checker::before. That's what makes the color independent of
   whatever the source image's own pixels are. */
.square__inset-asterisk {
  width: 100%;
  aspect-ratio: 184 / 200;
  background: var(--color-tan);
  -webkit-mask-image: url("../assets/images/Asterisk - Default, Cream.png");
  mask-image: url("../assets/images/Asterisk - Default, Cream.png");
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  animation: hero-asterisk-spin 8s linear infinite;
}

/* Curved along an actual circular arc via SVG textPath (see the HTML) —
   width caps the rendered size. Height/viewBox trimmed to a shallow
   arc (large radius, small rise) rather than a full half-circle — a
   deep curve left a lot of genuinely empty space below the text inside
   the SVG's own box, which read as "too much gap before the diamond"
   even after the diamond's own margin was reduced. */
.square__arc {
  display: block;
  width: 200px;
  height: 50px;
}
/* Same face/size as ordinary body text — --font-body, --text-body size,
   not italic. Previously used Instrument Serif italic at a one-off
   size, which was specific to "Learn more" inside the diamond, not
   this. Weight bolded to 500 (then reverted back to 300, body's own
   default) per explicit request to un-bold "Still curious?". */
.square__arc text, .square__arc textPath {
  font-family: var(--font-body);
  font-weight: 300;
  fill: var(--color-tan);
  font-size: var(--text-body);
}
/* CANONICAL "text + arrow" diamond CTA style — square 11's "Learn more"
   and section 4 square 3's "Check us out!" are its 2 instances today,
   but this exact size/stroke/line-height/font combo (130px, 1.25px
   border, --text-body Instrument Serif italic at line-height 1.1,
   arrow-glyph gap = the shared line-height) is the one to copy for any
   FUTURE diamond CTA that holds text + an arrow, not just a bare arrow
   (that's .diamond-cta--tight instead — a distinct, deliberately
   smaller/different style for icon-only diamonds). */
.diamond-cta {
  /* Enlarged from 110px (then tightened down from an initial 160px,
     which had too much slack) — measured the actual "Learn more" span
     at 51x93.6px un-rotated, so the diamond's own diagonal (side*√2)
     needs to clear (51+93.6) plus a modest margin: 130*√2≈184px, safely
     over the ~145px minimum with just enough breathing room, not the
     loose oversized gap 160px left. */
  /* clamp(), not a flat 130px — the OLD flat value + flex-shrink:0
     (below) meant this simply didn't fit once its own square shrank
     enough at narrow viewports (see .square--pad-lg's own padding fix
     above) — reported as "shrinking itself and its square an absurd
     amount," which was really the diamond staying rigidly fixed while
     everything around it shrank instead. Both width and height share
     the SAME clamp() (not independent values) so it stays a true
     square at every size — flex-shrink:0 still matters here even with
     a responsive size: it's what stops flexbox's own default
     shrink-the-main-axis-only behavior from squashing just the height
     once its column runs out of room, independent of whatever this
     clamp() itself resolves to. */
  width: clamp(70px, 20vw, 130px);
  height: clamp(70px, 20vw, 130px);
  /* Bumped back up from 6px — that read as too tight against the arc
     text above it. */
  margin-top: 16px;
  /* 1.25px — thickened slightly from an earlier 0.75px (which read as
     too thin/hairline once the diamond itself got smaller/more
     prominent), but well short of the original 1.5px this all started
     from. */
  border: 1.25px solid var(--color-tan);
  transform: rotate(45deg);
  display: flex;
  align-items: center;
  justify-content: center;
  /* Every instance sits inside a flex column (.square--content-left)
     whose height shrinks with the viewport — without this, flexbox's
     default flex-shrink:1 compresses just this box's height (the main
     axis) once the column runs out of room, while width (cross axis)
     stays untouched, rotating a squashed non-square box into a flat
     rectangle instead of a diamond. */
  flex-shrink: 0;
  /* Without this, hovering the arrow/text (plain inline content, not a
     real link) shows the browser's default text-selection I-beam
     cursor — decorative hover only, not a click affordance (same
     reasoning as .square:hover having no cursor change). Real <a>
     instances (square 11's "Learn more", square 7's arrow-only diamond)
     override this back to pointer below. */
  cursor: default;
}
a.diamond-cta {
  cursor: pointer;
}
/* Now built from the uploaded arrow PNGs (assets/images/Arrow, *.png)
   instead of CSS pseudo-elements — the shaft+chevron pseudo-element
   version (border-box geometry, currentColor) turned out impossible to
   keep pixel-perfect across every stroke-width/color tweak, so this
   switched to real artwork. Each instance provides 1 or 2 <img> tags
   (see the HTML): a single static image for plain text links (split-cta,
   view-more), or 2 stacked images — default + hover color — for
   anything with its own hover state (diamond CTAs, the mosaic caption).
   The 2-image ones crossfade via opacity (see .arrow-glyph__hover
   below); the ANCESTOR decides when (.diamond-cta--fill-on-hover:hover,
   .image-mosaic__caption--headline:hover), so a single generic rule
   here covers every hover-swapping instance regardless of which 2
   colors it actually uses.
   NOTE: "Arrow, blue.png" and "Arrow, brown.png" are misnamed relative
   to their actual pixel colors — "blue.png" is --color-dark and
   "brown.png" is --color-grey. This project already calls --color-grey
   "blue" informally elsewhere (.diamond-cta--fill-on-hover--blue), so
   treat these 2 files by their ACTUAL rendered color, not their
   filename, when using them anywhere. */
.arrow-glyph {
  display: inline-block;
  position: relative;
  /* Matches the source PNGs' own 66:42 aspect ratio (11:7) exactly, so
     object-fit:contain below never needs to compensate for a mismatch. */
  width: 22px;
  height: 14px;
  vertical-align: middle;
  /* No cursor override here (was cursor:default) — every current
     instance of this glyph lives inside a real clickable ancestor (a
     .diamond-cta <a>/<button>, or a plain <a> for the publications
     view-more/mosaic headline links), all of which already resolve to
     cursor:pointer. cursor is an inherited property, so leaving it
     unset here lets the glyph correctly pick up pointer from whatever
     it's actually inside, instead of a stray explicit default blocking
     that inheritance and showing a text-cursor over just the arrow's
     own pixels while the rest of the same clickable element shows
     pointer. Per explicit request: every arrow instance should show
     pointer on hover. */
}
.arrow-glyph img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.arrow-glyph__hover {
  /* Same positioning as .arrow-glyph img above — restated here (not
     merged into that selector) because this class is also used on a
     plain <div> (see .split-cta__diamond-arrow-cream), which the
     img-only selector above doesn't match. */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity 0.3s ease-out;
}
.diamond-cta--fill-on-hover:hover .arrow-glyph__hover,
.image-mosaic__caption--headline:hover .arrow-glyph__hover {
  opacity: 1;
  transition: opacity 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
.diamond-cta--fill-on-hover:hover .arrow-glyph__default,
.image-mosaic__caption--headline:hover .arrow-glyph__default {
  opacity: 0;
}
/* :not(.arrow-glyph) — .arrow-glyph is ALSO a <span>, so a bare
   ".diamond-cta span" selector matches it too whenever it's nested
   inside THIS span (square 11's "Learn more"), double-counter-rotating
   it: this span's own -45deg already cancels the diamond's +45deg for
   everything inside it (that's why the TEXT renders level), so the
   nested arrow-glyph getting ANOTHER independent -45deg from the same
   rule stacks to a net -45deg overall — a visibly tilted arrow, not the
   flat/horizontal one actually wanted. See .diamond-cta > .arrow-glyph
   below for the (different) direct-child case that DOES need its own
   -45deg — square 7's icon-only diamond, which has no wrapping span
   like this one to inherit the correction from. */
.diamond-cta > span:not(.arrow-glyph) {
  transform: rotate(-45deg);
  font-family: var(--font-display);
  font-style: italic;
  color: var(--color-tan);
  text-align: center;
  /* Matches --text-body — "same size as body text", not the earlier
     one-off 0.85rem. */
  font-size: var(--text-body);
  /* Tightened from 1.3 — read as too loose between "Learn"/"more"/the
     arrow. All 3 are lines of this same span (<br />-separated), so
     they share this one line-height — the arrow's own gap from "more"
     automatically matches the Learn/more gap with no separate margin
     needed, as long as the arrow stays inline in this same flow. */
  line-height: 1.1;
}
/* Only matches when .arrow-glyph is a DIRECT child of .diamond-cta
   (square 7's icon-only diamond) — nothing else cancels the diamond's
   own +45deg rotation for it, unlike square 11's arrow (nested inside
   the span above, which already handles it). Does NOT match square
   11's arrow, since there it's nested one level deeper. */
.diamond-cta > .arrow-glyph {
  transform: rotate(-45deg);
}
/* Much smaller than the "Learn more" diamond — this one holds a single
   arrow glyph, so it should hug it tightly rather than use the same
   160px size built for 3 lines of --text-body text. */
.diamond-cta--tight {
  width: 36px;
  height: 36px;
  /* More breathing room from the text above it — was 16px. */
  margin-top: 36px;
}
/* Section 1's "Read our latest issue!" arrow: a --tight diamond sitting
   INLINE within the caption's own running text, not stacked below it
   like every other --tight instance — the base .diamond-cta's
   display:flex is block-level, which would otherwise force it onto its
   own new line. inline-flex keeps the internal centering while
   participating in the surrounding text flow, same reason
   .arrow-glyph itself is inline-block. margin-top:0 overrides --tight's
   own 36px (that spacing assumes a stacked-below layout, not inline). */
.diamond-cta--inline {
  display: inline-flex;
  vertical-align: middle;
  margin-top: 0;
  margin-left: 8px;
}
/* Every diamond CTA on the page is hoverable: the diamond's own
   INTERIOR fills solid tan, and the text/arrow switches to a second
   color — never anything outside the 5-color palette. transition on
   both background-color (the fill) and color (the text) for a matched
   fade. Color variants exist because each diamond on the page wants its
   own post-fill text color — always the SAME color as the tile it sits
   on (this one "blue" — i.e. --color-grey, the palette's own blue-grey
   stand-in, no true blue exists — the roles-section one red). See
   .diamond-cta--dark-on-tan below for the one tile (section 4 square 3)
   where the tile itself is tan, which inverts this whole scheme instead
   of just picking a 3rd hover-text color. */
.diamond-cta--fill-on-hover {
  transition: background-color 0.3s ease-out;
}
.diamond-cta--fill-on-hover span {
  transition: color 0.3s ease-out;
}
/* Entering hover specifically gets the slower 0.6s cubic-bezier(0.25, 1,
   0.5, 1) — this page's own established "slow in / fast out" hover
   convention (see .image-mosaic__caption--headline:hover,
   .image-mosaic__hover-tile:hover) — to match .arrow-glyph__hover's OWN
   enter transition (same duration/curve, just below). Without this the
   fill+text finished in the base 0.3s while the arrow crossfade was
   still only half done, reading as the arrow lagging behind/changing on
   its own schedule instead of together with the diamond. */
.diamond-cta--fill-on-hover:hover {
  background-color: var(--color-tan);
  transition: background-color 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
.diamond-cta--fill-on-hover:hover span {
  transition: color 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
.diamond-cta--fill-on-hover--blue:hover span {
  color: var(--color-grey);
}
.diamond-cta--fill-on-hover--red:hover span {
  color: var(--color-red);
}
/* Section 4 square 3's own Instagram diamond sits directly on a TAN
   tile, so the shared tan hover-fill above and the shared beige default
   text/arrow (both tuned for a grey/red/dark tile, where tan reads as a
   clear accent) would both be invisible here — inverts the whole scheme
   instead of adding a 3rd hover-text color: dark default text/border/
   arrow (readable against tan), dark hover-fill (overriding the shared
   tan one), cream hover text/arrow (readable against the now-dark
   fill). Same fix already used for .split-cta__diamond, which hits this
   identical problem sitting on the cream page background instead of a
   tan tile. margin-top:0 too — the base .diamond-cta rule's 16px
   margin-top exists to clear an arc sitting above it (see square 11);
   this diamond is its tile's ONLY child, so that margin would just push
   it 8px below true center for no reason (half of 16px, since flex
   centers the diamond+margin box as one unit). */
.diamond-cta--dark-on-tan {
  border-color: var(--color-dark);
  margin-top: 0;
}
.diamond-cta--dark-on-tan > span:not(.arrow-glyph) {
  color: var(--color-dark);
}
.diamond-cta--dark-on-tan:hover {
  background-color: var(--color-dark);
}
/* Beige (--color-tan), not cream, per explicit follow-up — reads as more
   on-brand against the dark fill than the very light near-white cream.
   Arrow recolored to match (overriding the shared
   .split-cta__diamond-arrow-cream mask it otherwise reuses as-is) so
   text and arrow stay a matched pair on hover, same convention as every
   other state on this diamond. */
.diamond-cta--dark-on-tan:hover span {
  color: var(--color-tan);
}
.diamond-cta--dark-on-tan .split-cta__diamond-arrow-cream {
  background-color: var(--color-tan);
}
/* Section 5's big "View more" diamond sits directly on the plain cream
   page background, same problem as .split-cta__diamond's own tiny
   icon-only diamond (see that class) — just at the base .diamond-cta
   size, with real text inside. Same inversion shape as --dark-on-tan
   above: dark default border/text/arrow (readable against cream), dark
   hover-fill, but CREAM hover text/arrow here (not tan) — matching
   .split-cta__diamond-arrow-cream's own already-cream default fill
   exactly, so no scoped recolor override is needed the way --dark-on-tan
   needed one. */
.diamond-cta--dark-on-cream {
  border-color: var(--color-dark);
  margin-top: 0;
}
.diamond-cta--dark-on-cream > span:not(.arrow-glyph) {
  color: var(--color-dark);
}
.diamond-cta--dark-on-cream:hover {
  background-color: var(--color-dark);
}
.diamond-cta--dark-on-cream:hover span {
  color: var(--color-cream);
}

/* ===================================================================
   Featured carousel (section 4) — the viewport itself (.featured-carousel)
   carries the same 4/2 aspect-ratio every other 4-col x 2-row grid in
   sections 1-3 uses, full width, no side margin/whitespace. 4 slides
   live in .featured-carousel__track (flex row, width 400%, slid via
   transform: translateX by initFeaturedCarousel() in main.js, wrapping
   past the last slide back to the first). The 2 nav buttons AND the
   title/edition/number caption are direct children of .featured-carousel
   (siblings of the track, not inside it) so they stay fixed in place,
   overlaid INSIDE the image at a 24px inset (same convention as
   .image-mosaic__caption--left/right) rather than sliding with the
   track or sitting in an outer margin. Each slide just carries its own
   text as data-title/data-edition/data-number attributes (see
   buildCarouselLoopClone() and initFeaturedCarousel() in main.js) — the
   ONE real caption's text content crossfades to match on nav click
   instead of physically riding along with the sliding image.

   Only the FIRST slide gets the real reveal treatment — the other 3
   only ever appear via a click, not a scroll-into-view, so there's no
   "first paint" moment for them to reveal INTO. That first slide is
   the exact same 2-layer construction as section 1's image-mosaic: ONE
   shared placeholder "photo" (.featured-carousel__photo-placeholder — a
   single continuous hatch pattern, not 8 independently-hatched tiles
   that'd seam at every tile edge) behind a 4x2 grid of
   .featured-carousel__mask panels that fade OUT tile-by-tile to reveal
   it, exactly like .image-mosaic__mask (same inverted opacity: 1 ->
   .is-visible { opacity: 0 }, staggered by the same generic
   initRevealOnScroll() system — see its selector list in main.js).
   initCarouselReveal() in main.js chains masks-done -> scrim.mosaic-
   reveal -> title/edition/nav-buttons.mosaic-reveal, the same 3-step
   chain initMosaicReveal() runs for section 1. Slides 2-4 skip the
   mask/reveal machinery entirely — just the placeholder photo + an
   always-visible scrim, since they're never mid-reveal when the user
   actually sees them. .featured-carousel__scrim is the
   section 1 mosaic's own .image-mosaic__scrim gradient, just flipped
   (dark at the TOP, fading down) since the caption sits at the top
   here instead of the bottom.
=================================================================== */
.featured-carousel {
  position: relative;
  overflow: hidden;
  border-radius: 0 0 15px 15px;
  aspect-ratio: 4 / 2;
}
.featured-carousel__track {
  display: flex;
  /* 400%/25% below are sized for exactly 4 real slides — a sane
     fallback if, for whatever reason, initFeaturedCarousel() never runs.
     Normally both get overridden inline by that function once it's
     added its 2 loop-continuity clones (see its own comment), since the
     real total (6) no longer matches this hardcoded assumption. */
  width: 400%;
  height: 100%;
  /* cubic-bezier(0.25, 1, 0.5, 1) is this page's own established
     "entering" ease-out curve (see .square:hover, the diamond-cta
     fills) — reused here instead of a new curve. */
  transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
.featured-carousel__slide {
  position: relative;
  /* 1 of 4 slides, each of which is itself 400% of .featured-carousel's
     own width (see .featured-carousel__track) — together that's exactly
     100% of .featured-carousel per slide. See that rule's own comment on
     why this is a fallback value, normally overridden inline by JS. */
  flex: 0 0 25%;
}
/* Same pattern as .image-mosaic__photo — sits in the exact same DOM/
   stacking position .featured-carousel__photo-placeholder occupied (first
   child of the slide), so slides that now have a real photo still work
   with the existing mask-reveal/scrim/hover-tile layers on top of it
   unchanged; only slides without a real photo yet still use the
   placeholder. */
.featured-carousel__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.featured-carousel__photo-placeholder {
  position: absolute;
  inset: 0;
  background: var(--color-dark);
}
.featured-carousel__photo-placeholder::before {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    135deg,
    rgba(249,245,236,0.06),
    rgba(249,245,236,0.06) 10px,
    transparent 10px,
    transparent 20px
  );
}
.featured-carousel__masks {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(2, 1fr);
}
/* The opacity/transition/inverted-reveal rules live with
   .image-mosaic__mask's own (see the "Scroll reveal" section) — this is
   just the background color. */
.featured-carousel__mask {
  background: var(--color-cream);
}
.featured-carousel__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent 45%);
  pointer-events: none;
}
/* Same font/size as section 1's "Read our latest issue!"
   (.image-mosaic__caption--headline) — where each carousel image's own
   title will go once these are real photos, not placeholders. */
.featured-carousel__title {
  position: absolute;
  top: 24px;
  left: 24px;
  /* Higher than .featured-carousel__hover-tiles (z-index:2, below) — same
     reasoning as .image-mosaic__caption: without this, the hover-tiles
     layer sits on top and silently swallows the white-wash flash right
     over the text with nothing visible to show for it there. */
  z-index: 3;
  color: var(--color-tan);
  font-family: var(--font-display);
  font-style: italic;
  font-size: clamp(26px, 8vw, 40px);
  max-width: 70%;
  /* Both a real link now (see index.html) — same red-on-hover affordance
     as .image-mosaic__caption--headline. Deliberately NO base-rule
     `transition` here (unlike that element) — this one also carries
     .mosaic-reveal--slide, whose own `transition: opacity ..., transform
     ...` comes EARLIER in this file; redeclaring the transition shorthand
     here (equal specificity, later source) would fully replace it rather
     than add color to it, silently killing the opacity transition this
     element's own scroll-reveal AND the JS-driven caption crossfade (see
     .is-caption-fading below) both depend on. The :hover rule below
     handles the color transition instead — its extra specificity (a
     pseudo-class) wins safely without touching transition-property for
     anything else. */
  cursor: pointer;
}
/* Sits directly beneath the title — same "plain body text/size" freight
   treatment as .image-mosaic__caption--right ("Volume 2"), just left-
   aligned under the title instead of pinned to the opposite corner. */
.featured-carousel__edition {
  position: absolute;
  /* 24px (top) + the title's own 40px line + a small gap. */
  top: 74px;
  left: 24px;
  z-index: 3;
  color: var(--color-tan);
  font-family: var(--font-body);
  font-size: var(--text-body);
  /* Same reasoning as .featured-carousel__title above — no base-rule
     transition here either. */
  cursor: pointer;
}
.featured-carousel__title:hover,
.featured-carousel__edition:hover {
  color: var(--color-red);
  transition: color 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Bottom-right per-slide number, same "(0N)" format as the intro
   splash's own screen numbers (see .intro-splash__caption-line2) — same
   24px inset convention as title/edition above, just the opposite
   corner. */
.featured-carousel__number {
  position: absolute;
  bottom: 24px;
  right: 24px;
  z-index: 3;
  color: var(--color-tan);
  font-family: var(--font-display);
  font-size: var(--text-body);
}
/* Nav-click crossfade (title/edition/number swapping to match the new
   slide) is separate from the initial scroll-reveal fade-in above —
   both are opacity transitions on the same 3 elements, driven by
   initFeaturedCarousel() toggling this class on .featured-carousel while it
   swaps their text. 3-class specificity so it reliably wins over
   .mosaic-reveal.is-visible's opacity:1 (2 classes), which stays
   permanently applied after the first reveal. */
.featured-carousel.is-caption-fading .featured-carousel__title,
.featured-carousel.is-caption-fading .featured-carousel__edition,
.featured-carousel.is-caption-fading .featured-carousel__number {
  opacity: 0;
}
/* Same always-transparent-until-hovered per-cell flash as
   .image-mosaic__hover-tile — "the carousel is made of tiles/squares,
   so it should be universally hoverable" the same way every .square and
   the section 1 mosaic already are. Separate from .featured-carousel__mask
   on purpose: those end at opacity:0 permanently once revealed, so a
   hover effect added to them would have no visible content to
   brighten — this is its own layer, on top of everything else in the
   slide (masks, scrim, photo), same 4x2 grid. */
.featured-carousel__hover-tiles {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(2, 1fr);
}
.featured-carousel__hover-tile {
  background: rgba(255, 255, 255, 0);
  transition: background-color 0.3s ease-out;
}
.featured-carousel__hover-tile:hover {
  background: rgba(255, 255, 255, 0.14);
  transition: background-color 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Real <button>s (JS-driven, no destination), not <a> —
   .diamond-cta--tight for the same small icon-only sizing as
   split-cta's own diamond and section 2 square 7's. Sits ON TOP of the
   track/masks/scrim (z-index) so it's always clickable regardless of
   which slide is showing or what's mid-reveal underneath it. */
.featured-carousel__nav {
  /* The other diamond-cta instances are all <a>/<div> — this is the
     first real <button>, so it needs its own reset against the
     browser's native button chrome (background/padding/font), which
     .diamond-cta itself never had to account for. */
  appearance: none;
  background: none;
  padding: 0;
  font: inherit;
  position: absolute;
  top: 50%;
  z-index: 4;
  margin: 0;
  /* Must restate rotate(45deg) here, not just translateY(-50%) —
     transform is a single property, so a 2nd rule setting it without
     the rotate would silently REPLACE .diamond-cta's own
     transform:rotate(45deg) instead of composing with it, turning the
     diamond into a plain square (and, since the arrow-glyph's own
     -45deg counter-rotation assumes a real 45deg parent to cancel,
     visibly tilting the arrow too). translateY(-50%) still resolves
     against this element's own (unrotated) box size first, so
     centering is unaffected by also rotating. */
  transform: translateY(-50%) rotate(45deg);
  /* No border-color override — .diamond-cta's own base border (see
     above) is already --color-tan, i.e. beige, which is what's wanted
     here. */
  cursor: pointer;
  /* Matches split-cta's own diamond scale (28px), per explicit request
     to bring this in line with it rather than the shared 36px
     .diamond-cta--tight default every OTHER icon-only diamond
     (section 2's) still uses. */
  width: 28px;
  height: 28px;
}
/* Scaled down to match the smaller 28px diamond above — at the default
   22x14 (sized for the 36px diamond) the arrow read oversized/cramped
   inside it, same issue .split-cta__diamond already hit and fixed the
   same way. Same 11:7 aspect ratio preserved. */
.featured-carousel__nav > .arrow-glyph {
  width: 14px;
  height: 9px;
}
/* .diamond-cta > .arrow-glyph (see above) already provides the
   counter-rotation every icon-only diamond needs — nothing extra here
   except this one flip for "previous". */
.featured-carousel__nav--prev {
  left: 24px;
}
.featured-carousel__nav--prev > .arrow-glyph {
  /* Arrow artwork points right by default — flip for "previous". */
  transform: rotate(-45deg) scaleX(-1);
}
.featured-carousel__nav--next {
  right: 24px;
}

/* Bottom-center slide-count indicators — same job a typical row of
   carousel dots does, but filled diamonds (rotate(45deg) on a small
   solid square, same technique as .featured-carousel__nav/.diamond-cta
   above) per explicit request instead of circles. Built dynamically by
   initFeaturedCarousel() in main.js (one per REAL slide, never per
   clone) so the count can't drift out of sync with the actual number
   of slides; no base rule needed in index.html beyond the empty
   container. */
.featured-carousel__indicators {
  position: absolute;
  /* Same 24px inset every other persistent carousel element (title,
     edition, nav buttons, the "(0N)" number) already uses, so this
     lines up with the slide-count caption along the same bottom
     line instead of sitting at its own arbitrary offset. */
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 12px;
}
/* Solid fill, not an outline — "filled diamonds" per the request.
   Inactive ones sit at reduced opacity (dimmed, not literally
   transparent-to-invisible) so the active slide's diamond reads as
   "more solid" purely by contrast, without needing a second color. */
.featured-carousel__indicator {
  appearance: none;
  background: var(--color-tan);
  border: none;
  padding: 0;
  margin: 0;
  width: 9px;
  height: 9px;
  transform: rotate(45deg);
  cursor: pointer;
  opacity: 0.35;
  transition: opacity 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}
.featured-carousel__indicator:hover {
  opacity: 0.65;
}
.featured-carousel__indicator.is-active {
  opacity: 1;
}

/* ===================================================================
   Marquee banners ("Get Involved *", "Publications *") — no background
   (just page bg showing through), no rounded corners (nothing to round
   without a background anyway). Full-bleed: breaks out of the page's
   10px margin via the classic 100vw + negative-margin trick, so the
   repeated text is cropped by the actual screen edges rather than the
   page's own content width — that hard crop at the viewport edge, plus
   enough repetitions to overflow it, is what reads as an (uncut,
   static — it never actually moves) carousel strip continuing off
   both sides. Height is 2/3 of a regular square tile: a square is
   (full viewport width)/4 (4 columns), so 2/3 of that is width/6.
=================================================================== */
.marquee-banner {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  /* Content-based height (was a fixed viewport-width-derived calc, from
     back when the text was much smaller and unrelated in size to the
     strip's own height) — small vertical padding instead, so the strip
     is only as tall as the 40px text actually needs. */
  padding: 14px 0;
  display: flex;
  align-items: center;
  overflow: hidden;
  color: var(--color-dark);
}
/* The site's one shared marquee-strip preset — originally designed and
   tuned against #get-involved alone (through several rounds of explicit
   follow-up feedback), then promoted to the universal style for every
   .marquee-banner instance on index.html (#get-involved, #publications).
   publications.html used to have its own generated marquee strips too
   (one per category), replaced by .section-eyebrow — see that page's own
   CSS further down. Every instance's markup cycles the same 3 faces per
   word — Kapakana, Instrument Serif, Newsreader (only Newsreader
   rendered in caps) — see the HTML comment above #get-involved's own
   track in index.html for the word-span structure this relies on, and
   centerMarqueeTracks() in main.js for how each track's own Newsreader
   word gets centered independently. */
.marquee-banner {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  /* Content-based height (was a fixed viewport-width-derived calc, from
     back when the text was much smaller and unrelated in size to the
     strip's own height) — small vertical padding instead, so the strip
     is only as tall as the text actually needs. */
  padding: 14px 0;
  display: flex;
  align-items: center;
  overflow: hidden;
  color: var(--color-dark);
}
.marquee-banner__track {
  font-family: var(--font-display);
  font-size: var(--marquee-text-size);
  white-space: nowrap;
  width: 100%;
  text-align: center;
}
/* An ACTUALLY-scrolling variant of .marquee-banner (currently
   #get-involved and #publications — see their own HTML comments) — the
   base preset above is a deliberate static illusion (see its own
   comment), this instead moves continuously. .marquee-banner__scroll
   holds 2 identical .marquee-banner__track copies (see the HTML) laid
   out side by side via flex and animated between translateX(calc(-1 *
   var(--marquee-track-width))) and translateX(0) — since the 2 copies
   are byte-identical, the instant the 1st has scrolled fully into view
   the 2nd (identical) one is sitting exactly where the 1st started, so
   the loop point is invisible either direction.

   --marquee-track-width is set (as an exact px value, per instance) by
   setMarqueeScrollMetrics() in main.js, measuring THIS track's own real
   rendered width — NOT a bare -50%. -50% looks equivalent at a glance
   (2 equal-width tracks, so 50% of the pair should equal exactly 1
   track), but flexbox/whitespace/sub-pixel layout rounding can leave the
   container's true total width a fraction of a pixel off from exactly
   2x one track's width — invisible on a single glance, but a real,
   reported "the marquee resets/jumps" glitch once that fraction is
   compounded by a long-running infinite loop's own restart boundary.
   Shifting by this track's own measured width instead guarantees the
   2nd copy lands EXACTLY where the 1st started, every single loop, with
   no accumulated drift possible. The 50% fallback below only matters
   before that JS runs (or if it's ever missing).

   animation-duration is ALSO set inline by that same function — sized
   to this track's own width divided by MARQUEE_SCROLL_SPEED_PX_PER_S,
   so every .marquee-banner--scrolling instance moves at the exact same
   FELT speed regardless of how long its own content is (a longer track
   gets a proportionally longer duration, not the same fixed number a
   shorter track would look faster with). The 96s below is just a
   pre-JS fallback.

   width:auto/text-align:left/flex-shrink:0 override the base track rule
   above (equal selector specificity, but this comes later) —
   width:100%/text-align:center only made sense for a single centered
   static track, not 2 tracks meant to sit flush against each other in a
   flowing row. */
.marquee-banner--scrolling .marquee-banner__scroll {
  display: flex;
  width: max-content;
  animation: marquee-scroll 96s linear infinite;
}
.marquee-banner--scrolling .marquee-banner__track {
  width: auto;
  text-align: left;
  flex-shrink: 0;
}
@keyframes marquee-scroll {
  from { transform: translateX(calc(-1 * var(--marquee-track-width, 50%))); }
  to { transform: translateX(0); }
}
/* prefers-reduced-motion already forces every other ambient/looping
   animation on the site to a standstill (see that media query further
   down) — this one lives with its own rule instead of being folded into
   that shared list since .marquee-banner--scrolling didn't exist yet
   when that list was last touched; functionally identical (transition:
   none would be a no-op here since this uses `animation`, not
   `transition` — animation-play-state: paused is the actual equivalent). */
@media (prefers-reduced-motion: reduce) {
  .marquee-banner--scrolling .marquee-banner__scroll {
    animation-play-state: paused;
  }
}
/* Each "*" separator is the brown asterisk PNG (assets/images/Brown
   asterisk.png — see initMarqueeAsterisks() in main.js), not a plain
   text character — a raster image has no font-metric/line-height
   quirks to fight, unlike a bare "*" glyph.
   vertical-align: baseline (not middle) — middle centers against the
   font's X-HEIGHT, which sits measurably below cap-height, so it always
   reads as "too low" next to all-caps text. baseline is a fixed,
   well-defined anchor (the box's bottom sits exactly on the text
   baseline); alignAsteriskToCapHeight() in main.js then applies a
   JS-measured translateY (real canvas cap-height metrics, not an
   assumed ratio) to land the image's center exactly at cap-height/2
   above the baseline instead. */
.marquee-banner__word--script,
.marquee-banner__word--serif {
  /* 0.06em tracking only reads right on the caps face it was tuned for
     (typical for all-caps display type) — on these 2 mixed-case faces
     it just looks like broken kerning, and on the script face
     specifically it visibly breaks the letters' own connecting
     strokes. */
  text-transform: none;
  letter-spacing: normal;
}
.marquee-banner__word--script {
  font-family: "Kapakana", cursive;
  /* 1.55x the track's own size (Kapakana still reading a bit thin/small
     next to the other 2 faces at equal size otherwise). */
  font-size: calc(var(--marquee-text-size) * 1.55);
}
/* Wraps just the "G" (Get) and "I" (Involved) in the script-face cycle —
   letter-spacing trails the character it's set on, so putting -0.03em on
   a single-letter span pulls only the next letter in closer without
   touching spacing anywhere else in the word. */
.marquee-banner__kern-tight {
  letter-spacing: -0.03em;
}
.marquee-banner__word--caps {
  font-family: var(--font-body);
  /* Newsreader Regular (400) — the page's own ambient default is Light
     (300); this needs the heavier of the 2 to match the Figma. */
  font-weight: 400;
  /* -5% off the track's own size per explicit request — Newsreader's caps
     read visually larger than Instrument Serif's mixed case at the same
     nominal size (all-caps text is cap-height-dominant with no
     descenders/ascenders breaking it up), so this brings the two back
     into visual balance rather than literal size parity. */
  font-size: calc(var(--marquee-text-size) * 0.95);
  text-transform: uppercase;
  /* Explicit override, dialed down from letter-spacing values as large
     as 0.06em tried earlier (tuned for the OLD single-face 40px scale)
     per repeated explicit follow-up requests to reduce it further. */
  letter-spacing: 0.02em;
}
.marquee-asterisk {
  display: inline-block;
  vertical-align: baseline;
}
.marquee-asterisk img {
  display: block;
  /* Shrunk down from an original flat 18px per explicit request — the
     surrounding text shrank considerably (see --marquee-text-size
     above) and the asterisk started reading oversized next to it. */
  height: 14px;
  width: auto;
}

/* ===================================================================
   Split CTA
=================================================================== */
.split-cta {
  /* Was sized off an aspect-ratio (2/1 -> 2.15/1 -> 2.3/1, matching "N
     square-grid rows") — replaced entirely per explicit request to fill
     the viewport height instead. min-height (not height) so genuinely
     tall content still pushes the section taller rather than clipping,
     though at this content size it's effectively a flat 100vh.
     align-content:center is what actually centers the single grid row
     vertically within that extra height — align-items (below) only
     positions items WITHIN their row's own height, which does nothing
     once the row is shorter than the container itself. Doesn't touch
     .split-cta__illustration-wrap's own size (driven by the COLUMN
     WIDTH via its own aspect-ratio, not the section's height) or
     .split-cta__illustration's -90px offset from it, so the illust
     stays in exactly the same spot relative to the bg box regardless of
     how tall the section itself is. */
  min-height: 100vh;
  display: grid;
  align-content: center;
  /* Was 2fr 1fr (deco at only 1/3 width) — the illustration is meant to
     take up "a little more than half" of the section (per the mockup),
     matching its own explicit "2 squares wide" spec against a 4-square-
     wide section (2 of 4 = half). Bumped further toward the deco side
     per follow-up feedback that it was still too small. */
  grid-template-columns: 0.85fr 1.3fr;
  /* Bumped from 32px — a wider gap here does double duty: more breathing
     room from the illustration, AND it shortens the text column's own
     available width (so its lines wrap shorter), both per explicit
     request. */
  gap: 72px;
  align-items: center;
}
/* About page's own "Beyond the Page" instance only (see the HTML comment
   above that section) — widens the text column vs the shared rule above
   (1fr instead of 0.85fr, narrower 48px gap instead of 72px) so its
   paragraph lines run longer, extending further into the middle of the
   section, without touching index.html's own section 3, which shares
   the plain .split-cta class. */
.split-cta--beyond-page {
  grid-template-columns: 1fr 1.3fr;
  gap: 48px;
}
/* Bumped from 60px per explicit request to push the text further right. */
.split-cta__text { padding-left: 120px; }
/* <h3> is bold + has its own default margin in the browser's own UA
   stylesheet — reset both (same reason .square__role resets <h3>'s bold
   default). Face/size/weight now come from the shared .section-title
   class (see its own comment) — every <h3 class="split-cta__title"> in
   the markup also carries that class. */
.split-cta__title {
  margin: 0 0 16px;
}
/* The 3 paragraph lines are hardcoded <span>s (see index.html), not one
   wrapped <p> — needed so each can reveal independently (see
   .split-cta__text-line below). display:block scoped to the span tag
   specifically so it doesn't fight .split-cta__cta's own display:flex —
   that div shares the .split-cta__text-line class too (for the shared
   reveal treatment) but must stay a flex row, not become block. */
span.split-cta__text-line {
  font-family: var(--font-body);
  font-size: var(--text-body);
  display: block;
}
/* Reveal chain, step 3 (see initSplitCtaReveal() in main.js): fires once
   the illustration (step 2) finishes its own transition. Same slide-up +
   fade idiom as .intro-reveal--slide, and the same nth-child incremental
   -delay trick as .hero__eyebrow-line — is what makes 5 elements toggled
   by the SAME JS call still read as "line by line" instead of one block
   moving at once. All 5 (the title h3 + 3 text spans + the CTA row) are
   direct children of .split-cta__text so nth-child counts correctly
   across all 3 tags. */
.split-cta__text-line {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.45s ease-out, transform 0.45s ease-out;
  transition-delay: 0s;
}
.split-cta__text-line.is-visible {
  opacity: 1;
  transform: none;
}
.split-cta__text-line.is-visible:nth-child(2) { transition-delay: 90ms; }
.split-cta__text-line.is-visible:nth-child(3) { transition-delay: 180ms; }
.split-cta__text-line.is-visible:nth-child(4) { transition-delay: 270ms; }
.split-cta__text-line.is-visible:nth-child(5) { transition-delay: 360ms; }
.split-cta__cta {
  display: flex;
  align-items: center;
  /* Was 24px — pulled the diamond in closer to the text per feedback. */
  gap: 10px;
  /* Generous gap from the paragraph above — noticeably more than the
     12px between paragraph lines, matching the mockup. */
  margin-top: 40px;
}
.split-cta__link {
  /* Plain body-text preset (was font-weight:600 + an underline) — the
     mockup shows this as plain running text, not a bolded/underlined
     link treatment. Plain <p>, not <a>, now — only the diamond next to
     it is a real link (see index.html). */
  font-family: var(--font-body);
  font-size: var(--text-body);
}
/* Overrides .diamond-cta--tight (section 2's icon-only diamond, which
   this otherwise reuses as-is) for this specific instance: smaller, and
   brown border/arrow instead of tan — this diamond sits directly on the
   cream page, not a colored square, so tan (low-contrast against cream)
   doesn't work here the way it does there. margin-top:0 overrides that
   variant's own 36px top margin too — that value exists for square 7's
   STACKED layout (diamond below text), not this INLINE one
   (.split-cta__cta already centers it against the text via flexbox).
   translateY(-6px): flexbox align-items:center centers this against
   .split-cta__link's own LINE BOX, which sits a few px lower than the
   text glyphs' true visual center (line-height leaves more room below
   the glyphs than above) — measured the actual gap directly rather than
   eyeballing it. Hover fills brown (not the shared tan default) with a
   cream arrow (see .split-cta__diamond-arrow-cream) — same reasoning as
   the border: tan-on-cream reads as barely-there here. */
.split-cta__diamond {
  width: 28px;
  height: 28px;
  margin-top: 0;
  border-color: var(--color-dark);
  /* MUST combine with the base .diamond-cta rotate(45deg) in one value
     — a separate transform here would replace it outright rather than
     add to it (same class of bug as elsewhere in this file), rendering
     a square instead of a diamond. translateY listed first (outermost)
     so it shifts the ALREADY-rotated shape in plain screen-space,
     rather than being rotated along with it. */
  transform: translateY(-6px) rotate(45deg);
}
.split-cta__diamond:hover {
  background-color: var(--color-dark);
}
.split-cta__diamond .arrow-glyph {
  /* Smaller than the shared 22x14 default — matches this diamond's own
     smaller (28px vs. 36px) scale; at the standard size the arrow read
     as oversized/cramped inside it. Same 11:7 aspect ratio preserved. */
  width: 14px;
  height: 9px;
}
.split-cta__diamond-arrow-cream {
  -webkit-mask-image: url("../assets/images/Arrow, blue.png");
  mask-image: url("../assets/images/Arrow, blue.png");
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  background-color: var(--color-cream);
}
.split-cta__deco {
  display: flex;
  justify-content: center;
  align-items: center;
}
/* Section 3's illustration — a real photo/artwork, not a CSS shape like
   .deco-flower used to be (removed). Sized to the "2 squares wide, 1.5
   squares tall" spec via aspect-ratio (4/3 — 2 / 1.5 reduces to the same
   ratio) rather than a hardcoded pixel size, since "square" here is just
   a relative unit for the proportions, not a literal reference to any
   actual square tile elsewhere on the page. Just a positioning context
   now — the visible near-black rectangle lives in the child
   .split-cta__illustration-tiles layer below (see its own comment), not
   here directly, so this element's own overflow can stay visible for the
   artwork's deliberate bottom bleed. */
.split-cta__illustration-wrap {
  position: relative;
  /* Was 100% — filled its grid column edge-to-edge, so .split-cta__deco's
     justify-content:center had zero free space to actually center within
     (flush to both column edges isn't "centered," it's just full-bleed).
     90% leaves real breathing room on both sides so the box visibly
     floats centered in its column instead of touching either edge. */
  width: 90%;
  aspect-ratio: 4 / 3;
  /* NOT overflow:hidden — .split-cta__illustration is deliberately
     taller than this box and bleeds out past its bottom edge onto the
     page (the image's own built-in fade-to-cream at its bottom is what
     makes that bleed read as intentional rather than a clipping bug).
     No transform here — this wrap positions the BG TILES (its own
     background), so it stays at .split-cta's own align-items:center
     (dead center), per explicit request. Every subsequent "move the
     illust up" round belongs on .split-cta__illustration alone (see
     below) — the artwork and its bg box are separate entities; a
     transform here moves both together, which is exactly the bug this
     replaced (a long history of translateY values — -40 through a
     diagnostic -400px — all lived on this rule before that was caught). */
}
/* About page "Beyond the Page" (section 2) — same 90%-width/4:3-ratio
   sizing as .split-cta__illustration-wrap above (keeps this column
   matching section 3's proportions), but holds a photo stack instead
   of the tile-mosaic + artwork machinery that box normally holds. */
.beyond-page__images {
  position: relative;
  width: 90%;
  aspect-ratio: 4 / 3;
}
/* var(--color-tan), same warm-cream swatch used for .square--tan
   elsewhere — sits behind both photos as the one visible "circle"
   between them. Centered in .beyond-page__images itself (= centered in
   the section's right column, since that box already fills it) rather
   than pinned to an edge. */
.beyond-page__circle {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 62%;
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  background: var(--color-tan);
  z-index: 0;
}
/* Both source PNGs already bake in their own cream border + drop
   shadow (see assets/images/Outreach-img1.png / img2.png) — no
   box-shadow/border added here, that'd double up.

   Entrance: `translate`/`rotate` here are the standalone CSS properties
   (not the `transform` shorthand) specifically so they can transition
   independently. Per explicit request, opacity/translate/rotate all
   fire at once now (a single --reveal-delay, repeated across all 3 —
   CSS cycles a shorter transition-delay list to match a longer
   transition list) rather than rotate waiting for translate to finish
   first — that earlier staged version left a visible pause before the
   rotate kicked in; this reads as instantaneous instead, just with
   rotate's own overshooting cubic-bezier still giving it a bouncy feel
   as it settles into its final tilt while the slide/fade happen
   alongside it. Centering math lives in the Y value itself
   (calc(-50% + 40px) -> -50%) so the same `translate` property doubles
   as both "center this on its top/left point" AND "the 40px slide"
   without a separate transform.

   --reveal-delay is set by initSplitCtaReveal()'s counterpart override
   in main.js (a short, explicit 0ms/120ms pair specific to these 2
   photos — NOT the generic per-sibling stagger .split-cta__
   illustration-tile uses, which would also count the beige circle as a
   sibling and space them out further than wanted here) — img--2 settles
   120ms after img--1, "sooner" than the plain per-sibling math would
   give. */
.beyond-page__img {
  position: absolute;
  width: 50%;
  height: auto;
  /* Promotes each photo to its own GPU compositor layer up front,
     before the transition ever starts — without this, profiling showed
     the browser doing an expensive HIGH_RESOLUTION raster pass right as
     each photo's transition began (its first-ever rotate/translate
     change forces a late layer promotion + raster at that exact
     moment), which is exactly the scroll-in stutter reported. */
  /* transform (the magnetic-repulsion wobble — see
     initBeyondPageMagneticImages() in main.js) deliberately has NO CSS
     transition at all: that function drives it with a continuous
     per-frame lerp instead (same technique the custom-cursor asterisk's
     own floaty drag already uses elsewhere in this file), which is what
     actually produces the "floaty," not-jerky feel — a CSS transition
     retargeting on every raw mousemove event fights the lerp's own
     easing and reads as choppy. will-change still covers it for the
     same GPU-layer-promotion reason as opacity/translate/rotate below. */
  will-change: opacity, translate, rotate, transform;
  opacity: 0;
  rotate: 0deg;
  translate: -50% calc(-50% + 40px);
  transition:
    opacity 0.6s ease-out,
    translate 0.7s cubic-bezier(0.16, 1, 0.3, 1),
    rotate 0.6s cubic-bezier(0.34, 1.15, 0.64, 1);
  transition-delay: var(--reveal-delay, 0ms);
}
.beyond-page__img.is-visible {
  opacity: 1;
  translate: -50% -50%;
}
/* Doctor photo — sits behind img--2 (lower z-index), tilted counter-clockwise. */
.beyond-page__img--1 {
  top: 40%;
  left: 30%;
  z-index: 1;
}
.beyond-page__img--1.is-visible {
  rotate: -3.01deg;
}
/* Group/tabling photo — sits in front, tilted clockwise,
   overlapping img--1's bottom-right corner. */
.beyond-page__img--2 {
  width: 47%;
  top: 66%;
  left: 66%;
  z-index: 2;
}
.beyond-page__img--2.is-visible {
  rotate: 8.96deg;
}
/* Brown asterisk, top-right of the photo stack — same split-across-2-
   elements idea index.html's own section 3 uses for its own
   .split-cta__illustration-asterisk-wrap/__illustration-asterisk: the
   wrap holds position + a one-shot scroll-triggered pop-in
   (hero-asterisk-intro, added by initBeyondPageTextReveal() in main.js
   in sync with the photos' own entrance, not immediately on load), the
   inner img holds only the separate continuous spin (hero-asterisk-spin,
   8s linear infinite) — 2 elements because a single element can't run
   a one-shot animation and an infinite one on the same `transform`
   property at once, they'd fight instead of composing. Uses the actual
   pre-colored asset (Brown asterisk.png, also used by
   .partners__asterisk) rather than section 3's mask-image +
   background-color recoloring technique — no arbitrary color choice
   needed here since the asset is already the right brown. z-index above
   both photos so it always reads as sitting in front, not tucked under
   whichever photo happens to overlap this corner. */
.beyond-page__asterisk-wrap {
  position: absolute;
  top: 4%;
  right: 26%;
  width: 36px;
  z-index: 3;
  opacity: 0;
}
.beyond-page__asterisk-wrap.is-visible {
  animation: hero-asterisk-intro 1.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.beyond-page__asterisk {
  display: block;
  width: 100%;
  aspect-ratio: 180 / 200;
  animation: hero-asterisk-spin 8s linear infinite;
}
/* Per explicit request, this section's title/body now use the SAME
   .split-cta__text-line fade-up-on-scroll reveal as index.html's own
   section 3 — initSplitCtaReveal() itself still early-returns here (no
   .split-cta__illustration-tile/.split-cta__illustration on this page),
   so initBeyondPageTextReveal() in main.js is a small parallel function
   that triggers the SAME .split-cta__text-line classes off
   .beyond-page__img--1 becoming is-visible instead (this section's own
   "illustration" equivalent). */
.beyond-page__body {
  font-family: var(--font-body);
  font-size: var(--text-body);
}
.beyond-page__body + .beyond-page__body {
  margin-top: 16px;
}
.beyond-page__body strong {
  font-weight: 500;
}
/* The actual visible rectangle: 2 tiles across, 1.5 tiles down (a
   full-height row of 2 + a half-height row of 2 — tiles are square, so a
   half-height bottom row is what makes 2 rows add up to "1.5 tiles"
   tall). --color-illustration-bg is a one-off, explicitly requested
   color, NOT part of the 5-color swatch (same treatment as
   --color-tan-dark elsewhere in this file). overflow:hidden here (not on
   the parent wrap) clips the 4 tiles to the rounded corners without
   touching the artwork image, which is a SIBLING of this element, not a
   child of it. Each .split-cta__illustration-tile is opted into the
   site's shared reveal-on-scroll system (see that block further down +
   initRevealOnScroll()'s targets in main.js) purely by being added to
   those same selector lists — same tile-by-tile stagger machinery
   section 1's square-grid already uses, no bespoke animation code. */
.split-cta__illustration-tiles {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  /* Was 1fr 0.5fr (a full-height row + a half-height row) — the bottom
     2 tiles read as noticeably smaller/skinnier rectangles than the top
     2 on hover, inconsistent for what's meant to be one uniform 2x2
     grid. Equal rows now, so all 4 tiles are identical dimensions. */
  grid-template-rows: repeat(2, 1fr);
  border-radius: 24px;
  overflow: hidden;
}
.split-cta__illustration-tile {
  background: var(--color-illustration-bg);
  filter: brightness(1);
}
/* Same hover-glow MECHANISM as .square:hover (plain per-tile :hover, not
   triggered off an ancestor — decorative only, no cursor change), but
   NOT the same brightness value. .square's tiles are light colors
   (tan/cream/red) where a 4% bump is plainly visible; this tile is
   near-black (--color-illustration-bg, ~rgb(14,19,22)) — the SAME 4%
   only shifts each channel by about 1 out of 255, invisible regardless
   of what's covering it. 2.5 fixed that but read as "bizarre" (too
   washed out) — dialed back to a more moderate lift.

   Scoped to .is-visible:hover, not a bare :hover, and that's load-
   bearing, not stylistic: the shared reveal rule's
   ".split-cta__illustration-tile.is-visible { transition-delay: var(
   --reveal-delay, 0ms); }" (a SINGLE value, further down in this file)
   has the exact same specificity as a bare ":hover" rule (0,2,0 either
   way), so being LATER in the file let it win the whole property
   outright — meaning every tile's hover picked up its own REVEAL
   stagger delay (0/200/400/600ms) for its filter transition too, not
   just its opacity, which is exactly why the bottom two tiles (the
   larger delays) visibly lagged before brightening. Adding .is-visible
   to THIS selector (0,3,0) guarantees it wins regardless of source
   order, so the re-listed "filter ..., 0s" pair actually applies. */
.split-cta__illustration-tile.is-visible:hover {
  filter: brightness(1.7);
  transition: opacity 0.7s ease-out, filter 0.6s cubic-bezier(0.25, 1, 0.5, 1);
  transition-delay: var(--reveal-delay, 0ms), 0s;
}
/* Section 3 Artwork.png is portrait or (2428x3132, roughly 3:4) —
   noticeably taller than this box's own 4:3 ratio, so sizing it to a
   width narrower than the box (not 100%) and letting height:auto follow
   its natural ratio is what produces the "inset on the sides, bleeds
   out the bottom" look in the mockup, rather than the image exactly
   filling the box on every edge. Reveal chain, step 2 (see
   initSplitCtaReveal() in main.js): starts hidden, and is toggled
   .is-visible once ALL 4 tiles above (step 1) finish their own opacity
   transition — not on its own scroll trigger.

   The -90px lift lives HERE (not on .split-cta__illustration-wrap) —
   the artwork and its bg box are separate entities: this element alone
   is "the illust," and every "move it up" request should only ever
   touch this rule. It's folded into the SAME transform as
   translateX(-50%) (both here and in .is-visible below) rather than a
   separate declaration, since a 2nd `transform` rule would replace the
   first outright instead of composing with it — same class of bug hit
   repeatedly elsewhere in this file. The hidden state adds the reveal
   slide's +20px on top (-90 + 20 = -70px) so the slide-up-on-reveal
   motion still arrives at the same -90px resting position. */
.split-cta__illustration {
  position: absolute;
  top: 5%;
  left: 50%;
  /* Bumped repeatedly per follow-up feedback that it read as unchanged,
     then dialed back down after "massive": 72% -> 90% -> 80% -> 70% ->
     78% (a little larger per explicit follow-up). */
  width: 78%;
  height: auto;
  /* The artwork's own (mostly-transparent) bounding box sits ON TOP of
     the tiles underneath and, being taller than it is wide, covers
     nearly the entire box — without this, the cursor almost always
     lands on the artwork's img element first, so .split-cta__
     illustration-tile:hover practically never triggers except right at
     a bare corner. This is purely decorative (no click behavior of its
     own), so letting hover/click pass straight through to whatever's
     underneath is correct, not a regression. */
  pointer-events: none;
  opacity: 0;
  transform: translateX(-50%) translateY(-70px);
  /* transform gets its own longer duration + a strongly-decelerating
     curve ("easeOutExpo," same one used for the nav dropdown's own
     retract) per explicit request — reads as genuinely slowing into a
     smooth stop approaching the end of the slide, not just a flat
     ease-out. opacity stays quick/plain since it's not the motion being
     asked about. */
  transition: opacity 0.45s ease-out, transform 0.75s cubic-bezier(0.16, 1, 0.3, 1);
}
.split-cta__illustration.is-visible {
  opacity: 1;
  transform: translateX(-50%) translateY(-90px);
}
/* <div>s, not <img>s (see the HTML comment above these) — mask-image
   (the same PNG, used purely as an alpha stencil) + background-color is
   the only reliable way to recolor a raster asset to an arbitrary exact
   color; a plain <img>'s own pixel colors can't be overridden by CSS.
   Same technique as .intro-splash__asterisk--2/3/4. aspect-ratio
   (184/200, the source PNG's real proportions) stands in for the old
   <img>'s own height:auto — a masked div has no intrinsic size of its
   own to auto-size from. */
/* Position + the one-shot scroll-triggered pop-in live here; see the
   HTML comment above these for why the pop-in and the continuous spin
   below are split across two elements. Starts hidden — initSplitCtaReveal()
   in main.js adds .is-visible in sync with the illustration itself
   (large first, small chasing ~180ms behind — see
   SPLIT_CTA_ASTERISK_STAGGER_MS), same trigger tile, same reset-on-
   scroll-away behavior as the illustration/text lines. */
.split-cta__illustration-asterisk-wrap {
  position: absolute;
  opacity: 0;
}
.split-cta__illustration-asterisk-wrap.is-visible {
  animation: hero-asterisk-intro 1.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Bigger per explicit request — roughly 1.5x each (22->34, 36->56). */
.split-cta__illustration-asterisk-wrap--small {
  width: 34px;
  bottom: 6%;
  right: 8%;
}
.split-cta__illustration-asterisk-wrap--large {
  width: 56px;
  top: 8%;
  left: 8%;
}
.split-cta__illustration-asterisk {
  /* Brought back per explicit follow-up request — was display:none for
     a while, kept in the HTML rather than removed specifically so this
     was a one-line revert. */
  display: block;
  width: 100%;
  aspect-ratio: 184 / 200;
  animation: hero-asterisk-spin 8s linear infinite;
  -webkit-mask-image: url("../assets/images/Asterisk - Default.png");
  mask-image: url("../assets/images/Asterisk - Default.png");
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
  /* Beige (--color-tan), not the asset's own native cream, per request. */
  background-color: var(--color-tan);
}


/* ===================================================================
   Publications
=================================================================== */
.publications {
  /* No margin — main's own gap handles spacing to its neighbors. */
  text-align: center;
}
.publications__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 24px;
  /* Horizontal-ONLY margin per explicit request — matches the ~51px gap
     between .split-cta__illustration-wrap's own right edge and the
     viewport edge (measured directly: 40px added here + the page's own
     base 10px padding from body = ~51px total, same as section 3's).
     Deliberately more generous than the bare 10px page margin every
     other non-full-bleed section uses. Cards shrink a little as a
     result (3 columns now sharing 80px less width) — an accepted,
     anticipated side effect, not a bug.
     0 top/bottom — the `32px 40px` shorthand this used to be also added
     an (unintended) 32px top margin ON TOP of main's own 60px gap above
     this section (.publications itself deliberately carries no margin
     of its own — see its comment — specifically so main's gap is the
     ONLY thing spacing it from its neighbors), stacking into a visibly
     larger-than-everywhere-else 92px gap above the cards. */
  margin: 0 40px;
}
/* Containing block for the colored reveal below. */
.publication-card {
  position: relative;
}
/* Colored rectangle behind the cover, in the EXACT dimensions/radius/
   position of the cover's own resting state (not the lifted one) — as
   the cover lifts on hover (see .publication-card__cover:hover), this
   stays put and peeks out from underneath, instead of a blurred drop
   shadow. Has to live on the ARTICLE, not the cover itself: the cover is
   what actually transforms, so a pseudo-element on IT would lift away
   together with it and never get left behind to be revealed. Order
   matches the 3 cards left-to-right: red, blue (--color-grey — this
   project's informal name for it, no true blue exists), beige
   (--color-tan). */
.publication-card::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  aspect-ratio: 1672 / 2160;
  border-radius: 15px;
  opacity: 0;
  transition: opacity 0.2s ease-out;
  pointer-events: none;
}
.publication-card:hover::before {
  opacity: 1;
}
.publication-card:nth-child(1)::before { background: var(--color-red); }
.publication-card:nth-child(2)::before { background: var(--color-grey); }
.publication-card:nth-child(3)::before { background: var(--color-tan); }
.publication-card__cover {
  /* Matches the real cover art's OWN pixel dimensions (1672 x 2160 —
     Cover_ Volume 1/2.png) exactly, rather than a rounder but only
     approximate ratio like 3/4 or 4/5 — with the box and the image at
     the same proportions, object-fit:cover below never actually needs
     to crop anything, so nothing at the carefully-designed edges of the
     art gets cut off. (Cover_ Upcoming Volume.png is 1664 x 2156, a
     hair off this same ratio — imperceptible at this size, and it's a
     blank template with nothing meaningful near its edges anyway.) */
  aspect-ratio: 1672 / 2160;
  position: relative;
  overflow: hidden;
  /* Same rounding every section on the page uses (.square-grid,
     .featured-carousel). */
  border-radius: 15px;
  /* Fallback backing, in case an image is ever slow to load — no longer
     load-bearing for content the way it was for the old text-only
     placeholder (see the removed --placeholder variant this replaced). */
  background: var(--color-grey);
  /* Hover: lifts up — see .publication-card::before below for the
     colored rectangle revealed underneath instead of a drop shadow.
     0.2s, snappier than an earlier 0.4s pass per explicit follow-up.
     ease-out both directions (entering AND leaving decelerate into their
     resting state) per explicit request, not this page's usual
     asymmetric "slow in/fast out" hover convention used elsewhere. */
  transition: transform 0.2s ease-out;
}
.publication-card__cover:hover {
  transform: translateY(-12px);
}
.publication-card__cover-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* The upcoming volume's own cover doesn't exist yet — this is the real,
   blank cover template (border frame, no title/art) rather than a flat
   color box, with "Coming soon!" overlaid on top instead of baked into
   the (nonexistent) art. */
.publication-card__cover-caption {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* Same face/style/size a diamond button's own text uses (.diamond-cta
     > span:not(.arrow-glyph)) — Instrument Serif italic, --text-body. */
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--text-body);
  color: var(--color-cream);
}
/* Same size as body text elsewhere — was --text-small, a mismatch with
   every other paragraph of running text on the page. */
.publication-card__meta { margin-top: 12px; font-size: var(--text-body); }

/* Only the covers with a real, finished PDF get this (see data-pdf on the
   <article> itself in index.html/publications/index.html) — the "Coming
   soon!" card is deliberately left un-clickable, nothing to open yet. */
.publication-card--clickable {
  cursor: pointer;
}
.publication-card--clickable:focus-visible {
  outline: 2px solid var(--color-red);
  outline-offset: 4px;
  border-radius: 15px;
}

/* ===================================================================
   Edition lightbox — click a finished cover, flip through its PDF one
   page at a time. Built entirely in JS (initEditionLightbox() in
   main.js, same "generated, not hand-written" reasoning as the carousel
   indicators above) so this markup only has to exist once, not copied
   into every page that has a publication-card on it.
=================================================================== */
/* No opacity/transition of its OWN anymore — see .edition-lightbox__
   backdrop's own comment below for why a shared parent-level opacity
   fade (the original approach here) was the actual cause of the
   fade never visibly playing on real hardware, not a timing bug. */
.edition-lightbox {
  position: fixed;
  inset: 0;
  z-index: 10000;
  display: flex;
  align-items: center;
  justify-content: center;
  visibility: hidden;
  /* 0.4s, not 0.35s — matches the SLOWEST of the 3 independent child
     fades this now gates (backdrop 0.35s, nav/close 0.35s, but
     .edition-lightbox__frame's own scale+opacity entrance is 0.4s).
     Hiding visibility any sooner than the slowest one finishes would
     cut that child's own closing fade off mid-transition, since
     visibility:hidden stops the whole subtree from rendering at all,
     independent of whatever any child's own transition still has left. */
  transition: visibility 0s linear 0.4s;
}
.edition-lightbox.is-open {
  visibility: visible;
  /* 0s, explicitly — without restating this, the CLOSE transition's own
     0.35s delay above would otherwise still apply here (a later rule
     with an unset property doesn't reset it, it just doesn't touch it),
     making visibility flip to visible 0.35s LATE on open too. */
  transition: visibility 0s linear 0s;
}
/* Frosted-glass look — ONE flat translucent dark tint (no gradient —
   removed per earlier follow-up, a 2-stop gradient here read as an
   unwanted extra design decision rather than "just dark glass") plus a
   real backdrop blur of whatever page sits underneath. Kept quite low-
   opacity per earlier follow-up ("even more transparent") — the blur
   itself is what sells the glass read, not a heavy tint on top of it.
   -webkit- prefix still needed for Safari; unprefixed alone is silently
   ignored there, not just unsupported gracefully.
   Owns its OWN opacity fade now, rather than inheriting one from
   .edition-lightbox's own opacity (the original approach) — that meant
   THIS element's already-expensive full-viewport blur had to be
   recomposited every single frame ALONGSIDE the frame's separate scale+
   opacity entrance and the nav/close buttons' separate opacity entrance,
   since a parent's opacity multiplies through its whole subtree's
   render, not just this one element. Reported live as "no animation,
   just an abrupt appearance after a delay" in real (non-headless)
   Chrome specifically — a classic backdrop-filter-animation performance
   trap: the browser silently drops frames trying to re-blur + re-
   composite everything at once, then the FINAL frame is the only one
   that actually lands, reading as an instant snap. will-change:opacity
   hints the browser to promote this specific layer ahead of time so
   blending its alpha during the fade doesn't require re-running the
   expensive blur sample pass itself each frame. */
.edition-lightbox__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(24, 13, 10, 0.4);
  -webkit-backdrop-filter: blur(22px) saturate(140%);
  backdrop-filter: blur(22px) saturate(140%);
  opacity: 0;
  will-change: opacity;
  transition: opacity 0.35s cubic-bezier(0.25, 1, 0.5, 1);
}
.edition-lightbox.is-open .edition-lightbox__backdrop {
  opacity: 1;
}
.edition-lightbox__frame {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  max-width: 92vw;
  /* Own entrance, separate from the overlay's opacity fade above: a
     slight scale-up + fade reads as the page itself arriving, not just
     visibility toggling — same "slow in" easing this site already uses
     for every other reveal (mosaic-reveal, image-mosaic__caption, etc). */
  opacity: 0;
  transform: scale(0.96);
  transition: opacity 0.4s cubic-bezier(0.25, 1, 0.5, 1), transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}
.edition-lightbox.is-open .edition-lightbox__frame {
  opacity: 1;
  transform: scale(1);
}
.edition-lightbox__title {
  font-family: var(--font-body);
  font-size: var(--text-body);
  color: var(--color-cream);
  text-align: center;
  margin: 0;
}
.edition-lightbox__page-wrap {
  position: relative;
  /* Skeleton and canvas are stacked in the SAME grid cell (both explicit
     grid-column:1/grid-row:1 below) so they can crossfade in place
     without needing matching absolute-positioning math — whichever one
     is opacity:0 at a given moment just doesn't affect layout, since
     grid still reserves the cell for both. */
  display: grid;
}
/* Click-to-zoom (setZoomed() in main.js) — per explicit request, fills
   the ENTIRE screen edge-to-edge, not just the same 82vw/74vh window
   .edition-lightbox__canvas below normally fits within. Deliberately
   NOT position:fixed on this element to get there: .edition-lightbox
   __frame (this element's own parent) carries a `transform` for its own
   scale+opacity entrance (see that rule), and ANY transform value other
   than none makes an element the containing block for its own
   position:fixed descendants — confirmed live, a fixed-positioned wrap
   here actually sized/positioned itself relative to the (92vw-capped)
   FRAME, not the true viewport, undermining the whole point. Growing
   this element to a real 100vw/100vh IN NORMAL FLOW instead, with the
   frame's own max-width raised to match (see .edition-lightbox.is-zoomed
   .edition-lightbox__frame below) and title/page-count removed from that
   flow entirely (display:none, same rule) sidesteps the containing-block
   question altogether — nothing here is fixed-positioned, so no
   ancestor's transform can retarget it. Close/prev/next stay reachable
   on top regardless, since they're positioned against .edition-lightbox
   itself (z-index:2 there), a sibling of .edition-lightbox__frame
   (z-index:1), not a descendant of it. overscroll-behavior keeps a
   scroll/drag that hits an edge from chaining through to the page
   underneath the (already scroll-locked) overlay. touch-action:none
   hands touch-drag panning entirely to the same JS pointer handlers
   driving mouse-drag (see onCanvasPointerMove() in main.js) instead of
   letting the browser's own native touch-scroll ALSO try to claim the
   same gesture. */
.edition-lightbox__page-wrap.is-zoomed {
  width: 100vw;
  height: 100vh;
  max-width: 100vw;
  max-height: 100vh;
  overflow: auto;
  overscroll-behavior: contain;
  touch-action: none;
}
/* Lets the page-wrap rule above actually reach a full 100vw — this
   element's own max-width:92vw (see its base rule) would otherwise still
   clip it, even though the wrap itself isn't position:fixed and so isn't
   escaping normal flow/clipping the way a fixed element would. Title/
   page-count hiding moved further down (see the comment there for why
   it has to come AFTER the fullscreen-mode block specifically). */
.edition-lightbox.is-zoomed .edition-lightbox__frame {
  max-width: 100vw;
}
/* Subtle contact shadow (not the site's usual soft/wide ambient shadow —
   this needs to read as a thin dark HALO right at each button's own
   edge, since it has to keep contrasting against WHATEVER busy part of
   the zoomed page happens to sit behind it, not just a plain backdrop)
   per explicit request: close/prev/next can otherwise disappear against
   a light or cream-heavy region of the page once it fills the whole
   screen behind them. filter:drop-shadow, not box-shadow — these are
   rotated 45deg diamonds with real transparent corners outside the
   diamond itself (see .edition-lightbox__nav/__close's own comments);
   box-shadow would draw a rectangular shadow around that whole invisible
   bounding box, while drop-shadow hugs the actual rendered (diamond +
   arrow/×/corners) alpha shape. Also applies in is-fullscreen, not just
   is-zoomed — that's the state this matters most for (a full-bleed page
   behind these buttons is the normal case there, not an occasional one). */
.edition-lightbox.is-zoomed .edition-lightbox__nav,
.edition-lightbox.is-zoomed .edition-lightbox__close,
.edition-lightbox.is-zoomed .edition-lightbox__fullscreen,
.edition-lightbox.is-fullscreen .edition-lightbox__nav,
.edition-lightbox.is-fullscreen .edition-lightbox__close,
.edition-lightbox.is-fullscreen .edition-lightbox__fullscreen {
  filter: drop-shadow(0 1px 5px rgba(0, 0, 0, 0.55));
}
.edition-lightbox__skeleton,
.edition-lightbox__canvas {
  grid-column: 1;
  grid-row: 1;
}
.edition-lightbox__canvas {
  display: block;
  max-width: 82vw;
  max-height: 74vh;
  width: auto;
  height: auto;
  /* Reads as a lifted page, same instinct as .publication-card__cover's
     own hover lift, just always-on here since there's no resting state
     to lift FROM inside the lightbox. Mostly vertical offset with a
     tighter blur (was 20px/60px) — per explicit request to angle it
     more downward, like an overhead light, rather than the softer
     ambient-from-all-sides look the wider blur read as. */
  box-shadow: 0 40px 45px rgba(0, 0, 0, 0.45);
  background: var(--color-cream);
  opacity: 0;
  transition: opacity 0.3s ease-out;
  cursor: zoom-in;
}
.edition-lightbox__page-wrap.is-ready .edition-lightbox__canvas {
  opacity: 1;
}
/* is-zoomed's own width/height comes from an explicit inline px style
   (set in main.js, off the canvas's own just-before-zoom fitted size —
   see setZoomed()'s own comment for why that has to be real width/
   height, not a transform: scale()) — max-width/max-height:none here is
   what lets that inline size actually take effect instead of staying
   clamped back down to this rule's own 82vw/74vh. */
.edition-lightbox__page-wrap.is-zoomed .edition-lightbox__canvas {
  max-width: none;
  max-height: none;
}
/* Placeholder shown only until the FIRST page of a freshly-opened
   edition has actually rendered (see initEditionLightbox()'s own
   comment on why later page-to-page navigation doesn't need this) —
   same box dimensions/shadow as the real canvas (1672:2160 is the same
   cover-art ratio .publication-card__cover already uses) so there's no
   layout jump once the swap happens, just a crossfade. width uses
   min() rather than the canvas's own max-width/max-height + auto trick
   — that trick relies on a REPLACED element's own intrinsic size to
   resolve "auto", which a plain div has none of. */
.edition-lightbox__skeleton {
  width: min(82vw, calc(74vh * 0.7741));
  aspect-ratio: 1672 / 2160;
  /* Same downward-angled shadow as the real canvas (see its own comment)
     — matched deliberately so the crossfade between them doesn't also
     visibly shift the shadow underneath. */
  box-shadow: 0 40px 45px rgba(0, 0, 0, 0.45);
  background: linear-gradient(100deg, var(--color-tan) 30%, var(--color-cream) 50%, var(--color-tan) 70%);
  background-size: 200% 100%;
  animation: edition-lightbox-shimmer 1.6s ease-in-out infinite;
  transition: opacity 0.3s ease-out;
}
.edition-lightbox__page-wrap.is-ready .edition-lightbox__skeleton {
  opacity: 0;
  pointer-events: none;
}
@keyframes edition-lightbox-shimmer {
  0% { background-position: 150% 0; }
  100% { background-position: -150% 0; }
}
.edition-lightbox__page-count {
  font-family: var(--font-body);
  font-size: var(--text-small);
  color: var(--color-cream);
  margin: 0;
  /* Reserves this line's own height even while its text is empty (see
     openLightbox() in main.js: it's blanked out while a fresh edition's
     PDF is still loading, covered by the shimmer skeleton instead) —
     without this, an EMPTY paragraph collapses to less height than a
     populated one, so the moment the page count text actually appears,
     .edition-lightbox__frame's total height grows, and since the whole
     frame is centered as a unit inside .edition-lightbox (align-items:
     center), a taller frame re-centers around the SAME midpoint, moving
     its OWN top edge — and everything above the page count, including
     the skeleton/PDF itself — up by roughly half that height gain.
     Reported live as the PDF "bumping up" right as it finishes loading;
     this was really that recentering, not anything about the PDF/
     skeleton crossfade itself. Matches the line-height this text
     actually renders at once populated (1.4, inherited from body). */
  min-height: calc(var(--text-small) * 1.4);
}
/* Prev/next/close all reuse the site's actual .diamond-cta shape (see
   that class + .diamond-cta--tight/.arrow-glyph in the "canonical
   diamond CTA" block above) — same rotated-square outline — but WITHOUT
   .diamond-cta--fill-on-hover: per explicit request these stay pure
   stroke, no solid fill on hover, just the border/glyph color itself
   swapping for feedback. Cream by default, not the shared tan (per
   explicit follow-up) — hover swaps to tan, the reverse pairing, so
   there's still a visible state change without introducing a 3rd color.
   Positioned fixed to the actual SCREEN edges (not the frame/canvas) at
   --page-margin PLUS the .publications__grid section's own extra 40px
   margin (see that class up above) — per explicit follow-up asking for
   these "closer together," using that specific section's own margin
   instead of the bare page-wide 10px this used at first. Direct children
   of .edition-lightbox (not the frame or page-wrap) specifically so this
   positioning is relative to the whole viewport-covering box, not the
   frame's own (narrower, page-width) content column. */
.edition-lightbox__nav,
.edition-lightbox__close,
.edition-lightbox__fullscreen {
  /* Real <button>s, not <a>/<div> like every other existing .diamond-cta
     instance except .featured-carousel__nav — same native-chrome reset
     that one already needed (see its own comment): without this, the
     browser's own default button background/padding shows through as a
     visible fill INSIDE the diamond's border, which is exactly the
     "shouldn't have fills" bug this was meant to fix, not a leftover
     from the removed .diamond-cta--fill-on-hover class. */
  appearance: none;
  background: none;
  padding: 0;
  font: inherit;
  position: absolute;
  z-index: 2;
  margin-top: 0;
  cursor: pointer;
  border-color: var(--color-cream);
  /* Difference blend, not the plain cream this used to be alone — per
     explicit request, so the outline stays visible against ANY page
     color behind it (a light/cream-heavy PDF page especially, once
     is-zoomed/is-fullscreen puts the actual page directly behind these,
     not just the dark blurred backdrop — see the drop-shadow halo just
     below, which this complements rather than replaces: the halo helps
     against BUSY/textured content, this handles flat-color content the
     halo alone can't). Reset to normal on :hover below — once hovered,
     the diamond fills a SOLID opaque cream with a dark icon (see that
     rule), which is already self-contained contrast that doesn't depend
     on whatever's behind it; blending a solid near-white fill would
     invert it toward invisible against a light page, undoing that. */
  mix-blend-mode: difference;
  transition: border-color 0.2s ease-out, background-color 0.2s ease-out, opacity 0.35s cubic-bezier(0.25, 1, 0.5, 1);
  /* Own fade-in, synced with the frame's above rather than instant —
     otherwise these pop in ahead of the page itself since they aren't
     nested inside .edition-lightbox__frame's own opacity transition. */
  opacity: 0;
}
.edition-lightbox.is-open .edition-lightbox__nav,
.edition-lightbox.is-open .edition-lightbox__close,
.edition-lightbox.is-open .edition-lightbox__fullscreen {
  opacity: 1;
}
.edition-lightbox__nav {
  top: 50%;
  transform: translateY(-50%) rotate(45deg);
}
.edition-lightbox.is-open .edition-lightbox__nav {
  /* Restates the same transform as above — opacity is the only thing
     actually animating, but the transition shorthand on this element
     also lists transform, and leaving it unset here would tween FROM
     whatever .edition-lightbox__nav's own base rule left it at, which
     is identical anyway; stated explicitly so that stays true even if
     the base rule's transform value ever changes later. */
  transform: translateY(-50%) rotate(45deg);
}
/* --page-margin + 40px, NOT a plain var(--page-margin) (see this whole
   block's own intro comment on the "closer together" follow-up) — PLUS
   7.46px more, for a separate, unrelated reason: .diamond-cta rotates
   this 45deg around its own center, so the visible DIAMOND's leftmost/
   rightmost point sits further in than an unrotated box's edge would.
   Rotating a 36px (.diamond-cta--tight) square 45deg expands its
   bounding box to 36*sqrt(2)≈50.9px, symmetric around the same center,
   so the visible tip is (50.9-36)/2≈7.46px further from that center than
   the unrotated box's own edge was. Confirmed against a real
   getBoundingClientRect() measurement earlier: a bare inset value here
   alone lands the visible DIAMOND noticeably closer to the edge than
   intended — adding this back is what makes the diamond's actual point,
   not its invisible unrotated bounding box, land at the intended inset. */
.edition-lightbox__nav--prev {
  left: calc(var(--page-margin) + 40px + 7.46px);
}
.edition-lightbox__nav--next {
  right: calc(var(--page-margin) + 40px + 7.46px);
}
.edition-lightbox__nav:disabled {
  opacity: 0.35;
  cursor: default;
}
/* Arrow art points right by default (see .arrow-glyph's own comment) —
   same flip .featured-carousel__nav--prev already uses for the same
   reason. */
.edition-lightbox__nav--prev > .arrow-glyph {
  transform: rotate(-45deg) scaleX(-1);
}
/* A true CREAM arrow, not another beige/tan PNG swapped in — none of the
   4 uploaded arrow files are actually cream (see .arrow-glyph's own
   misnaming note), so this reuses the exact same mask-image trick
   .split-cta__diamond-arrow-cream already established elsewhere: the
   arrow PNG supplies only its ALPHA shape as a mask, and a real
   background-color paints through it. Switches to brown on hover (see
   .edition-lightbox__nav:hover below) once the diamond itself fills
   cream — cream-on-cream would disappear entirely. */
.edition-lightbox__nav-arrow {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  -webkit-mask-image: url("../assets/images/Arrow, blue.png");
  mask-image: url("../assets/images/Arrow, blue.png");
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  background-color: var(--color-cream);
  /* Same difference-blend visibility trick as the diamond outline itself
     (see .edition-lightbox__nav/__close/__fullscreen's own comment) —
     the icon needs it independently since it's a separate painted layer,
     not something the parent's blend-mode carries down automatically.
     Reset to normal on hover below, matching the parent's own reset. */
  mix-blend-mode: difference;
  transition: background-color 0.2s ease-out;
}
.edition-lightbox__nav:hover:not(:disabled) .edition-lightbox__nav-arrow {
  background-color: var(--color-dark);
  mix-blend-mode: normal;
}
/* Hover: the DIAMOND fills cream, the ICON (arrow/×) switches to brown
   — per explicit follow-up, the reverse pairing of an earlier pass that
   filled the diamond brown and left the icon cream throughout. Border
   also cream so the diamond reads as one solid filled shape rather than
   a fill peeking out past a differently-colored outline. */
.edition-lightbox__nav:hover:not(:disabled),
.edition-lightbox__close:hover,
.edition-lightbox__fullscreen:hover {
  background-color: var(--color-cream);
  border-color: var(--color-cream);
  /* Undoes the resting state's difference blend (see that rule's own
     comment) — once filled solid cream + a dark icon, this diamond is
     already opaque and self-contrasting, so blending it would invert the
     fill toward invisible against a light page instead of helping. */
  mix-blend-mode: normal;
}
/* Same --page-margin + 40px + rotation-compensation as the nav diamonds
   above. */
.edition-lightbox__close {
  top: calc(var(--page-margin) + 40px + 7.46px);
  right: calc(var(--page-margin) + 40px + 7.46px);
}
/* The × is built from 2 crossed bars (::before/::after), not the "×"
   text glyph this used at first — a font character has almost no
   visual weight next to the nav arrows' own solid mask artwork, which
   is exactly the "different stroke" mismatch reported live. Measured
   the actual arrow source art (Arrow, blue.png, 66x42) to match: its
   shaft is ~6px thick within a 42px-tall box, i.e. ~14% of the glyph's
   own height — at this span's 16px size, that's ~2px, hence the bars
   below. This span still ALSO matches the shared ".diamond-cta >
   span:not(.arrow-glyph)" rule above (still a plain span, still not
   .arrow-glyph) — that's what supplies its rotate(-45deg) counter-
   rotation, needed so the two bars (each further rotated their own
   +/-45deg below) net out to a proper X instead of a "+". That shared
   rule's OWN font-size/color/font-style no longer matter now that this
   span holds no text (font-size:0) and the bars paint via their own
   explicit background-color, not the span's `color` — so unlike the
   earlier text-glyph version, this one doesn't need matching
   specificity to win a fight over those, only the transform. */
.edition-lightbox__close-glyph {
  position: relative;
  display: block;
  width: 16px;
  height: 16px;
  /* Overrides the shared rule's own font-family/font-size/line-height —
     none of those matter now that this span holds no text, just 2
     positioned bars. */
  font-size: 0;
}
.edition-lightbox__close-glyph::before,
.edition-lightbox__close-glyph::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 16px;
  height: 2px;
  background-color: var(--color-cream);
  /* Same difference-blend visibility trick as the diamond outline itself
     (see .edition-lightbox__nav/__close/__fullscreen's own comment). */
  mix-blend-mode: difference;
  transition: background-color 0.2s ease-out;
}
.edition-lightbox__close-glyph::before {
  transform: translate(-50%, -50%) rotate(45deg);
}
.edition-lightbox__close-glyph::after {
  transform: translate(-50%, -50%) rotate(-45deg);
}
/* Same brown switch as the nav arrows — cream-on-cream would otherwise
   disappear once the diamond itself fills cream on hover. */
.edition-lightbox__close:hover .edition-lightbox__close-glyph::before,
.edition-lightbox__close:hover .edition-lightbox__close-glyph::after {
  background-color: var(--color-dark);
  mix-blend-mode: normal;
}
/* Fullscreen toggle — mirrors .edition-lightbox__close's own top-RIGHT
   inset onto the opposite top-LEFT corner (same --page-margin + 40px +
   rotation-compensation math, see that rule's own comment for why the
   extra 7.46px). Icon built the same way the nav arrows already are
   (mask-image supplies an alpha shape, background-color paints through
   it — see .edition-lightbox__nav-arrow's own comment) rather than a
   new PNG asset: an inline SVG data URI needs no separate file and
   costs nothing extra to add a 2nd (exit) shape alongside it. */
.edition-lightbox__fullscreen {
  top: calc(var(--page-margin) + 40px + 7.46px);
  left: calc(var(--page-margin) + 40px + 7.46px);
}
.edition-lightbox__fullscreen-icon {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* Counter-rotates the diamond's own 45deg (see .edition-lightbox__nav
     's shared base rule) so the icon reads upright, same reasoning as
     .edition-lightbox__nav--prev's own scaleX(-1) counter-flip. */
  transform: rotate(-45deg);
  -webkit-mask-size: 60%;
  mask-size: 60%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  background-color: var(--color-cream);
  /* Same difference-blend visibility trick as the diamond outline itself
     (see .edition-lightbox__nav/__close/__fullscreen's own comment). */
  mix-blend-mode: difference;
  transition: background-color 0.2s ease-out;
}
/* 4 corner brackets, vertex AT each corner — the universal "expand to
   fill the screen" shape (same read as a camera viewfinder's own
   corners). */
.edition-lightbox__fullscreen-icon--enter {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='square'%3E%3Cpath d='M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='square'%3E%3Cpath d='M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5'/%3E%3C/svg%3E");
}
/* Same 4 brackets, each translated 5 units in toward the center — the
   mirror-image "shrink back down" shape once already fullscreen. */
.edition-lightbox__fullscreen-icon--exit {
  display: none;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='square'%3E%3Cpath d='M9 14V9h5M10 9h5v5M15 10v5h-5M14 15H9v-5'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='square'%3E%3Cpath d='M9 14V9h5M10 9h5v5M15 10v5h-5M14 15H9v-5'/%3E%3C/svg%3E");
}
.edition-lightbox.is-fullscreen .edition-lightbox__fullscreen-icon--enter {
  display: none;
}
.edition-lightbox.is-fullscreen .edition-lightbox__fullscreen-icon--exit {
  display: block;
}
/* Same brown switch as the nav arrows/close × — cream-on-cream would
   otherwise disappear once the diamond itself fills cream on hover. */
.edition-lightbox__fullscreen:hover .edition-lightbox__fullscreen-icon {
  background-color: var(--color-dark);
  mix-blend-mode: normal;
}
/* ===================================================================
   Fullscreen MODE (persists across page turns — unlike is-zoomed, which
   setZoomed() deliberately resets on every goToPage()/open/close, see
   its own comments) — per explicit request: each page fills the real
   screen (requestFullscreen() on THIS element, not just a CSS-only
   imitation — see the fullscreenBtn click handler in main.js — so the
   browser's own chrome gets out of the way too, not just our overlay's
   82vw/74vh window), with the existing close/prev/next/fullscreen-exit
   controls still reachable ON TOP of it, per explicit request.
=================================================================== */
.edition-lightbox.is-fullscreen .edition-lightbox__frame {
  max-width: 100vw;
}
/* Raising the SAME max-width/max-height .edition-lightbox__canvas
   already fits within (82vw/74vh normally) to a full 100vw/100vh —
   width:auto/height:auto (that rule's own base values, untouched here)
   already do all the "shrink to fit, preserve aspect ratio" math as a
   plain replaced-element contain-fit against whatever max-width/
   max-height it's given, same as an <img>'s own intrinsic-ratio
   sizing — no JS measurement needed, and it re-fits itself for free on
   any window resize since it's live CSS, not a one-time computed px
   value (contrast setZoomed()'s own inline width/height, which
   deliberately IS a one-time px snapshot instead, for the different
   reasons given in that function's own comment). */
.edition-lightbox.is-fullscreen .edition-lightbox__canvas {
  max-width: 100vw;
  max-height: 100vh;
}
/* Title/page-count stay VISIBLE in fullscreen (per explicit request:
   "ui over them," not gone) — but pulled out of the frame's own flex
   flow into an absolutely-positioned HUD instead of leaving them as
   flex siblings, which would keep reserving their own height + the
   flex gap alongside the canvas, eating into the 100vh it just gained
   above. Centered the same way .edition-lightbox__title/page-count
   already read (this frame's own align-items:center normally does
   that job; position:absolute opts back out of it, so it has to be
   restated by hand here). z-index above the canvas (that element's own
   stacking context is otherwise unordered relative to this) so the HUD
   text stays legible over it rather than sitting behind it. */
.edition-lightbox.is-fullscreen .edition-lightbox__title,
.edition-lightbox.is-fullscreen .edition-lightbox__page-count {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1;
  margin: 0;
  /* A portrait page fills the full 100vh with no letterboxing above/
     below it (only the sides letterbox — see .edition-lightbox__canvas
     above) confirmed live: this HUD text lands directly on top of
     whatever the page itself drew at that exact spot, not on a plain
     backdrop, same "needs to keep contrasting against whatever's behind
     it" problem the close/nav/fullscreen buttons already have their own
     drop-shadow for (see that rule's own comment) — same fix, just
     text-shadow instead of filter:drop-shadow since there's no
     transparent-cutout diamond shape here to hug. */
  text-shadow: 0 1px 5px rgba(0, 0, 0, 0.55);
}
.edition-lightbox.is-fullscreen .edition-lightbox__title {
  top: calc(var(--page-margin) + 40px);
}
.edition-lightbox.is-fullscreen .edition-lightbox__page-count {
  bottom: calc(var(--page-margin) + 40px);
}
/* Auto-hide (armIdleTimer()/clearIdleTimer() in main.js, fullscreen-only
   — see that function's own guard) per explicit request: once there's
   been no cursor activity for a few seconds, the controls AND the title/
   page-count HUD fade out, reading as "looking at the page itself," not
   a viewer's chrome sitting permanently on top of it — any pointer
   movement (or a fresh tap/click) fades them back in. pointer-events:
   none while faded, not just opacity:0, so an invisible button can't
   still eat the FIRST click of "move the mouse back" (which would
   otherwise both reveal the UI and silently activate whatever sat under
   the cursor, one click doing two unrelated things). Extra specificity
   from the 3rd class (.is-idle, on top of .edition-lightbox.is-fullscreen
   already on every other rule here) is what lets this win over the
   plain .edition-lightbox.is-open opacity:1 rules further up without
   needing !important. */
.edition-lightbox.is-fullscreen.is-idle .edition-lightbox__nav,
.edition-lightbox.is-fullscreen.is-idle .edition-lightbox__close,
.edition-lightbox.is-fullscreen.is-idle .edition-lightbox__fullscreen,
.edition-lightbox.is-fullscreen.is-idle .edition-lightbox__title,
.edition-lightbox.is-fullscreen.is-idle .edition-lightbox__page-count {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.6s ease-out;
}
/* Click-to-zoom still works WHILE fullscreen (setZoomed() just measures
   whatever the canvas's own current fitted size happens to be at that
   moment, fullscreen or not) — but the two states fighting over
   title/page-count (is-zoomed wants them hidden entirely; the
   fullscreen HUD rules above want them visible) needs one deliberate
   winner. Equal specificity either way (2 classes + 1 element each), so
   it comes down to source order: this rule has to stay AFTER the
   fullscreen block above, not before, so zooming in temporarily hides
   the HUD even while already fullscreen, then has it reappear once
   zoom toggles back off (fullscreen itself never left). Title/
   page-count go to display:none rather than just opacity:0: this frame
   is a flex column (gap:14px between each child) — invisible-but-still-
   in-flow siblings would keep reserving their own height/gap alongside
   the now-fullscreen wrap, visibly shrinking it and/or pushing it off-
   center instead of the wrap alone filling the whole frame. */
.edition-lightbox.is-zoomed .edition-lightbox__title,
.edition-lightbox.is-zoomed .edition-lightbox__page-count {
  display: none;
}
/* Scroll lock behind the lightbox — same idea as .nav-dim-overlay's own
   dimming, just for page scroll instead of a click-catcher. */
body.edition-lightbox-open {
  overflow: hidden;
}
/* Large diamond button (same size/shape as section 1's "Learn more"),
   replacing the old plain underlined text link. Centered via
   margin:auto (this is a block-level flex box — .publications' own
   text-align:center wouldn't center it, that only affects inline/
   inline-block content). 100px top gap — more than a bare gap would
   give, per explicit request, since this is a secondary CTA that
   shouldn't read as just another grid row. */
.publications__view-more {
  margin: 100px auto 0;
}

/* ===================================================================
   Quote block — now the "Slogan" banner image (was the plain text
   quote "Empowering voices previously unheard.", per explicit request
   to replace it with a graphic that has that same line baked into its
   own artwork). Composed of an evenly-sized tile grid layered over one
   uncut <img> — same "mask panels fade out to unveil the photo
   underneath" trick as .image-mosaic__mask (see that section's own
   comment), per explicit request that this assemble in tile-by-tile on
   scroll rather than fade in as one flat rectangle. The tiles
   themselves are generated at runtime by initQuoteBlockTiles() in
   main.js (one shared tile count instead of hand-duplicating the same
   mask <div>s across the 6 separate HTML files that all reuse this
   .quote-block markup) — see that function's own comment for the grid
   math. .quote-block__mask (NOT .quote-block itself) is what's in
   initRevealOnScroll()'s staggeredTargets list and the shared
   opacity-reveal selector list below, mirroring .image-mosaic__mask
   exactly: the section wrapper just always renders, its mask tiles do
   the actual revealing.
=================================================================== */
/* ===================================================================
   Team bios (About page, below the "Meet the Team" marquee)
   No fixed/tile-derived height anymore (that was aspect-ratio: 4/1 on
   .team-bio, matching one row of section 1's 4-column grid) — per
   explicit request, each row's height is now just whatever the photo
   itself renders at: .team-bio__photo-wrap gets aspect-ratio: 1/1
   (matching the source photos, all 1660x1660) at its own 28% flex-basis
   width, and that's the only thing driving the row's height. align-
   items: flex-start (not stretch) so the text column top-aligns with
   the photo instead of being forced to match its height — if a bio's
   body copy runs longer than the photo is tall, the row simply grows to
   fit it (text determines the row instead of the other way around). */
.team-bios {
  display: flex;
  flex-direction: column;
  gap: 48px;
}
.team-bio {
  display: flex;
  align-items: flex-start;
  gap: 48px;
}
.team-bio__photo-wrap {
  flex: 0 0 28%;
  aspect-ratio: 1 / 1;
  border-radius: 15px;
  overflow: hidden;
}
.team-bio__photo {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.team-bio__text {
  flex: 1;
  min-width: 0;
  padding: 24px 48px 24px 0;
}
.team-bio__name {
  font-family: var(--font-display);
  font-size: 34px;
  font-weight: 400;
  margin: 0 0 4px;
}
.team-bio__role {
  font-family: var(--font-body);
  font-size: var(--text-body);
  margin: 0 0 16px;
}
.team-bio__body {
  font-family: var(--font-body);
  /* 20px, matching .hero__eyebrow's own size — deliberately smaller than
     --text-body per explicit request, to fix the overflow the previous
     size caused (see this section's earlier comment above .team-bios in
     the HTML). */
  font-size: 20px;
  margin: 0 0 12px;
}
.team-bio__body:last-child {
  margin-bottom: 0;
}
/* About page's exploratory swap-in for .quote-block right above (see
   the HTML comment there) — a plain, roomy section instead of the
   photo + mosaic-reveal treatment: no image, just centered body text
   with generous top/bottom breathing room. */
.slogan-preview {
  padding: 160px 0;
  display: flex;
  justify-content: center;
  text-align: center;
}
.slogan-preview__text {
  margin: 0;
}
/* Same size as .hero__eyebrow (20px) per explicit request — overrides
   span.split-cta__text-line's own font-size (var(--text-body)) at equal
   specificity via the extra .slogan-preview ancestor, so this doesn't
   need to fight that shared rule's own tag+class selector. Each line is
   its own .split-cta__text-line span (same class index.html's section 3
   uses for its own line-by-line reveal) purely for the reveal mechanics
   — fade+slide-up, staggered via that class's own nth-child rules (0ms/
   90ms) — not for its size, which this overrides. Registered in
   initRevealOnScroll()'s staggeredTargets (main.js) so checkAll() adds
   is-visible to these like any other scroll-tracked element; --reveal-
   delay still gets set on them by that generic per-sibling loop but goes
   unused here since .split-cta__text-line's own transition-delay is a
   flat 0s overridden only by its nth-child rules, not var(--reveal-
   delay). */
.slogan-preview .split-cta__text-line {
  font-size: 20px;
}
.quote-block {
  position: relative;
  text-align: center;
  /* No max-width (was 700px, sized for a short line of text). Previously
     matched .publications__grid's own narrower 50px-inset width (an
     extra 40px on top of body's own --page-margin); per explicit
     follow-up request, widened again to align with most other
     sections instead — e.g. the footer (.site-footer, via
     .square-grid), which carries no horizontal margin of its own and
     so just sits at body's plain 10px --page-margin. Only the
     vertical 60px top margin is this section's own. */
  margin: 60px 0 0;
}
/* Wraps the photo + its mask grid — needs an explicit size (via
   aspect-ratio, since its children are position:absolute and can no
   longer size it from their own intrinsic dimensions the way a plain
   <img> could) locked to Slogan.jpg's own real 3600x1837 proportions,
   so object-fit:cover on the photo below never has to crop it. */
.quote-block__image {
  position: relative;
  overflow: hidden;
  border-radius: 15px;
  aspect-ratio: 3600 / 1837;
  min-width: 0;
}
.quote-block__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* Grid math lives with initQuoteBlockTiles() in main.js (it sets
   grid-template-columns/rows inline, sized to match that function's own
   tile count) — no fixed column/row count here so the two can't drift
   out of sync with each other. */
.quote-block__masks {
  position: absolute;
  inset: 0;
  display: grid;
}
.quote-block__mask {
  background: var(--color-cream);
}
/* Same faint hover glow as .image-mosaic__hover-tile/
   .featured-carousel__hover-tile — per-cell white wash on the photo
   itself, since a tiled image reads as clickable/interactive squares the
   same way .square already does elsewhere on the site. Sits above the
   mask grid (z-index) so it's hoverable immediately once a tile's own
   mask has faded — .quote-block__overlay's own z-index:3 (below) keeps
   the caption card itself above THIS layer, same reasoning as
   .image-mosaic__caption's own comment. */
.quote-block__hover-tiles {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: grid;
}
.quote-block__hover-tile {
  background: rgba(255, 255, 255, 0);
  transition: background-color 0.3s ease-out;
}
.quote-block__hover-tile:hover {
  background: rgba(255, 255, 255, 0.14);
  transition: background-color 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Centers the text card over the Slogan artwork without stretching it —
   this wrapper spans the full image so it can center its child on both
   axes, but carries no size/background of its own (see
   .quote-block__overlay-text for the actual visible rectangle, sized to
   its own content). Carries the generic .mosaic-reveal class (in the
   HTML) instead of its own bespoke opacity/is-visible rule — the tile
   grid now assembles at its own pace (however long the mask cascade
   actually takes to scroll into view, not a fixed guess), so
   initQuoteBlockReveal() in main.js uses the same revealWithTile()
   helper initMosaicReveal() does, firing this once the LAST mask tile
   genuinely finishes its own transition instead of a hardcoded delay
   layered on top of .quote-block's own (now nonexistent — see above)
   whole-image fade. */
.quote-block__overlay {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.quote-block__overlay-text {
  margin: 0;
  padding: 28px 40px;
  background: var(--color-cream);
  color: var(--color-dark);
  border-radius: 0;
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 3vw, 2.2rem);
  /* -2% per explicit request. */
  letter-spacing: -0.02em;
  /* "Slightly reduced" from a display face's own natural (looser) line
     metrics — matches .spotlight__lead-title's own line-height for the
     same reason (a big display face reads too airy at its default
     metrics once it wraps to more than one line). */
  line-height: 1.15;
}
.quote-block__overlay-text em {
  font-style: italic;
}

/* ===================================================================
   Partner logos
=================================================================== */
.partners {
  /* EXPERIMENTAL marquee conversion — was a centered flex row of the 3
     raw logo <img>s; now a full-bleed (100vw, matching .marquee-banner's
     own edge-to-edge treatment) overflow:hidden strip wrapping
     .partners__scroll instead. Now ALSO carries .marquee-banner--
     scrolling (see index.html) for its scroll-triggered reveal, per
     explicit follow-up request to match the other marquees exactly —
     this class used to have its own bespoke fade+slide (matching the
     hero title's values) specifically to stay OUT of that shared
     system; now that it's deliberately IN it, .partners itself carries
     only layout, no opacity/transform/transition of its own — see
     .marquee-banner--scrolling/.marquee-banner--scrolling.is-visible
     further down for the reveal it now shares with #get-involved/
     #publications. */
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  overflow: hidden;
  /* Padding is internal breathing room around the logos. Used to also
     carry margin-top: 40px (an explicit ADDITION on top of main's own
     60px gap, from when .partners was a plain static row and the
     publications-to-here stretch read as too cramped) — dropped per
     explicit follow-up feedback: that extra 40px, added only above and
     not below, made the space above the marquee (132px: main's 60px gap
     + this 40px + this rule's own 32px padding-top) visibly bigger than
     the space below it (92px: this rule's own 32px padding-bottom +
     main's 60px margin-bottom) — confirmed live. Removing it restores
     the same 92px both sides. */
  padding: 32px 0;
}
/* .partners__scroll/.partners__track mirror .marquee-banner--scrolling's
   own 2-identical-copies-side-by-side technique (see that class's own
   long comment further up) — parallel, not shared, classes: same
   mechanics (a flex row of 2 byte-identical tracks, animated by exactly
   ONE track's own measured width so the loop point is invisible),
   measured/driven by initPartnersMarquee() in main.js rather than
   setMarqueeScrollMetrics(), which only ever queries .marquee-banner__*.
   --marquee-track-width/duration are set inline per-instance by that
   function, same as the text marquees; the values below are only the
   pre-JS fallback. Flex (not the text marquees' inline/nowrap layout)
   since these are images of very different heights (76-100px) needing
   real vertical centering, not baseline text alignment — no canvas
   cap-height measuring hack needed here. */
.partners__scroll {
  display: flex;
  width: max-content;
  animation: marquee-scroll 96s linear infinite;
}
.partners__track {
  display: flex;
  align-items: center;
  gap: 48px;
  flex-shrink: 0;
  /* Matches .marquee-banner's own padding so the strip reads as the
     same "thing" as the other marquees, just carrying logos instead of
     text. */
  padding: 0 24px;
}
@media (prefers-reduced-motion: reduce) {
  .partners__scroll {
    animation-play-state: paused;
  }
}
.partners__logo {
  height: 76px;
  width: auto;
}
/* Kelly Writers House's own logo is a near-square mark, not a wide
   wordmark like the other two — at the same height constraint it
   reads visibly smaller/lighter next to them, so it gets its own
   larger height. */
.partners__logo--kelly {
  height: 100px;
}
/* Same brown asterisk artwork AND size as every other marquee's "*"
   separator (assets/images/Brown asterisk.png, MARQUEE_ASTERISK_SIZE =
   14px in main.js) — per explicit request, matching those rather than
   sized up for the taller logos. Vertical centering comes for free from
   .partners__track's own align-items:center (a flex row, unlike the
   text marquees' inline layout), so none of the canvas-measured
   cap-height alignment those need applies here. */
.partners__asterisk {
  height: 14px;
  width: auto;
}

/* ===================================================================
   Footer — the <footer> itself is the grid (square-grid
   square-grid--4col, same as section 1's #overview), not a wrapper
   around a nested grid div. See the HTML comment above <footer>.
=================================================================== */
.site-footer {
  /* NO background here — same reasoning as .square-grid (section 1's
     own grid container): each CELL paints its own background (the
     checkers via --checker-a, the center via .site-footer__center's own
     background below), so each one's opacity fade is a real, visible
     color-fade against the page's cream background underneath. A
     background painted on the FOOTER ITSELF would sit under every cell
     at a constant opacity:1 regardless of any child's own fade state —
     which is exactly what made the reveal-on-scroll fade look like only
     the TEXT inside each cell was animating while "the squares
     themselves" stayed solid throughout. */
  /* Tan, not cream — cream is the page's own near-white background
     color, way too light/washed-out against dark brown to read as a
     deliberate "beige" text color. */
  color: var(--color-tan);
  /* NOT square-grid--rows-2's aspect-ratio trick, since that's a
     container-level ratio and this footer's own squareness instead
     comes from each checker's own aspect-ratio below. */
  grid-template-rows: repeat(2, 1fr);
}
/* Plain aspect-ratio:1/1 divs have no intrinsic height otherwise — this
   is what makes the row come out truly square (matching column width),
   the same way every other checker tile on the page does via its
   section's own container aspect-ratio. */
.site-footer .square--checker {
  aspect-ratio: 1 / 1;
  /* Mitigation for a reported real-Chrome-only bug: elements this far
     down a long page can get a delayed "cold start" repaint the first
     time they're scrolled into view, making the opacity transition's
     very first rendered frame already partway faded in instead of
     starting clean. This hints the browser to keep the element
     composite-ready ahead of time rather than repainting from scratch
     at reveal. */
  will-change: opacity;
}
.site-footer__center {
  /* No background of its own — it sits on top of squares 2 & 3 (see
     the HTML), which are already plain --color-dark squares underneath,
     so its own opacity fade only needs to affect its TEXT, which is
     exactly the desired "content fades in once the tiles have already
     landed" sequencing. pointer-events:none for the same reason: this
     box's own empty space (everything besides the actual title/links
     text) was sitting on top of those same 2 squares and silently
     eating their hover entirely — confirmed via elementFromPoint at
     their own center resolving to this element, not the square itself.
     Same pattern as .nav (pointer-events:none here, re-enabled below on
     the actual links). */
  pointer-events: none;
  /* position:relative so .site-footer__copyright can pin itself to the
     bottom (absolute) independent of this flex group's own centering —
     see .site-footer__copyright. */
  position: relative;
  will-change: opacity;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 16px;
}
.site-footer__title {
  width: 30%;
  height: auto;
}
/* 18px, matching .hero__eyebrow's own size (not --text-body/--text-small
   — same reasoning as the eyebrows: this isn't part of the 2-size body
   scale, it's sized to match a specific other element). Explicit margins
   (not a uniform flex gap) so the two text lines can sit closer to each
   other than the title sits to the first line. */
.site-footer__links {
  display: flex;
  gap: 6px;
  margin-top: 42px;
  font-size: 18px;
}
/* Same border-bottom-as-underline convention as .split-cta__link, but
   NOT bold, and thinned to match the nav underline's rendered thickness
   (.nav__indicator: height:1px scaled to 0.75 — can't scale just the
   border here without also squashing the glyphs, so a sub-pixel
   border-width gets the same effective hairline instead). */
.site-footer__links a,
.site-footer__inspo a,
.site-footer__copyright a {
  /* Re-enables clicking these specific links despite .site-footer__center's
     own pointer-events:none (see that rule's comment). */
  pointer-events: auto;
}
.site-footer__links a,
.site-footer__inspo a {
  border-bottom: 0.75px solid currentColor;
}
.site-footer__links li:not(:last-child)::after {
  content: "|";
  margin-left: 6px;
  opacity: 0.5;
}
.site-footer__inspo {
  margin-top: 12px;
  font-size: 18px;
}
.site-footer__copyright {
  /* Pinned to the bottom of .site-footer__center (absolute, taken out
     of flex flow) rather than just another centered line — so it
     reads as "at the bottom of the footer, with a little padding" as
     its own thing, independent of the title/links/inspo group's own
     centering above it. */
  position: absolute;
  left: 0;
  right: 0;
  bottom: 10px;
  font-size: var(--text-small);
  opacity: 0.6;
}
.site-footer__copyright a {
  border-bottom: 0.75px solid currentColor;
}

/* ===================================================================
   Responsive
=================================================================== */
@media (max-width: 720px) {
  /* Dropping to 2 columns changes each grid's effective row count (spans
     reflow), so the 4-column aspect-ratio math no longer applies — fall
     back to per-item squares instead of a container-driven height. */
  /* grid-auto-rows:1fr (the base rule, above) only means "divide the
     container's own definite height evenly" on desktop, where
     .square-grid--rows-N's aspect-ratio gives the grid a real height.
     Once that's aspect-ratio:auto (indefinite height, sizes to content
     instead), 1fr on an indefinite container falls back to sizing every
     implicit row to the SAME value — the tallest single row's content
     demand, applied uniformly to ALL of them — confirmed live: with an
     8-row grid, one row needing 280px of text made every other row
     (checkers included) 280px tall too, each with 75px+ of dead cream
     space below its own much-shorter square content. auto sizes each
     row independently, purely from what's actually in IT. */
  .square-grid--4col { grid-template-columns: repeat(2, 1fr); grid-auto-rows: auto; aspect-ratio: auto; }
  .square-grid--rows-1, .square-grid--rows-2, .square-grid--rows-3, .square-grid--rows-4 { aspect-ratio: auto; }
  .square { aspect-ratio: 1 / 1; }
  /* A strict square is too short to hold real paragraph copy once its
     own column narrows this far — confirmed live: "Through interviews…"
     was getting its last line clipped by .square's own overflow:hidden
     at 430px. These variants (all genuine content, not a repeating
     pattern/photo) size to their content instead of a forced 1:1 —
     listed by variant class rather than :has(.square__body) so
     .square--deco's SVG arc text ("Still curious?") is included too.
     min-height is just a floor so a short line (e.g. "Applications
     open in the spring.") doesn't look like a thin sliver. Deliberately
     does NOT include .square--checker or .square--inset: a checker's
     pattern is mask-sized as a % of its OWN box, so a non-square box
     visibly stretches the diamonds into ovals — those two need to stay
     real squares, which is why the row remap right below pairs every
     text square with ANOTHER text square (both flex to whatever height
     their own copy needs, independent of the other) rather than with a
     checker/inset (which would otherwise get stretched by the grid's
     default row-fill behavior, or leave a gap if it didn't). */
  .square--pad-lg, .square--text-gap-30, .square--content-left, .square--deco, .square--role-reveal {
    aspect-ratio: auto;
    min-height: 160px;
  }
  /* #overview's 11 children carry inline grid-column/grid-row values
     written for the 4-column desktop grid (columns 3 and 4 included).
     Once the grid above drops to 2 explicit columns, those column-3/4
     placements don't reflow into column 1/2 — CSS grid instead invents
     new IMPLICIT columns 3/4 to honor them, so half these tiles (and
     the "Learn more" diamond inside one of them) were rendering
     squeezed into extra columns that overlap columns 1/2 rather than
     stacking two-per-row underneath. !important is required: an inline
     style attribute always wins over an external stylesheet rule of
     any specificity UNLESS that external rule is also !important.

     Rows are grouped by content type, not strict source order: pairing
     a content-driven square (auto height, above) with a checker/inset
     (forced 1:1) left the fixed-height one stranded at the top of a
     taller row with dead cream space below it — grid's default stretch
     can't reconcile "fill the row" with "keep this a perfect square" at
     the same time, so it just keeps the square and leaves the gap.
     Pairing content-with-content and decorative-with-decorative avoids
     that mismatch entirely: children 2+3 (the two text squares) share
     row 1, child 1 (checker) moves down to pair with child 4 (inset
     image) — both real squares, no mismatch — and 6+8 (text-gap +
     deco, both flexible) share a row while 7+9 and 10+11 (all
     decorative) keep their own matched pairs. */
  #overview > *:nth-child(2) { grid-column: 1 !important; grid-row: 1 !important; }
  #overview > *:nth-child(3) { grid-column: 2 !important; grid-row: 1 !important; }
  #overview > *:nth-child(1) { grid-column: 1 !important; grid-row: 2 !important; }
  #overview > *:nth-child(4) { grid-column: 2 !important; grid-row: 2 !important; }
  #overview > *:nth-child(5) { grid-column: 1 / span 2 !important; grid-row: 3 / span 3 !important; }
  #overview > *:nth-child(6) { grid-column: 1 !important; grid-row: 6 !important; }
  #overview > *:nth-child(8) { grid-column: 2 !important; grid-row: 6 !important; }
  #overview > *:nth-child(7) { grid-column: 1 !important; grid-row: 7 !important; }
  #overview > *:nth-child(9) { grid-column: 2 !important; grid-row: 7 !important; }
  #overview > *:nth-child(10) { grid-column: 1 !important; grid-row: 8 !important; }
  #overview > *:nth-child(11) { grid-column: 2 !important; grid-row: 8 !important; }
  /* Same "inline column-3/4 placements don't reflow" problem, same
     !important-override fix, for about.html's own section 1 — see
     #overview's own comment above for the full reasoning. Only 1
     content-driven square here (child 3, the merged intro text tile),
     so it just gets its own full-width row instead of being paired
     with another content square; the remaining 8 decorative squares
     pair up 2-per-row in source order. */
  #about-intro > *:nth-child(1) { grid-column: 1 !important; grid-row: 1 !important; }
  #about-intro > *:nth-child(2) { grid-column: 2 !important; grid-row: 1 !important; }
  #about-intro > *:nth-child(3) { grid-column: 1 / span 2 !important; grid-row: 2 !important; }
  #about-intro > *:nth-child(4) { grid-column: 1 !important; grid-row: 3 !important; }
  #about-intro > *:nth-child(5) { grid-column: 2 !important; grid-row: 3 !important; }
  #about-intro > *:nth-child(6) { grid-column: 1 !important; grid-row: 4 !important; }
  #about-intro > *:nth-child(7) { grid-column: 2 !important; grid-row: 4 !important; }
  #about-intro > *:nth-child(8) { grid-column: 1 !important; grid-row: 5 !important; }
  #about-intro > *:nth-child(9) { grid-column: 2 !important; grid-row: 5 !important; }
  /* .image-mosaic (child 5 above) has no aspect-ratio of its own — on
     desktop its height comes entirely from spanning 3 rows of the
     aspect-ratio-locked 4-col grid. That grid is aspect-ratio:auto here,
     so without its own ratio the mosaic would collapse to ~0 height
     (its <img> is position:absolute and contributes no intrinsic
     height). Desktop's proportions were 2 columns wide by 3 of those
     same-width rows tall, i.e. width:height of 2:3 — reproduced
     directly so it keeps the same shape, just recomputed for a
     2-column mobile width instead of a 4-column desktop one. */
  .image-mosaic { aspect-ratio: 2 / 3; }
  /* align-content:center (default is stretch): with only 1 explicit
     column, the grid's 3 auto-sized rows would otherwise stretch to
     fill the whole min-height:100vh container, leaving each row's
     content vertically centered in its own third of the screen —
     spreading title/eyebrow/eyebrow far apart instead of them reading
     as one neatly stacked block. Packs the rows (title + both eyebrow
     lines) together instead, with just `gap` between them. */
  .hero { grid-template-columns: 1fr; align-content: center; gap: 28px; text-align: center; padding: 48px 32px; }
  /* DOM order is eyebrow-left, wordmark, eyebrow-right, stacked (desktop
     reads left-to-right across the 3-column grid; the merged stacked
     block sits last, invisible on desktop). Stacking to 1 column without
     reordering would put a hidden eyebrow-left ahead of the title in
     source order — moot for layout once it's display:none, but order:2
     below still puts the one VISIBLE eyebrow (stacked) below the title,
     per the original explicit request for that reading order. */
  /* Both split blocks (left AND right — .hero__eyebrow--right included,
     via the shared .hero__eyebrow class) give way to the single merged
     .hero__eyebrow--stacked block below once everything's one column;
     see that class's own comment in the base rules above for why this
     is separate markup rather than a reflow of the same spans. */
  .hero__eyebrow { display: none; }
  /* max-width:none + its own smaller font-size — the base .hero__eyebrow
     rule's max-width:12em and 20px font-size were both tuned for the
     desktop split's shorter per-line fragments ("The University of" /
     "Pennsylvania's only"). This block's own first line, "The
     University of Pennsylvania's," is the same words but NOT split, so
     at that size/width it wraps to 2 lines instead of reading as the
     single line it's meant to — confirmed live, reported as an
     unwanted mid-sentence break. Dropping the cap and sizing down a bit
     gives it room to actually fit on one line down to real small-phone
     widths (~375px) without needing to shrink so far it stops looking
     like the same eyebrow style used everywhere else. */
  .hero__eyebrow--stacked {
    display: block;
    order: 2;
    text-align: center;
    margin: 0 auto;
    max-width: none;
    font-size: clamp(14px, 4.4vw, 18px);
  }
  /* .hero__wordmark-wrap's base rule (way above) hard-codes
     grid-column: 2 for the desktop 3-column layout. .hero only has ONE
     explicit column here, so that stale "2" was creating an IMPLICIT
     2nd column instead of reflowing into the single real one — same
     bug as #overview's tiles, just on the hero. Pin it back to the one
     column that actually exists. */
  /* margin:0 auto — .hero__wordmark-wrap's width comes from a clamp(),
     always narrower than the full grid track at this breakpoint, so
     without auto margins it just sits flush against the track's start
     (left) edge instead of centered, even though .hero itself has
     text-align:center (that only centers TEXT/inline content, not a
     block-level grid item's own box within its cell). */
  .hero__wordmark-wrap { order: 1; grid-column: 1; margin: 0 auto; }
  .split-cta { grid-template-columns: 1fr; }
  /* .split-cta__text's 120px left padding is tuned for sitting beside
     .split-cta__deco in the desktop 2-column layout above — once that
     collapses to a single stacked column here, the same fixed 120px
     eats over a third of a 320px viewport's width, crushing "What
     else?" and the paragraph lines into an unnecessarily narrow strip. */
  .split-cta__text { padding-left: 32px; padding-right: 32px; }
  .publications__grid { grid-template-columns: repeat(2, 1fr); }
  /* Same reasoning as .square-grid--4col above — 4 narrow columns with
     side checkers squeezed thin doesn't hold up on mobile, so stack to
     a single column instead. */
  .site-footer { grid-template-columns: 1fr; grid-template-rows: auto; }
  .site-footer__center { grid-column: 1; grid-row: auto; padding: 48px 24px; }
  /* All 4 decorative tile squares (checkers + the 2 plain dark ones),
     not just the checkers — only .site-footer__center's own content
     should show on mobile. */
  .site-footer .square:not(.site-footer__center) { display: none; }
}

/* ===================================================================
   Scroll reveal — a slow, plain fade (no slide/motion) as content
   enters view, with a pronounced per-item stagger for a dramatic,
   floaty cascade.

   The opacity:0 default lives directly on these concrete selectors
   (rather than on a class main.js adds later) so it's true from the
   very first paint — the external stylesheet blocks rendering until
   loaded, so there's no frame where these are visible. That matters:
   a class added by JS *after* first paint would make the browser
   animate the jump from "however it first rendered" down to opacity:0,
   i.e. a visible fade-OUT flash on every load, before ever fading back
   in on scroll. Baking it into the selector avoids that entirely.

   .split-cta itself (the whole section) is deliberately NOT in this
   list, unlike before — it has no visible fill of its own, and now that
   its insides reveal via their own explicit chain (tiles -> illustration
   -> text lines, see initSplitCtaReveal() in main.js), also fading the
   section wrapper as a scroll-triggered unit would compound against that
   chain (its --reveal-delay comes from its index among ALL of <main>'s
   section-level children, an unrelated number that could easily outpace
   or lag the internal choreography) for no visual benefit, since the
   section has nothing to show on its own besides its children.

   .marquee-banner--scrolling, per explicit request: the whole marquee
   fades in as one flat unit when it scrolls into view (a per-phrase
   left-to-right stagger was tried first and reverted — overcomplicated
   for what this is), same as every other plain single-element target in
   this list, while its own continuous scroll animation (a DIFFERENT
   property — see that class further down) keeps running underneath
   completely independently of this opacity fade. */
.square,
.split-cta__illustration-tile,
.image-mosaic__mask,
.featured-carousel__mask,
.quote-block__mask,
.publication-card,
.marquee-banner--scrolling,
.team-bio {
  opacity: 0;
  /* filter here too — only .square ever actually changes that property
     (its own hover-leave brightness reset, see .square's base rule),
     so this is a harmless no-op for the other selectors in this list.
     Without it, THIS rule's own `transition` shorthand (equal
     specificity to .square's base rule, declared later here) silently
     discarded that rule's `transition: filter 0.3s ease-out;` outright
     instead of adding to it — same class of bug hit repeatedly
     elsewhere in this file — leaving hover with NO filter transition
     applicable at all outside of :hover itself, so brightness snapped
     back instantly instead of easing out. */
  transition: opacity 0.7s ease-out, filter 0.3s ease-out;
  /* Always instant when LEAVING .is-visible (whichever direction that
     represents — see .image-mosaic__mask's inverted case below); the
     staggered --reveal-delay only applies while ENTERING it (see the
     .is-visible-scoped rule right below this one). Same reasoning as
     the hero's scroll-linked exit fix: a delay that also applies when
     leaving .is-visible can get caught by a fast scroll reversal before
     it's even started, leaving a stale partial opacity for the next
     transition to wrongly continue from instead of a clean value.
     Second 0s is filter's own delay — always immediate, never tied to
     the reveal stagger (see the .is-visible-scoped rule's own comment
     for why that stays true even once .is-visible is added). */
  transition-delay: 0s, 0s;
}
.square.is-visible,
.split-cta__illustration-tile.is-visible,
.image-mosaic__mask.is-visible,
.featured-carousel__mask.is-visible,
.quote-block__mask.is-visible,
.publication-card.is-visible,
.marquee-banner--scrolling.is-visible,
.team-bio.is-visible {
  /* Second value pins filter's delay to 0s even once .is-visible is
     present — transition-delay lists repeat cyclically to match
     transition-property's count when there are fewer values than
     properties, so a single value here would otherwise apply the
     opacity-reveal's OWN stagger to hover-leave brightness too, adding
     a stale, unwanted delay to an interaction that has nothing to do
     with the tile's one-time scroll-reveal. */
  transition-delay: var(--reveal-delay, 0ms), 0s;
}
.is-visible {
  opacity: 1;
}
/* Mask panels invert the direction: start opaque (hiding the photo
   behind them), fade OUT on reveal instead of in. Higher specificity
   than the bare .is-visible above, so it wins regardless of order. */
.image-mosaic__mask,
.featured-carousel__mask,
.quote-block__mask {
  opacity: 1;
}
.image-mosaic__mask.is-visible,
.featured-carousel__mask.is-visible,
.quote-block__mask.is-visible {
  opacity: 0;
}
/* Covers additionally slide up as they fade — the shared rule above
   only fades. A scoped rule rather than adding transform to that shared
   list: it also covers .square/.quote-block/etc., none of which should
   suddenly start sliding too. Equal specificity to that shared rule
   (each selector in a list carries its own specificity independently),
   so this — declared later — wins outright for `transition`, meaning it
   has to restate opacity's own transition alongside the new transform
   one rather than relying on the earlier rule's value surviving (same
   shorthand-clobbering trap hit elsewhere in this file). Same reasoning
   for transition-delay: both properties need the SAME --reveal-delay
   stagger, or transform (defaulting to 0s once the shared rule's 2nd
   delay value — meant for filter — got reassigned to it instead) would
   finish its slide long before opacity's staggered fade-in even starts,
   reading as a plain fade with no visible motion left for every card
   after the first. */
.publication-card {
  transform: translateY(24px);
  /* Same hover-glow mechanism as .square:hover (see that rule's own
     comment) — filter added to this rule's own transition list (not
     just the shared one above, which this — declared later, equal
     specificity — already fully replaces per that shared rule's own
     shorthand-clobbering warning) so leaving hover has a transition to
     animate through at all. */
  filter: brightness(1);
  transition: opacity 0.7s ease-out, transform 0.7s ease-out, filter 0.3s ease-out;
  transition-delay: 0s, 0s, 0s;
}
.publication-card.is-visible {
  transform: none;
  transition-delay: var(--reveal-delay, 0ms), var(--reveal-delay, 0ms), 0s;
}
/* Per explicit request: same brighten-on-hover feedback as the square
   tiles. Redeclares opacity/transform's own transitions alongside
   filter's slower "entering" one (:hover and .is-visible are equal
   specificity — whichever is declared later wins the WHOLE shorthand,
   same clobbering trap as everywhere else in this file), so hovering a
   card mid-reveal (or already revealed) doesn't lose its fade/slide
   transition outright. */
.publication-card:hover {
  filter: brightness(1.07);
  transition: opacity 0.7s ease-out, transform 0.7s ease-out, filter 0.6s cubic-bezier(0.25, 1, 0.5, 1);
  transition-delay: var(--reveal-delay, 0ms), var(--reveal-delay, 0ms), 0s;
}
/* Same fade + slide-up as .publication-card just above — same reasoning
   for why this needs its own transform override instead of relying on
   the shared opacity-only list further up (equal specificity, later
   source order wins the whole shorthand). */
.team-bio {
  transform: translateY(24px);
  transition: opacity 0.7s ease-out, transform 0.7s ease-out;
  transition-delay: 0s, 0s;
}
.team-bio.is-visible {
  transform: none;
  transition-delay: var(--reveal-delay, 0ms), var(--reveal-delay, 0ms);
}

/* Intro sequence — title, then both eyebrow blocks (in sync with each
   other, staggered line-by-line within each block), then the nav bar as
   one unit — plays once on page load, independent of
   scroll/IntersectionObserver. Order is encoded purely as --intro-delay
   per element; main.js just flips "is-visible" on all of them at once.
   The nav bar has nothing to worry about re: pre-existing opacity rules
   on its children (link hover/active dimming) since opacity:0 lives on
   the header itself, hiding logo + links together as a single unit —
   the links' own opacity rules just resume normally once the header is
   visible again. */
.intro-reveal {
  opacity: 0;
  transition: opacity 0.4s ease-out;
  transition-delay: var(--intro-delay, 0ms);
}
.intro-reveal.is-visible {
  opacity: 1;
}
/* Title: fades + slides up into place, quick. */
.intro-reveal--slide {
  transform: translateY(20px);
  transition: opacity 0.45s ease-out, transform 0.45s ease-out;
  transition-delay: var(--intro-delay, 0ms);
}
.intro-reveal--slide.is-visible {
  transform: none;
}
/* Eyebrow lines: same slide-up + fade, but much slower, with a gentler
   decelerate curve — cubic-bezier(0.33, 1, 0.68, 1) ("easeOutCubic").
   Note: an earlier attempt used Expo Out (0.19, 1, 0.22, 1), which is
   fast-start/slow-finish too, but so aggressively front-loaded that
   ~90% of the motion (both fade and slide) completes in the first
   third of the duration — the remaining time is a sub-pixel crawl,
   which reads as "already done" rather than a visible slow finish.
   easeOutCubic spreads the deceleration out so the slide stays
   perceptible across the whole duration. Distance bumped 20px -> 36px
   for the same reason — more travel to actually see happening. */
.intro-reveal--slide-slow {
  transform: translateY(36px);
  /* 0.8s — was 1.6s, confirmed as the permanent speed after trying it
     out live: the hero title/eyebrows fading+sliding in faster, on
     every page that uses this reveal (index.html's own splash-driven
     entrance included — this rule is shared, not page-specific).
     SKIP_INTRO_SPLASH_OFFSET_MS in main.js is tuned against THIS
     duration (re-tune that constant too if this one ever changes
     again — see its own comment for why). */
  transition: opacity 0.8s cubic-bezier(0.33, 1, 0.68, 1),
              transform 0.8s cubic-bezier(0.33, 1, 0.68, 1);
  transition-delay: var(--intro-delay, 0ms);
}
.intro-reveal--slide-slow.is-visible {
  transform: none;
}
/* Hero title (word 1, word 2, "for") AND the eyebrow lines — the shared
   easeOutCubic above front-loads its motion (fast start, slow settle),
   which read as an abrupt "pop" for a pure fade specifically, even
   though it feels right for the slide. Only overriding
   transition-timing-function (not the whole `transition` shorthand) so
   this doesn't reset the transition-delay overrides set elsewhere for
   the scroll-linked exit/re-entry — comma position matches
   .intro-reveal--slide-slow's own property order (opacity, transform):
   ease-in only touches the fade, the slide keeps its existing curve
   untouched. ease-in is slow-then-fast — the opposite shape from
   ease-out — so the fade now eases gently into motion instead of
   jumping straight to visible. Originally scoped to .hero__title-exit
   only; the eyebrow lines were reported showing the exact same "already
   fully opaque" pop on scroll re-entry once the title's own fade was
   fixed and made the eyebrows' un-fixed one obvious by contrast next to
   it — same underlying cause, same fix, just never applied there too. */
.hero__title-exit,
.hero__eyebrow-line {
  transition-timing-function: ease-in, cubic-bezier(0.33, 1, 0.68, 1);
}
.hero__title-exit {
  /* Title pieces travel further to reach their resting spot than the
     shared .intro-reveal--slide-slow default (36px) — per explicit
     request for more visible motion on the title specifically, without
     touching the eyebrow lines/nav/"for" wrap that also share that
     class. Equal specificity (single class) to .intro-reveal--slide-slow,
     but this rule sits later in the cascade, so it wins for `transform`
     alone; transition timing/duration still come from the shared rule.
     80px read as still not enough travel, bumped to 140px. */
  transform: translateY(140px);
}
/* Squash-and-stretch entrance, per explicit request. Scale is split onto
   the OUTER wrapper (.hero__wordmark-word / .hero__wordmark-for-wrap)
   rather than .hero__title-exit itself, so it composes with (rather than
   competes against) that element's own slide+fade transition — nested
   transforms on different elements never fight over the same property.
   First version of this keyframe had a mid-point overshoot (squash past
   1, then spring back) — reported as reading like a bounce when the
   intent was a smooth ease into the resting scale. Fixed by dropping the
   overshoot keyframe entirely: this is now a straight 0%->100% interpolation
   (squashed -> normal) riding the SAME easeOutCubic curve as the slide,
   so it decelerates into place with no spring-back. Scale amounts toned
   down from 1.18/0.9 after that fix read as too strong a stretch.
   Both keyframes prepend var(--squash-base) — each of this animation's
   target elements (.hero__wordmark-word--1/--2, .hero__wordmark-for-wrap)
   already carries its own static positioning nudge as a plain `transform`
   (e.g. translateY(-6px)), which an `animation` on the SAME property
   completely overrides for as long as it's in effect (a `transform`
   declared elsewhere never blends with an active animation on the same
   element). That's invisible while the animation is scoped to
   body:not(.intro-finished) below AND body.intro-finished hasn't fired
   yet — but body.intro-finished fires on its own, independently-timed
   ~6.65s countdown, well after this animation's own delay+duration has
   already finished and it's just sitting on its `forwards`-held 100%
   frame. The instant body.intro-finished DOES fire, the `:not()` below
   stops matching, the animation (and the static nudge it was masking)
   disappears, and the element's REAL static transform applies for the
   first time — reading as an abrupt unexplained shift, seconds after
   the entrance visually looked done. Composing --squash-base (a copy of
   that same static nudge, set as a custom property alongside it — see
   e.g. .hero__wordmark-word--1) into both keyframes means the animation's
   own held end-state is pixel-identical to the static resting transform
   underneath it, so losing the animation later is a genuine no-op. */
@keyframes hero-title-squash-stretch {
  0% { transform: var(--squash-base, none) scaleY(1.1) scaleX(0.95); }
  100% { transform: var(--squash-base, none) scaleY(1) scaleX(1); }
}
/* .hero__wordmark-word/--for-wrap carry their own copy of the SAME
   --intro-delay inline value as the .hero__title-exit they wrap (see
   index.html) — a custom property set on a child isn't visible to a
   rule targeting its ancestor, so the delay has to be duplicated onto
   the wrapper directly rather than read through it. :has() is what
   triggers this off the INNER element's .is-visible (added by
   initIntroReveal() in main.js) without main.js needing to know or care
   that this wrapper-level animation exists at all. */
.hero__wordmark-word,
.hero__wordmark-for-wrap {
  transform-origin: 50% 100%;
}
body:not(.intro-finished) .hero__wordmark-word:has(> .hero__title-exit.is-visible),
body:not(.intro-finished) .hero__wordmark-for-wrap:has(> .hero__title-exit.is-visible) {
  animation: hero-title-squash-stretch 0.8s cubic-bezier(0.33, 1, 0.68, 1) forwards;
  animation-delay: var(--intro-delay, 0ms);
}
/* Scroll-linked exit for the hero eyebrow lines (independent of the
   one-time page-load intro above): initHeroEyebrowExit() toggles
   is-visible on ALL 4 lines together, based on the HERO SECTION's own
   scroll position (5% of its height scrolled out) — not each line's
   individual geometry, so they can't drift out of sync with each other.
   Scoped to body.intro-finished (set once the whole intro sequence is
   done, see initIntroReveal()) — without that scope, this would fight
   the intro's OWN "not visible yet" state (both are ":not(.is-visible)"
   before the intro adds it, but need different resting transforms:
   sliding up FROM below to arrive vs. sliding further up to exit). Once
   scoped, this overrides .intro-reveal--slide-slow's translateY(36px)
   with an upward exit instead (translateY(-100px)) for
   .hero__eyebrow-line specifically, without touching that shared
   class's behavior for anything else that uses it (title, "for", nav). */
body.intro-finished .hero__eyebrow-line {
  /* .intro-reveal--slide-slow's own transition-delay reads the SAME
     --intro-delay custom property left over (inline) from the
     one-time intro — without resetting it here, every scroll-toggled
     transition after the intro would still carry that multi-second
     delay from the original entrance, so nothing would visibly move
     for the first few seconds after scrolling. */
  transition-delay: 0s;
}
/* Both lines toggle from the exact same JS trigger (see
   initHeroEyebrowExit()) — this small extra delay on just the 2nd line
   of each block is what makes them read as two lines moving "line by
   line" instead of a single 2-line block sliding as one paragraph.
   Scoped to .is-visible (ENTERING only) — applying it to exiting too
   meant a fast scroll-down-then-up before this delay had even elapsed
   could catch the exit transition still waiting in its delay phase,
   never having actually started, so the following re-entrance would
   animate from whatever partial opacity was left over instead of a
   clean 0. Exiting now always uses the base rule's 0s delay above
   (line 1867), regardless of nth-child — always instant, so there's
   nothing left mid-flight for a quick reversal to catch. */
body.intro-finished .hero__eyebrow-line.is-visible:nth-child(2) {
  transition-delay: 90ms;
}
/* .hero__eyebrow--stacked (mobile-only, see its own comment further up)
   is the only block with a 3rd line — this only ever matches there,
   same "line by line" reasoning as nth-child(2) above, just one more
   step down the stagger. */
body.intro-finished .hero__eyebrow-line.is-visible:nth-child(3) {
  transition-delay: 180ms;
}
body.intro-finished .hero__eyebrow-line:not(.is-visible) {
  /* transition-duration:0 (not the whole shorthand — property list and
     timing-function stay put) blips this straight to hidden instead of
     smoothly fading out. Without this, scrolling down then back up
     again quickly enough could catch the exit fade still mid-flight,
     so the following re-entrance would start from whatever partial
     opacity was left over instead of a clean 0 — visually "only half
     the reset animation plays." Exiting doesn't need to look graceful;
     it just needs to reliably finish being gone before any re-entrance
     can start from a known-clean state. */
  transition-duration: 0s;
  opacity: 0;
  transform: translateY(-100px);
}
body.intro-finished .hero__eyebrow-line.is-visible {
  opacity: 1;
  transform: none;
}
/* Same scroll-linked exit, applied to the "Word for Word" title pieces
   (both "Word"s + the "for" overlay) — initHeroEyebrowExit() toggles
   is-visible on these together with the eyebrow lines and asterisk, all
   from the SAME hero-scrolled-5%-out trigger (same moment), but each
   GROUP staggers its own internal pieces independently: eyebrows go
   line 1 then line 2 (see the nth-child rule above), the title goes
   word 1, then "for", then word 2 + the asterisk together (a 3rd,
   later beat) — not evenly, just enough separation to read as
   sequential rather than everything popping at once. */
body.intro-finished .hero__title-exit {
  transition-delay: 0s;
}
/* Both staggers below are scoped to .is-visible (ENTERING only) for the
   same reason as the eyebrow line 2 stagger above: exiting must always
   be instant (the base 0s right above), so a fast scroll reversal can
   never catch one of these mid-delay, still not actually having started
   its exit, and re-enter from a stale partial value instead of a clean
   0. */
/* Stagger amounts match the ORIGINAL page-load intro's own relative
   cadence (word 1 -> "for" +130ms -> word 2 +350ms — see the <header>
   intro sequence comment in index.html), not an arbitrary re-entry-only
   value, per explicit request that scrolling back should just replay
   what the intro itself does. */
/* Word 1 previously had NO extra delay (0s, straight off the base reset
   above) — it started moving the INSTANT the scroll threshold was
   crossed, so by the time "for"/word 2 even started, word 1 was already
   mid-flight or done, reading as barely-there next to them. Per explicit
   request this isn't about the trigger (still the same scroll threshold,
   still the first piece to move) — just a small head-start delay so the
   eye actually catches it beginning to move instead of arriving
   "already in progress." Small enough to still leave a clear gap before
   "for" starts at 0.13s (comparable to the eyebrow lines' own working
   90ms line-1-to-line-2 stagger), so the sequential read isn't lost. */
body.intro-finished .hero__wordmark-word--1 .hero__title-exit.is-visible {
  transition-delay: 0.05s;
}
body.intro-finished .hero__wordmark-for.is-visible {
  transition-delay: 0.13s;
}
body.intro-finished .hero__wordmark-word--2 .hero__title-exit.is-visible {
  transition-delay: 0.35s;
}
body.intro-finished .hero__title-exit:not(.is-visible) {
  /* Same instant-blip reasoning as .hero__eyebrow-line:not(.is-visible)
     above — exiting must reliably finish before any fast down-then-up
     scroll can catch it mid-fade and start the next re-entrance from a
     stale partial value. */
  transition-duration: 0s;
  opacity: 0;
  /* -100px (matching the eyebrow lines' own exit distance) read as
     barely-there on re-entry — word 1 in particular, since it has no
     extra transition-delay (see the stagger rules above) and so is
     already done sliding before "for"/word 2 even start, making its
     travel easy to miss entirely. Per explicit request this is a
     distance problem, not a timing one: bumped to -180px so the title's
     re-entry reads as clearly as its page-load intro (.hero__title-exit's
     own 140px) does. Left the eyebrow lines/asterisk at -100px — only
     the title itself was reported as reading weak. */
  transform: translateY(-180px);
}
body.intro-finished .hero__title-exit.is-visible {
  opacity: 1;
  transform: none;
}
/* The hero asterisk (beside the second "Word"'s "d") joins the same
   scroll-linked exit as the title/eyebrows. REDESIGNED per explicit
   request: re-entry should just REPLAY the exact page-load intro pop
   (hero-asterisk-intro), synced to word 2's own beat, not fade in early
   via a plain transition with a "bonus" spin tacked on afterward — the
   previous approach (0.2s fade start + a spin at 1.5s) read as "the
   asterisk is already visible" well before the spin ever kicked in,
   since the plain transition had a full 1.3s head start on it.
   Exiting is unchanged (smooth fade+slide, matching the title/eyebrows'
   own exit duration) — only the ENTERING side changes below.
   Duration here is a SEPARATE, hardcoded copy of
   .intro-reveal--slide-slow's own (this system doesn't use that shared
   class at all) — keep it in sync by hand if that duration ever changes
   again; it was left at the OLD 1.6s once already after that duration
   was cut to 0.8s, which was the actual cause of a real reported bug:
   re-entering the hero via scroll read as noticeably SLOWER than the
   initial page-load reveal, even though "it's the same hero." */
body.intro-finished .hero__wordmark-asterisk-wrap {
  animation: none;
  opacity: 1;
  transform: none;
  transition: opacity 0.8s cubic-bezier(0.33, 1, 0.68, 1),
              transform 0.8s cubic-bezier(0.33, 1, 0.68, 1);
  transition-delay: 0s;
}
body.intro-finished .hero__wordmark-asterisk-wrap:not(.is-visible) {
  /* Same instant-blip reasoning as .hero__eyebrow-line/.hero__title-exit
     above. */
  transition-duration: 0s;
  opacity: 0;
  transform: translateY(-100px);
}
/* transition-delay here MUST match .is-respinning's own animation-delay
   below exactly (both 0.35s): when both start at the literal same
   instant, the animation wins outright (an animation always overrides a
   transition running on the same property), so this plain transition
   never gets to visibly show a single frame of its own fade — it only
   exists so non-reduced-motion users still have SOMETHING driving
   opacity if .is-respinning somehow isn't added, and so reduced-motion
   users (who never get .is-respinning at all — see initHeroEyebrowExit)
   have a real reveal mechanism instead of none. If this delay is ever
   shortened below the matching .is-respinning delay below, the old bug
   comes back: a visible partial fade before the pop starts. */
body.intro-finished .hero__wordmark-asterisk-wrap.is-visible {
  opacity: 1;
  transform: none;
  transition-delay: 1.15s;
}
/* Replays the exact pop-in spin the asterisk played on the very first
   page load (hero-asterisk-intro — see .hero__wordmark-asterisk-wrap's
   base, unscoped rule above) every time scrolling back up brings the
   hero into view again. .is-respinning is added by initHeroEyebrowExit()
   in main.js ONLY on an actual hidden->visible transition (never on
   every scroll tick it's already visible for), and removed-then-re-
   added there each time (forcing a reflow in between) so the animation
   genuinely restarts even on the 2nd/3rd/etc. re-entry, not just the
   first. Delay is 1.15s — word 2's own 0.35s start delay + its full
   0.8s transition duration (was 1.95s = 0.35s + 1.6s, back when that
   duration was still 1.6s — left stale after it got cut to 0.8s, which
   made scroll re-entry visibly slower than the initial page-load
   reveal despite being "the same hero," a real reported bug) — so the
   asterisk pops only once word 2 has actually FINISHED animating in,
   not merely started (tried syncing it to word 2's START instead;
   explicit follow-up feedback was that this fired too early — the pop
   needs to wait for word 2 to actually settle). Gated entirely in JS
   behind prefers-reduced-motion (see initHeroEyebrowExit()), same as
   everywhere else motion is optional on this page — .is-respinning is
   simply never added for those users, rather than relying on this
   rule's specificity to lose to the
   reduced-motion override further down (it wouldn't: 3 classes here
   beats that rule's 1). */
body.intro-finished .hero__wordmark-asterisk-wrap.is-respinning {
  animation: hero-asterisk-intro 1.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
  animation-delay: 1.15s;
}
/* Nav bar: fades in while dropping down from just above its resting spot. */
.intro-reveal--drop {
  transform: translateY(-16px);
  transition: opacity 0.45s ease-out, transform 0.45s ease-out;
  transition-delay: var(--intro-delay, 0ms);
}
.intro-reveal--drop.is-visible {
  transform: none;
}

/* ===================================================================
   Custom cursor asterisk — see initCustomCursor() in main.js. Currently
   using the hero's own asterisk PNG artwork (swapped in from an earlier
   SVG-text glyph as a diagnostic: to test whether the reported
   "orbiting" is caused by position-lag while trailing the cursor, or by
   something specific to the SVG/text centering path it replaced).
   Split into wrap (position + hover scale, set via JS each frame) +
   burst (one-shot click spin) + the img itself (continuous spin) for
   the same reason as .hero__wordmark-asterisk-wrap: an `animation`
   touching `transform` replaces an element's own transform outright, so
   position/burst/spin can't safely share one element.
=================================================================== */
.custom-cursor-wrap {
  position: fixed;
  top: 0;
  left: 0;
  /* No width/height here — sized entirely by its content (the burst div
     and img, which set their own explicit sizing). Setting a DIFFERENT
     size here was an earlier bug with the old SVG glyph: it silently
     rescaled the whole glyph to fit, which is why "bigger" font-size
     values weren't visibly landing. */
  pointer-events: none;
  z-index: 100000;
  will-change: transform;
  /* Contrast against whatever's underneath comes from the blend mode,
     not a JS-driven swap between 2 fixed color variants (that approach
     needed elementFromPoint + a background-color allowlist checked on
     every mousemove, and only ever produced one of 2 discrete colors).
     MUST live here, on the actual position:fixed element, not on the
     nested .custom-cursor-asterisk img — .custom-cursor-wrap creates
     its own stacking context (position:fixed + z-index), so a
     descendant's mix-blend-mode only blends against that LOCAL context
     (which contains nothing but this glyph, so visibly does nothing),
     never against the real page behind it. Confirmed with a side-by-
     side repro: same nesting, blend-mode on the inner element showed
     the artwork's plain unblended color; moved up to this element, it
     correctly inverted per-background. multiply, chosen by eye from a
     side-by-side comparison of every blend mode against all 5 palette
     colors (difference/exclusion skewed blue-cyan against the warm
     light backgrounds; hard-light was rejected too) — multiply keeps
     the asterisk's own reddish-brown character while darkening
     consistently against every background. */
  mix-blend-mode: multiply;
}
.custom-cursor-asterisk {
  display: block;
  width: 18px;
  height: auto;
  /* No animation here — rotation is JS-driven (see raf() in
     initCustomCursor(), main.js) so its angular speed can react to
     mouse movement speed, which a fixed-duration CSS animation can't
     do without an abrupt phase jump every time the duration changes. */
}

/* ===================================================================
   Article pages (generated by scripts/build_articles.py from the
   markdown files under content/articles/) — a simple, static reading
   layout: illustration, body copy, credits. No scroll-reveal/intro
   choreography of its own,
   matching every other new page on the site (see templates/_header.html).
=================================================================== */
.article-page {
  max-width: 900px;
  margin: 0 auto;
  /* Clears the fixed .site-header (70px) with genuine breathing room
     above the title, rather than the title sitting flush under the bar. */
  padding: 150px 100px 0;
}
.article-page__eyebrow {
  /* Same size/weight/tracking as .hero__eyebrow — this is the same "short
     label above a headline" role, just not part of that element's own
     JS-driven reveal mechanics. */
  font-size: 18px;
  font-weight: 400;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-red);
}
.article-page__title {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(36px, 5vw, 64px);
  line-height: 1.1;
  margin-top: 12px;
  text-wrap: balance;
}
.article-page__byline {
  margin-top: 16px;
  font-size: var(--text-small);
  color: var(--color-dark);
}
.article-page__illustration-wrap {
  margin-top: 48px;
}
.article-page__illustration-wrap img {
  width: 100%;
  height: auto;
  /* Same radius as .split-cta__illustration-wrap — the site's one
     established "photo container" corner treatment. */
  border-radius: 24px;
}
.article-page__illustration-caption {
  margin-top: 12px;
  font-size: var(--text-small);
  color: var(--color-dark);
  opacity: 0.7;
}
.article-page__body {
  max-width: 720px;
  margin: 48px auto 0;
}
.article-page__body p {
  margin-top: 24px;
}
.article-page__body p:first-child {
  margin-top: 0;
}
.article-page__body a {
  text-decoration: underline;
  text-underline-offset: 3px;
}
.article-page__body a:hover {
  color: var(--color-red);
}
.article-page__body h2,
.article-page__body h3 {
  font-family: var(--font-display);
  font-weight: 400;
  margin-top: 40px;
}
.article-page__body h2 { font-size: 32px; }
.article-page__body h3 { font-size: 26px; }
.article-page__body ul {
  margin-top: 24px;
  padding-left: 1.2em;
  list-style: disc;
}
.article-page__body li + li {
  margin-top: 8px;
}
/* Pull-quotes: same display-face treatment as .quote-block (the
   homepage's own "Empowering voices previously unheard." moment) —
   reused here rather than invented fresh, so a quote embedded in an
   article reads consistently with the rest of the site. */
.article-page__body blockquote {
  text-align: center;
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 3vw, 2.2rem);
  margin: 48px auto;
  max-width: 700px;
}
.article-page__body blockquote p {
  margin-top: 0;
}
.article-page__credits {
  max-width: 720px;
  margin: 64px auto 0;
  padding-top: 32px;
  border-top: 1px solid var(--color-tan);
  display: flex;
  flex-wrap: wrap;
  gap: 24px 48px;
  font-size: var(--text-small);
}
.article-page__credit-label {
  text-transform: uppercase;
  letter-spacing: 0.04em;
  opacity: 0.6;
  margin-bottom: 4px;
}
@media (max-width: 720px) {
  .article-page { padding: 120px 24px 0; }
  .article-page__body,
  .article-page__credits { max-width: none; }
}

/* ===================================================================
   about.html / get-involved.html — deliberately just the shared header
   + page background for now (no footer, per explicit request), as
   placeholders for the "Learn more" / "Get involved" links to actually
   lead somewhere instead of nowhere. min-height keeps the empty page
   from collapsing to the header's own height.
=================================================================== */
.static-page {
  min-height: 100vh;
  /* No top clearance needed here (unlike .article-page's own 150px,
     which really does sit flush under the fixed header) — both pages
     now have their own .hero (hero--compact, copied from
     publications.html) directly above <main>, which already provides
     both the header clearance AND its own margin-bottom spacing before
     this starts. Stacking padding-top here on TOP of that (padding and
     a sibling's margin never collapse) would reproduce the exact same
     bug fixed on .publications-main — see that rule's own comment. */
  padding-top: 0;
}

/* ===================================================================
   Section eyebrow — the page-wide section-label convention on
   publications.html (Volumes, Spotlight, More to read, and every
   build_articles.py-generated category heading), replacing the
   .marquee-banner strips that page used to use for the same job. A
   plain rule-line + small-caps label reads lighter/more editorial,
   appropriate for a page that's mostly listings rather than one big
   banner moment per section.
=================================================================== */
.section-eyebrow {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 24px;
}
.section-eyebrow span:first-child {
  font-size: 18px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  /* Was --color-red — per explicit request, section titles (Volumes,
     Spotlight, More to read, and every per-category label on
     interviews/essays/narratives/outreach/publications.html) now read
     in the same brown as the rest of the page's deep text. */
  color: var(--color-dark);
  /* No explicit weight before this — these titles were quietly
     inheriting body's own Light (300) default. Bumped to 500 (Medium)
     as a "slightly thicker" test, per explicit request to see these
     specific section titles rendered heavier — Newsreader is a real
     variable font (wght 200-800), so 500 is its own genuinely
     interpolated weight. Revert to removing this line (falls back to
     inherited 300) if it doesn't read right. */
  font-weight: 500;
  white-space: nowrap;
}
.section-eyebrow__rule {
  flex: 1;
  height: 1px;
  background: var(--color-tan-dark);
}
.section-eyebrow--secondary {
  margin-top: 56px;
}

/* ===================================================================
   Spotlight (publications.html only) — 1 lead article + a "more to
   read" grid, sitting between the Volumes section above and the
   per-category listings below. The "more to read" grid reuses
   .article-teaser-grid/.article-teaser from the category listings
   further down (same title/summary/meta treatment as every other
   article card on the page), so only the lead + tag need their own
   rules here.
=================================================================== */
.spotlight {
  /* Was 100px — per explicit request, now spans the same width as this
     page's own section 1 (.publications__grid's 40px-extra-on-top-of-
     --page-margin inset, i.e. 40px here). */
  padding: 0 40px;
  margin: 60px 0;
}
.spotlight__lead {
  display: grid;
  grid-template-columns: 1.1fr 1fr;
  gap: 40px;
  align-items: stretch;
}
.spotlight__lead-art {
  border-radius: 15px;
  min-height: 340px;
  position: relative;
  overflow: hidden;
}
.spotlight__lead-art-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* Bottom-up scrim so the label reads over whatever's directly behind
   it, same purpose as .featured-carousel__scrim (that one's top-down,
   for a top-left caption instead of this bottom-left one). */
.spotlight__lead-art-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent 45%);
}
.spotlight__lead-art-label {
  position: absolute;
  left: 24px;
  bottom: 24px;
  font-size: var(--text-small);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-tan);
}
.spotlight__lead-body {
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.spotlight__tag {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-red);
  margin: 0 0 14px;
}
.spotlight__tag--interview {
  color: var(--color-grey);
}
.spotlight__lead-title {
  font-family: var(--font-display);
  /* Explicit — h2's own UA-default bold otherwise stacks on top of
     Instrument Serif's already-heavier display cut, reading noticeably
     thicker than every other headline on the site. */
  font-weight: 400;
  font-size: 40px;
  line-height: 1.15;
  margin: 0 0 10px;
}
.spotlight__lead-title a {
  color: var(--color-dark);
  text-decoration: none;
}
.spotlight__lead-title a:hover {
  color: var(--color-red);
}
.spotlight__lead-sub {
  font-family: var(--font-body);
  font-style: italic;
  font-size: 20px;
  color: var(--color-dark);
  opacity: 0.75;
  margin: 0 0 16px;
}
.spotlight__teaser {
  /* Was --text-body — per explicit request, sized down to the design
     system's own next-smallest step (the only 2 body/UI sizes this site
     uses are --text-body and --text-small; see the :root comment on
     that scale). */
  font-size: var(--text-small);
  line-height: 1.5;
  margin: 0 0 16px;
}
.spotlight__byline {
  font-size: var(--text-small);
  opacity: 0.6;
}
@media (max-width: 720px) {
  .spotlight {
    padding: 0 24px;
  }
  .spotlight__lead {
    grid-template-columns: 1fr;
  }
}

/* ===================================================================
   Publications listing (generated) — one .marquee-banner (the site's
   existing section-label convention, see #publications on index.html)
   per article category, each followed by a grid of teaser cards.
=================================================================== */
/* No top clearance needed here (unlike .article-page's own 150px/120px,
   which really does sit flush under the fixed header) — publications.html
   now has its own .hero (hero--compact, see style.css) directly above
   <main>, which already provides both the header clearance AND its own
   margin-bottom spacing before this starts. Stacking padding-top here
   on TOP of that (padding and a sibling's margin never collapse) left
   the gap below the hero much bigger than the gap above it, reading as
   badly off-center overall even with the hero's own content correctly
   centered within itself. */
.publications-main {
  padding-top: 0;
}
.publications-category {
  padding: 0 100px;
  /* Previously came from the marquee strip's own padding sitting above
     each category (now replaced by .section-eyebrow, which carries no
     top spacing of its own) — restoring it here instead so categories
     still have breathing room between them. */
  margin-top: 48px;
}
.article-teaser-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 32px;
  margin: 32px 0;
}
.article-teaser {
  display: block;
  text-align: left;
}
.article-teaser__cover {
  aspect-ratio: 4 / 3;
  border-radius: 15px;
  overflow: hidden;
  background: var(--color-tan);
}
.article-teaser__cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.article-teaser__title {
  margin-top: 16px;
  font-family: var(--font-display);
  font-size: 28px;
  line-height: 1.2;
}
.article-teaser:hover .article-teaser__title {
  color: var(--color-red);
}
.article-teaser__summary {
  margin-top: 8px;
  font-size: var(--text-small);
  opacity: 0.8;
}
.article-teaser__meta {
  margin-top: 8px;
  font-size: var(--text-small);
  opacity: 0.6;
}
.publications-category__empty {
  margin: 32px 0;
  font-size: var(--text-small);
  opacity: 0.6;
  text-align: center;
}
@media (max-width: 720px) {
  .publications-main { padding-top: 120px; }
  .publications-category { padding: 0 24px; }
  .article-teaser-grid { grid-template-columns: repeat(2, 1fr); }
}

/* ===================================================================
   Applications toast — bottom-right "Applications are open!" banner
   (initApplicationsToast() in main.js). Built in JS, not hand-written
   per page, same "generated, not duplicated" reasoning as the edition
   lightbox above. Animates in once this page's own intro sequence has
   genuinely finished (the homepage's real hero choreography, or the
   near-immediate equivalent on every other page — see that function's
   own comment), self-dismisses after 30s or on its own close button.
=================================================================== */
/* Positioning shell only — deliberately carries NO background/border of
   its own now (see .applications-toast__surface below for the actual
   visible card). A negative z-index on ::before was tried first and
   DOESN'T work for "behind THIS element's own background" the way it
   sounds like it should: per the CSS stacking spec, an element's own
   background is always painted first/bottom within its own stacking
   context, BEFORE any child at any z-index, negative included — a
   negative-z child only ever sinks below OTHER normal-flow content, never
   below its own parent's background. .publication-card::before achieves
   the "peeking out from behind" look a different way: THAT parent also
   carries no background of its own, so its ::before and its real
   .publication-card__cover sibling are just two plain same-level layers,
   and the cover (later in source order) naturally paints over the
   pseudo — no z-index involved at all. Mirroring that structure here
   (background moved onto a real __surface child, ::before staying on
   this backgroundless parent) is what actually gets the red rectangle
   behind the cream card. */
.applications-toast {
  position: fixed;
  right: 24px;
  bottom: 24px;
  z-index: 5000;
  max-width: min(480px, calc(100vw - 48px));
  /* The entrance: clip-path reveals this shell (and BOTH its children —
     __surface, the visible cream card, AND ::before, the red peek behind
     it — see that rule's own comment for why clipping THIS ancestor
     specifically is what keeps the two in sync) starting from a small
     square flush against the right edge, growing to the full box.
     initApplicationsToast() in main.js measures the real natural
     width/height, sets the starting square clip, then swaps it for the
     fully-open clip once .is-open lands.

     Deliberately NOT a `width` transition on __surface (an earlier
     version of this did that instead): animating actual layout width
     meant __surface's flex content (justify-content:space-between, see
     that rule) had genuinely different free space to distribute at
     every frame, so __close visibly hitched/jumped the moment the
     growing box crossed the content's own natural width — confirmed
     live, reported as the close button ending up "too far right,"
     since that jump landed it past where the padding-respecting
     space-between position was ever meant to put it. clip-path never
     touches layout at all: __surface renders at its true final size for
     the ENTIRE animation, so space-between places __close in its real
     resting spot (flush against the padding, no extra margin hack
     needed — see __surface's own comment) from frame one, and only the
     PAINTED reveal grows. Nothing left to jump.

     No `.is-open { clip-path: ... }` rule here on purpose: main.js sets
     BOTH the starting square AND the fully-open target directly via
     inline style (el.style.clipPath), and an inline style always wins
     over a stylesheet rule regardless of class — a CSS-declared target
     for .is-open would just be dead weight, never actually able to
     override JS's own later inline value. This transition declaration
     is the only thing that needs to live here: it's what makes each of
     those inline value changes actually animate. */
  transition: clip-path 0.5s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Same "solid colored rectangle peeking out from behind" trick as
   .publication-card::before, not a blurred box-shadow — a flat,
   un-blurred var(--color-red) card sitting directly behind
   .applications-toast__surface, offset straight down (matching this
   site's own shadow convention elsewhere: 0 12px 20px / 0 40px 45px,
   always pure-vertical, never diagonal) so it shows only as a red sliver
   below the bottom edge. inset:0 sizes it to match .applications-toast's
   own box exactly, which — since that parent carries no padding/border
   of its own — shrink-wraps to exactly __surface's own rendered size. */
.applications-toast::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 16px;
  background: var(--color-red);
  /* 5px — the resting sliver size, shown at all times now (no hover
     reveal). */
  transform: translateY(5px);
}
/* The actual visible card — see .applications-toast's own comment above
   for why this lives on a separate child rather than the fixed-position
   shell itself. position:relative + being LATER in source order than
   ::before (not z-index) is what lands this on top of the red rectangle. */
.applications-toast__surface {
  position: relative;
  display: flex;
  align-items: center;
  gap: 20px;
  /* Vertical padding brought down from 26px per explicit request ("too
     tall") — horizontal stays at 30px, unrelated to height. The square
     entrance's starting size (main.js reads this element's own real
     offsetHeight) shrinks right along with it automatically, no JS
     change needed there. */
  padding: 16px 30px;
  border-radius: 16px;
  background: var(--color-cream);
  border: 1.5px solid var(--color-red);
  /* space-between, unconditionally (not just at the ≤480px full-width-bar
     breakpoint): above that breakpoint __surface shrink-wraps to its own
     content (see .applications-toast's own comment on why), so free
     space is ~0 and this is a no-op there — text and __close just sit
     adjacent, same as flex-start would give. At ≤480px this is what
     actually pins __close to the row's real right end, flush against
     the padding (30px, same inset the text itself sits at) with no
     extra margin trick needed — see .applications-toast's own comment
     for why an earlier margin-right:-30px override existed and why it's
     gone now: this only works cleanly because __surface's WIDTH never
     animates (the grow is a clip-path on the ancestor instead, see
     that rule) — space-between over a constant width has the same free
     space on every frame, so __close never has to jump partway through
     the entrance the way it did when the container itself was growing
     out from under it. */
  justify-content: space-between;
}
.applications-toast__text {
  margin: 0;
  font-family: var(--font-body);
  font-size: clamp(16px, 2.4vw, 19px);
  color: var(--color-dark);
  line-height: 1.4;
  /* __surface itself always renders at full size now (see
     .applications-toast's own comment on why the grow is a clip-path,
     not a real width change) — this isn't load-bearing for the
     animation anymore, just a plain safeguard against this one-line
     message ever wrapping awkwardly at an odd viewport width. */
  white-space: nowrap;
}
.applications-toast__link {
  color: var(--color-dark);
  /* Own underline — matches the surrounding sentence's own color, so
     the underline is what actually marks it as clickable rather than a
     color difference. Same reasoning as the footer links elsewhere on
     this site, which are plain cream/tan text distinguished the same
     way. */
  text-decoration: underline;
  text-underline-offset: 2px;
}
.applications-toast__link:hover {
  color: var(--color-red);
}
/* Same crossed-2-bars technique as .edition-lightbox__close-glyph
   (see that rule's own comment for the full reasoning) — smaller here
   to match this toast's own more modest scale. */
.applications-toast__close {
  flex-shrink: 0;
  appearance: none;
  background: none;
  border: none;
  padding: 6px;
  margin: -6px;
  cursor: pointer;
}
/* "text appears at the same time" as __surface's own width grow, per
   explicit request — same duration/curve as that width transition
   above so both finish together, not one trailing the other. Shared
   between __text and __close (not the whole __surface) since the
   square START state still needs to actually show its cream/red card
   styling immediately, just with nothing legible inside it yet. */
.applications-toast__text,
.applications-toast__close {
  opacity: 0;
  transition: opacity 0.5s cubic-bezier(0.25, 1, 0.5, 1);
}
.applications-toast.is-open .applications-toast__text,
.applications-toast.is-open .applications-toast__close {
  opacity: 1;
}
.applications-toast__close-glyph {
  position: relative;
  display: block;
  width: 16px;
  height: 16px;
}
.applications-toast__close-glyph::before,
.applications-toast__close-glyph::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 16px;
  /* Thinner per explicit request (was 2px). */
  height: 1.5px;
  background-color: var(--color-dark);
  transition: background-color 0.2s ease-out;
}
.applications-toast__close-glyph::before {
  transform: translate(-50%, -50%) rotate(45deg);
}
.applications-toast__close-glyph::after {
  transform: translate(-50%, -50%) rotate(-45deg);
}
.applications-toast__close:hover .applications-toast__close-glyph::before,
.applications-toast__close:hover .applications-toast__close-glyph::after {
  background-color: var(--color-red);
}
@media (max-width: 480px) {
  /* Full-width-minus-margins rather than the fixed 380px cap above —
     that max-width already accounts for viewport width via calc(), but
     the RIGHT-anchored positioning alone still reads as awkwardly
     off-center on a narrow phone screen; centering it as a full-width
     bar along the bottom matches how this site already treats other
     small-viewport UI (e.g. .edition-lightbox__frame's own full-width
     behavior there). */
  .applications-toast {
    left: 16px;
    right: 16px;
    max-width: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .square,
  .image-mosaic__mask,
  .featured-carousel__mask,
  .quote-block__mask,
  .publication-card,
  /* .split-cta itself is no longer part of the opacity-reveal system
     (see the comment on that shared selector list further up) — its
     insides (tiles, illustration, text lines) are what actually need
     covering here now. */
  .split-cta__illustration-tile,
  .split-cta__illustration,
  .split-cta__text-line,
  .intro-reveal,
  .intro-reveal--slide,
  .intro-reveal--slide-slow,
  .intro-reveal--drop,
  .mosaic-reveal,
  .mosaic-reveal--slide { transition: none; opacity: 1; transform: none; }
  /* The square-grow entrance lives on .applications-toast's own
     clip-path (set inline by initApplicationsToast() in main.js, which
     checks this SAME media query and skips straight to the fully-open
     clip with no square-then-grow step when it matches) plus
     __text/__close's opacity, both neutralized here. */
  .applications-toast { transition: none; }
  .applications-toast__text,
  .applications-toast__close { transition: none; opacity: 1; }
  .image-mosaic__mask,
  .featured-carousel__mask,
  .quote-block__mask { opacity: 0; }
  /* Roles grid hover swap still needs to toggle (opacity/transform still
     differ between rest and :hover), just instantly rather than sliding. */
  .square__role,
  .square__role-description { transition: none; }
  /* Carousel nav still needs to actually move the track (it's a
     click-driven jump between slides, not an ambient animation), just
     instantly rather than sliding. */
  .featured-carousel__track { transition: none; }
  .intro-splash,
  .intro-splash__asterisk-wrap { animation: none; opacity: 0; visibility: hidden; }
  .hero__wordmark-asterisk-wrap { animation: none; opacity: 1; transform: none; }
  .hero__wordmark-asterisk { animation: none; }
  .custom-cursor-wrap { display: none; }
  .square__down-arrow { animation: none; opacity: 1; }
}
