/*
Theme Name: St. John's Episcopal School
Theme URI: https://stjes.org
Author: Joel South
Author URI: mailto:joel.south@gmail.com
Description: Custom theme for St. John's Episcopal School (STJES), a PreK-8 school in Olney, MD. Navy/off-white palette, Archivo type, wavy contour section dividers.
Version: 1.0.0
Requires at least: 6.0
Requires PHP: 7.4
License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html
Text Domain: stjes
*/

:root {
	/* Brand colors — official STJES brand guide (Style Guide Logo Font
	   Colors.docx): STJES Blue Pantone 2748 / RGB 4,26,113 / #041A71.
	   Tonal ramp recomputed proportionally from the old mockup ramp. */
	--color-navy: #041a71;
	--color-navy-rgb: 4, 26, 113;
	--color-navy-100: #e3e6ef;
	--color-navy-200: #c8cde0;
	--color-navy-300: #98a1c5;
	--color-navy-600: #031458;
	--color-navy-700: #021044;
	--color-navy-800: #020a2d;
	--color-navy-900: #01061a;
	--color-offwhite: #f3f2f2;
	--color-white: #ffffff;

	/* Official STJES Gold (Pantone 7406 / RGB 240,179,0). */
	--color-gold: #f0b300;
	--color-gold-rgb: 240, 179, 0;

	/* Every actual gold accent in the theme (nav dropdown underline,
	   tags, outline buttons, etc.) is wired to this variable rather
	   than --color-gold directly. It previously held a deliberately
	   softer, non-Pantone gold (#d7b15b); per the school's official
	   style guide it's now the same official Pantone value as
	   --color-gold above, so the two are equivalent — kept as two
	   variables only to avoid touching every call site. */
	--color-gold-accent: #f0b300;

	/* Text */
	--color-ink: #201e1d;
	--color-ink-muted: #605d5d;

	/* Surfaces */
	--color-surface-light: #f8f4f4;

	/* Rules */
	--color-divider: rgba(32, 30, 29, 0.4);

	/* Type */
	--font-heading: 'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
	--font-body: 'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;

	/* Structural constants (flat/architectural design language) */
	--rule-width: 2px;
	--radius: 0;

	/* Vertical rhythm between major page sections. These four steps
	   replace what had drifted into ten distinct hardcoded values
	   (0/40/48/56/64/88/90/96/120/160) across the section rules -- most
	   of the spread was noise rather than intent: 88 vs 90 vs 96 top
	   padding on four homepage sections that are meant to read the
	   same, none of them documented as deliberate.
	   Each value here is one already dominant in the file, so adopting
	   them is a no-op almost everywhere.
	   NOTE a floor these have to respect: wave-dividers are absolutely
	   positioned and 90px tall, clipped inside their own section by
	   overflow:hidden, so a section with a wave needs at least 90px of
	   padding on that edge or its content slides underneath. That's why
	   --space-section is 96 and not something smaller, and why
	   .cta-banner (two waves, 160px) is left out of this scale
	   entirely -- its value is doing a different job. */
	--space-section-sm: 56px;
	--space-section-md: 64px;
	--space-section: 96px;
	--space-section-lg: 120px;

	/* Height of the fixed .site-header, used to reserve/offset space for
	   it elsewhere (see .site-body--no-hero below, and
	   .subpage-banner__content's top offset further down, which also
	   has to stay in step with this by hand). Hardcoded rather than
	   JS-measured: an early attempt measured it via offsetHeight, but
	   that value proved unreliable depending on exactly when the script
	   ran relative to stylesheet/webfont loading, silently under-sizing
	   the reserved space and letting the header clip page content. A
	   fixed value has no such race — just keep it in sync by hand if the
	   header's own height changes.
	   72px = calculated, not yet measured live: a slim single-row bar —
	   12px top + 12px bottom padding, with the 48px logo (bumped up from
	   an initial 40px, which read too small) back to being the row's
	   tallest child, taller than the primary nav links' own 44px
	   min-height. Re-measure against the live site and adjust if off. */
	--header-height: 72px;
}

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

html {
	-webkit-text-size-adjust: 100%;
}

body {
	margin: 0;
	font-family: var(--font-body);
	color: var(--color-ink);
	background-color: var(--color-offwhite);
	line-height: 1.5;
}

/* The header is fixed and out of flow on every page, so it takes no
   flow space — the same as its old absolute positioning. That means a
   hero/banner already bleeds under it with no extra work (it's simply
   the first thing in the document). Pages with no hero (plain page.php)
   have no hero/banner to bleed under, so the very first content needs a
   one-time header-height buffer or it starts out hidden behind the fixed
   header at the top of the page. Sections further down the page need no
   equivalent buffer of their own — scrolling content up behind a fixed
   header as the page scrolls is normal, expected behavior (the same as
   any fixed-header site), not something to reserve extra space for. An
   earlier version added that buffer after the hero/banner too, on the
   theory that the next section's heading would otherwise end up tucked
   behind the header the moment the hero scrolled out of view — but since
   that reserved space isn't covered by any background (it lives in the
   section's margin, outside its own box), it just showed up as a stray
   blank gap between the hero's wave divider and the next section. */
.site-body--no-hero {
	padding-top: var(--header-height);
}

h1,
h2,
h3,
h4,
h5,
h6 {
	font-family: var(--font-heading);
	font-weight: 700;
	line-height: 1.1;
	margin: 0;
}

p {
	margin: 0 0 1em;
}

img {
	max-width: 100%;
	height: auto;
	display: block;
	border-radius: var(--radius);
}

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

ul,
ol {
	margin: 0;
	padding: 0;
	list-style: none;
}

button,
input,
select,
textarea {
	font-family: inherit;
	border-radius: var(--radius);
}

.screen-reader-text {
	position: absolute;
	width: 1px;
	height: 1px;
	overflow: hidden;
	clip: rect(1px, 1px, 1px, 1px);
	white-space: nowrap;
}

.screen-reader-text:focus {
	position: fixed;
	top: 8px;
	left: 8px;
	width: auto;
	height: auto;
	padding: 10px 16px;
	background: var(--color-navy);
	color: var(--color-white);
	z-index: 100;
}

/* Buttons */
.btn {
	display: inline-flex;
	align-items: center;
	justify-content: flex-start;
	gap: 6px;
	font-family: var(--font-heading);
	font-weight: 700;
	font-size: 13px;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	line-height: 1.2;
	padding: 14px 22px;
	border: 2px solid transparent;
	white-space: nowrap;
	cursor: pointer;
	transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
}

.btn-primary {
	background: var(--color-navy);
	color: var(--color-white);
	border-color: var(--color-navy);
}

/* Lift + shadow on hover -- the site's single most-used interactive
   element (header, every hero/quote-band, the CTA banner) previously had
   no motion at all beyond the color swap. Shadow color is navy-tinted
   here since this variant normally sits on a light page background, so
   a navy shadow reads correctly underneath it. */
.btn-primary:hover {
	background: var(--color-white);
	color: var(--color-navy);
	transform: translateY(-2px);
	box-shadow: 0 8px 20px rgba(var(--color-navy-rgb), 0.25);
}

/* Focus-visible outlines: keyboard-only (mouse clicks don't trigger
   :focus-visible the way plain :focus would), so this doesn't add a ring
   on every click. Two groups rather than one shared currentColor rule --
   a button's own text color is chosen to contrast against its own fill,
   not against the page behind it once outline-offset pushes the ring
   outside the button, so light-surface elements get a navy outline and
   on-navy elements (buttons/links that live inside navy sections) get a
   gold one instead. .btn-primary--on-navy/.btn-secondary--on-navy also
   match the generic .btn rule below; being declared after it is what lets
   this gold version win that specificity tie (same 0,2,0 either way). */
.btn:focus-visible,
.text-link:focus-visible,
.subpage-subnav__list a:focus-visible,
.accordion-item__summary:focus-visible {
	outline: 2px solid var(--color-navy);
	outline-offset: 3px;
}

.btn-primary--on-navy:focus-visible,
.btn-secondary--on-navy:focus-visible,
.site-footer__social-link:focus-visible,
.site-footer__back-to-top:focus-visible,
.site-footer__nav-list a:focus-visible {
	outline: 2px solid var(--color-gold-accent);
	outline-offset: 3px;
}

/* Site container */
.container {
	max-width: 1240px;
	margin: 0 auto;
	padding: 0 24px;
}

/* Site header: fixed on every page so it stays visible through scroll.
   body's padding-top (above) reserves its space; the hero/banner elements
   cancel that out to bleed seamlessly under it (see below). */
.site-header {
	position: fixed;
	left: 0;
	right: 0;
	top: 0;
	z-index: 20;
}

/* min-height, NOT height, and nowrap. Together these are what stop the
   header silently losing its own contents.

   It used to be height:72px with flex-wrap:wrap, and the two combined
   badly: when the nav no longer fit beside the logo it wrapped to a
   second line, and because the height was pinned, that second line
   rendered OUTSIDE the painted box. .site-header--solid's navy only
   ever covered 72px, so the wrapped nav landed on the page background
   below it -- white text on off-white, invisible. On overlay pages it
   landed on the dark banner photo and stayed readable, which is why
   this went unnoticed until a plain page showed it.

   nowrap keeps the logo and the nav on one line, so the nav can no
   longer jump to a line of its own. Pressure is absorbed instead by
   .primary-nav__list, which wraps its own links -- and that grows the
   row, which min-height now paints navy all the way down.

   KNOWN AND ACCEPTED CONSEQUENCE: .site-body--no-hero's padding-top is
   a hardcoded var(--header-height) and this header is position:fixed,
   so if the row ever does grow to two lines the top of the page content
   sits under it. That is a deliberate trade -- overlapping but readable
   beats invisible, and it is visibly wrong rather than silently wrong.
   Not worth a JS measurement to avoid: that was tried for the same
   value and proved unreliable depending on when the script ran relative
   to stylesheet and webfont loading (see --header-height's own note). */
.site-header__row {
	max-width: 1240px;
	margin: 0 auto;
	min-height: var(--header-height);
	padding: 12px 24px;
	display: flex;
	flex-wrap: nowrap;
	align-items: center;
	gap: 12px 20px;
}

.site-header__logo {
	display: inline-flex;
	flex-direction: column;
	/* Without this, flex's default align-items:stretch stretches the
	   logo <img> to the container's full cross-axis width (a column-flex
	   container's cross axis is horizontal) instead of letting it size
	   itself from its own height + aspect ratio — invisible at the old
	   ~166px logo width, but badly broken (near-full-viewport-width) once
	   the container had room to stretch into on mobile's single-column row. */
	align-items: flex-start;
	/* Top-aligned (not the row's own align-items:center) so the breakout
	   logo's extra height spills only downward past the row's bottom
	   edge. Centering it instead would push its top half above y:0 —
	   off-canvas above the viewport, not just past the header — since
	   .site-header sits flush against the very top of the page. */
	align-self: flex-start;
	line-height: 1.1;
	/* The row's own fixed height used to be what stopped the oversized
	   breakout logo stretching the bar. That height is now min-height
	   (see above), so the guarantee has to live here instead: this box
	   stays 48px whatever size the <img> inside it is, and the breakout
	   logo overflows it downward exactly as before rather than growing
	   the header on every overlay page. Without this, changing height to
	   min-height would have traded one visible bug for another. */
	height: 48px;
	/* And it must not be squeezed now that the row is nowrap -- the nav
	   beside it carries min-width:0 and absorbs the pressure instead. */
	flex: 0 0 auto;
}

/* Compact size by default — what the old inline height="48px" set,
   moved into CSS so it can be overridden by the breakout/scroll rules
   below. This is also the permanent size on solid-header pages (no
   hero photo for an oversized logo to break out over, so they never
   get the overlay-only rule below at all). */
.site-header__logo img {
	width: auto;
	display: block;
	filter: brightness(0) invert(1) drop-shadow(0 1px 6px rgba(0, 0, 0, 0.55));
	height: 48px;
	transition: height 250ms ease;
}

/* Breakout state: on overlay pages (hero/banner underneath), before the
   header has scrolled solid, the logo renders larger than the 72px bar
   and spills past its bottom edge onto the photo below — reuses
   header-scroll.js's existing .site-header--scrolled toggle (40px
   threshold) unchanged rather than adding a second scroll listener;
   once that class lands, this rule stops applying and the compact
   48px rule above takes back over, with the transition above
   animating the shrink. Solid-header pages never match
   .site-header--overlay, so they stay at the compact size always.
   Reduced from the original 96px to 80px, and given a second, wider
   drop-shadow underneath it (stacked onto the compact rule's own
   contrast shadow above) — at 96px with only the thin contrast shadow,
   the overflow onto the hero photo read as an unstyled rendering
   glitch rather than a deliberate accent; the smaller size leaves less
   hanging past the bar, and the added shadow grounds it against the
   photo the way a physically-placed emblem would cast one, instead of
   looking like an image that just happens to overflow its box. */
.site-header--overlay:not(.site-header--scrolled) .site-header__logo img {
	height: 80px;
	filter: brightness(0) invert(1) drop-shadow(0 1px 6px rgba(0, 0, 0, 0.55)) drop-shadow(0 14px 18px rgba(0, 0, 0, 0.4));
}

.site-header__logo-name {
	font-family: var(--font-heading);
	font-size: 18px;
	font-weight: 700;
	letter-spacing: 0.01em;
	text-transform: uppercase;
	white-space: nowrap;
}

.site-header__logo-tagline {
	font-size: 12px;
	letter-spacing: 0.14em;
	text-transform: uppercase;
	margin-top: 4px;
}

/* Quick Links: a single trigger replacing the old flat 4-link row, per the
   dev-handoff spec. Reveals its panel the same way the primary nav's
   mega-menu dropdown does (hover/focus-within on the wrapper) at desktop
   widths; below 900px, assets/js/mobile-nav.js switches this to a tap-
   toggled .is-expanded class instead (see the max-width:900px override),
   since the wrapper now sits inside a static mobile overlay rather than
   an absolutely-positioned hover panel. */
.quick-links {
	position: relative;
}

/* Typography matched to .primary-nav__list a (font-size, letter-spacing,
   min-height, transparent border-bottom) so Quick Links reads as one of
   the nav items rather than a visibly smaller/different afterthought —
   it's a <button> instead of an <a> only because it opens a panel
   in place rather than navigating, not because it's meant to look
   different. No gap: the ::after chevron (see the shared rule further
   down, matching .primary-nav__list li.menu-item-has-children > a
   exactly) provides its own margin-left, same as those items — a gap
   here too would double up the spacing beyond what they use. */
.quick-links__trigger {
	display: inline-flex;
	align-items: center;
	min-height: 44px;
	background: none;
	border: none;
	border-bottom: 3px solid transparent;
	padding: 0;
	font: inherit;
	cursor: pointer;
	font-size: 14px;
	font-weight: 700;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: inherit;
}

/* Same gold-underline-on-open treatment as a menu-item-has-children
   trigger (.primary-nav__list li.menu-item-has-children:hover > a) —
   Quick Links behaves the same way (hover/focus reveals a panel), so it
   gets the same hover feedback. */
.quick-links:hover .quick-links__trigger,
.quick-links:focus-within .quick-links__trigger {
	border-bottom-color: var(--color-gold-accent);
}

.quick-links__panel {
	position: absolute;
	top: 100%;
	right: 0;
	min-width: 220px;
	margin-top: 12px;
	padding: 8px 0;
	background: var(--color-white);
	border-top: 2px solid var(--color-navy);
	box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
	opacity: 0;
	visibility: hidden;
	transform: translateY(-6px);
	transition: opacity 200ms ease, transform 200ms ease, visibility 0s 200ms;
}

.quick-links:hover .quick-links__panel,
.quick-links:focus-within .quick-links__panel {
	opacity: 1;
	visibility: visible;
	transform: translateY(0);
	transition: opacity 200ms ease, transform 200ms ease, visibility 0s;
}

.quick-links__panel li {
	border-bottom: 1px solid var(--color-navy-100);
}

.quick-links__panel li:last-child {
	border-bottom: none;
}

/* Qualified with .site-header + .quick-links (3 classes), same fix as
   .site-header .nav-dropdown .nav-dropdown__links a above: a 2-class
   .site-header .quick-links__panel a selector ties in specificity with
   the overlay/solid header's white-text rule (.site-header--overlay
   .primary-nav__list a etc.), which also matches these links since
   they're nested inside .primary-nav__list — and being later in the
   file, white was winning that tie, leaving Parent Portal / St. John's
   Episcopal Church (and, depending on cascade order, any of the 4)
   rendering white-on-white. */
.site-header .quick-links .quick-links__panel a {
	display: block;
	min-height: 0;
	padding: 10px 20px;
	font-size: 13px;
	font-weight: 600;
	letter-spacing: 0.04em;
	text-transform: uppercase;
	color: var(--color-navy);
	text-shadow: none;
	white-space: nowrap;
}

.site-header .quick-links .quick-links__panel a:hover {
	background: var(--color-surface-light);
}

/* Hamburger toggle: hidden by default (desktop keeps the inline nav
   row/hover-dropdowns exactly as before), shown only below 900px (see
   that media query) in place of the nav row, which switches to a
   full-screen overlay there (see .primary-nav's own mobile override).
   White bars unconditionally, not tied to the overlay/solid header
   color rules below -- mobile forces the header solid navy in both
   variants (see the max-width:900px .site-header--overlay rule), so
   white always has enough contrast here. */
.mobile-nav-toggle {
	display: none;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	gap: 5px;
	width: 44px;
	height: 44px;
	margin-left: auto;
	padding: 0;
	background: none;
	border: none;
	cursor: pointer;
}

.mobile-nav-toggle__bar {
	display: block;
	width: 26px;
	height: 2px;
	background: var(--color-white);
	transition: transform 200ms ease, opacity 200ms ease;
}

/* Morphs the three bars into an X -- purely a CSS response to the same
   .is-nav-open class the open/close JS toggles on .site-header, no
   separate icon-swap logic needed. */
.site-header.is-nav-open .mobile-nav-toggle__bar:nth-child(1) {
	transform: translateY(7px) rotate(45deg);
}

.site-header.is-nav-open .mobile-nav-toggle__bar:nth-child(2) {
	opacity: 0;
}

.site-header.is-nav-open .mobile-nav-toggle__bar:nth-child(3) {
	transform: translateY(-7px) rotate(-45deg);
}

/* Sits inline with the logo in .site-header__row (previously its own
   full-width row below, hence the old border-top divider — no longer
   needed with everything on one row). margin-left: auto pushes the
   nav over to the row's right edge. Request Information is the list's
   last item (see header.php), right after Quick Links, so it rides
   along on this same right edge rather than needing positioning of
   its own.

   align-self: flex-start (rather than inheriting the row's own
   align-items: center) matches .site-header__logo's own alignment —
   both opt out of centering for the same reason: .site-header__row has
   a fixed (not min-) height so the breakout logo can overflow it
   without stretching it (see that rule's comment), and centering an
   oversized child in a fixed-height flex container makes it spill
   evenly on both sides. For the logo that's the intended breakout
   effect; for nav it isn't — if .primary-nav__list's own flex-wrap
   ever wraps its items onto a second line (its natural width now runs
   close to the row's available space with Request Information in it),
   a centered nav would spill upward too, and with the header pinned at
   the very top of the viewport that upward half gets clipped off-
   screen, making nav look like it starts too low with a gap above it.
   flex-start makes any overflow spill only downward, fully visible,
   the same safe direction the logo already spills in. */
.primary-nav {
	margin-left: auto;
	min-width: 0;
	align-self: flex-start;
}

/* gap's column value has now been clawed back twice: 24px to 18px when
   the Request Information CTA joined the row, and 18px to 15px when the
   search control did. Each addition to this row costs width it does not
   really have.

   This is the belt, not the braces. Tightening buys roughly 21px across
   the seven gaps and makes wrapping less likely; it does not make it
   impossible, and the third time someone shaves this value it will be
   because something else was added. What actually guarantees the header
   stays readable is min-height on .site-header__row above -- if this
   list does wrap, the row grows and the navy grows with it.

   Worth knowing before shaving it again: at 15px the links are close to
   the point where the row reads as a solid band of text rather than
   distinct items, so the next reclaim should come from somewhere
   else. */
.primary-nav__list {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 8px 15px;
}

.primary-nav__list li {
	position: relative;
}

/* :where(:not(.btn)) excludes the Request Information CTA (the list's
   last item, see header.php) — it keeps its own .btn/.btn-primary
   sizing, padding and border rather than inheriting the plain nav-link
   treatment here. Wrapped in :where() specifically so the exclusion
   adds zero specificity: a bare :not(.btn) counts as a class for
   specificity purposes, which would push this rule (and the matching
   overlay/solid white-text rules below) up to tie with — and, being
   later in the file, beat — the dropdown-link color fixes further up
   (.nav-dropdown__links a / .quick-links__panel a), silently making
   their text white-on-white again. :where() keeps the exclusion
   without that side effect. */
.primary-nav__list a:where(:not(.btn)) {
	display: inline-flex;
	align-items: center;
	min-height: 44px;
	font-size: 14px;
	font-weight: 700;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	border-bottom: 3px solid transparent;
}

.primary-nav__list li.current-menu-item > a,
.primary-nav__list li.current-menu-ancestor > a {
	border-bottom-color: currentColor;
}

/* Tighter than .btn's default 14px 22px — only in the nav, not the
   page-content CTAs that reuse .btn.btn-primary elsewhere (About's
   welcome CTA, Admissions, etc.), which keep the full padding. Part of
   the same width reclaim as .primary-nav__list's reduced gap above. */
.primary-nav__list .primary-nav__cta .btn {
	padding: 12px 14px;
}

/* Dropdown (mega-menu-lite): auto-populated from a top-level item's child
   pages — see stjes_render_primary_nav(). Revealed on hover/focus at
   desktop widths; below 900px, assets/js/mobile-nav.js tap-toggles an
   .is-expanded class on the parent <li> instead (see the max-width:900px
   override), since this now sits inside a static mobile overlay rather
   than an absolutely-positioned hover panel.
   Gold underline on the trigger while its panel is open — higher
   specificity than the current-page underline above, so it wins whenever
   both apply (e.g. hovering the section you're already on).
   Quick Links shares this exact chevron (was its own smaller, rotating
   Unicode triangle glyph — .quick-links__caret, removed) so its arrow
   matches the other 5 nav items' style/size exactly, not a visibly
   different mark. */
.primary-nav__list li.menu-item-has-children > a::after,
.quick-links__trigger::after {
	content: "";
	display: inline-block;
	width: 6px;
	height: 6px;
	margin-left: 8px;
	border-right: 2px solid currentColor;
	border-bottom: 2px solid currentColor;
	transform: translateY(-2px) rotate(45deg);
}

.primary-nav__list li.menu-item-has-children:hover > a,
.primary-nav__list li.menu-item-has-children:focus-within > a {
	border-bottom-color: var(--color-gold-accent);
}

.nav-dropdown {
	position: absolute;
	top: 100%;
	left: 0;
	display: grid;
	grid-template-columns: 220px 280px;
	background: var(--color-white);
	border-top: 2px solid var(--color-navy);
	box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
	opacity: 0;
	visibility: hidden;
	transform: translateY(-6px);
	transition: opacity 200ms ease, transform 200ms ease, visibility 0s 200ms;
}

.primary-nav__list li.menu-item-has-children:hover > .nav-dropdown,
.primary-nav__list li.menu-item-has-children:focus-within > .nav-dropdown {
	opacity: 1;
	visibility: visible;
	transform: translateY(0);
	transition: opacity 200ms ease, transform 200ms ease, visibility 0s;
}

.nav-dropdown__links {
	padding: 8px 0;
}

.nav-dropdown__links li {
	border-bottom: 1px solid var(--color-navy-100);
}

.nav-dropdown__links li:last-child {
	border-bottom: none;
}

/* Qualified with .site-header + .nav-dropdown (3 classes) rather than
   just .site-header + .nav-dropdown__links (2 classes): the overlay
   header's white-text rule further down is also a 2-class selector
   targeting `a`, and being later in the file it was winning that
   specificity tie, leaving these links rendering white-on-white. */
.site-header .nav-dropdown .nav-dropdown__links a {
	display: block;
	min-height: 0;
	padding: 12px 20px;
	font-size: 13px;
	font-weight: 600;
	letter-spacing: 0.04em;
	text-transform: uppercase;
	color: var(--color-navy);
	text-shadow: none;
	border-bottom: none;
	white-space: normal;
}

.site-header .nav-dropdown .nav-dropdown__links a:hover {
	background: var(--color-surface-light);
}

.nav-dropdown__feature {
	padding: 20px;
	background: var(--color-surface-light);
	border-left: 1px solid var(--color-navy-100);
	/* Clips the image's hover-zoom (below) to this panel's edges --
	   same containment role .pillar-card/.feature-card's own overflow:
	   hidden plays for their own hover-zoomed images, which also sit in
	   the same element as the card's text content underneath. */
	overflow: hidden;
}

/* Qualified with the parent class (2 classes) rather than just
   .nav-dropdown__image alone: a grayscale utility class once applied to
   this same image set height:100%, and being later in the file it was
   winning the specificity tie against a bare single-class selector, so
   the fixed 140px height never applied. That utility class is gone now
   (removed theme-wide), but this stays qualified since there's no
   reason to unwind it just because the tie it was defending against no
   longer exists. */
.nav-dropdown__feature .nav-dropdown__image {
	width: 100%;
	height: 140px;
	object-fit: cover;
	margin-bottom: 12px;
	transform: scale(var(--stjes-photo-zoom, 1));
	transition: transform 400ms ease;
}

/* Hover-zoom, matching the same scale(1.06)/400ms treatment already
   used on .feature-card__image, .pillar-card img, .division-card img,
   etc. This image isn't itself inside an <a> (unlike those), so the
   trigger keys off the same parent-item hover/focus-within that already
   reveals the whole .nav-dropdown panel and drives its "Learn More"
   link, rather than a :hover on the image's own non-existent link. */
.primary-nav__list li.menu-item-has-children:hover .nav-dropdown__image,
.primary-nav__list li.menu-item-has-children:focus-within .nav-dropdown__image {
	transform: scale(calc(var(--stjes-photo-zoom, 1) * 1.06));
}

.nav-dropdown__text {
	margin: 0 0 12px;
	font-size: 13px;
	line-height: 1.5;
	color: var(--color-ink-muted);
	transition: opacity 120ms ease;
}

/* Qualified with .nav-dropdown too (same specificity-tie reasoning as
   .nav-dropdown__links a above): a bare .site-header .nav-dropdown__more
   loses a tie against the overlay header's white-text rule since that
   one also carries an implicit `a` element in its selector. */
.site-header .nav-dropdown .nav-dropdown__more {
	display: inline-block;
	min-height: 0;
	padding: 0;
	font-size: 12px;
	font-weight: 700;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--color-navy);
	text-shadow: none;
	border-bottom: 2px solid var(--color-gold-accent);
	white-space: normal;
	transition: opacity 120ms ease, border-color 200ms ease;
}

/* The other half of the tertiary tier's missing hover (see .text-link
   above). Inverted rather than matching it, because this one's resting
   underline is ALREADY gold -- so it moves to currentColor instead,
   which resolves to navy against the desktop panel's white background
   and to white inside the mobile overlay, staying legible in both
   without a context-specific rule.
   Qualified to 3 classes + :hover so it outranks both the base rule
   above and the mobile color override, same specificity-tie reasoning
   as that base rule's own comment. Opacity is deliberately untouched:
   assets/js/nav-dropdown-blurb.js drives it inline for the blurb
   crossfade, and a competing hover on the same property would fight
   it. */
.site-header .nav-dropdown .nav-dropdown__more:hover {
	border-bottom-color: currentColor;
}

/* Overlay variant: floats transparently over a hero image (front page,
   or any template that forces overlay mode via the stjes_header_overlay
   filter). Fixed (not absolute) so it stays pinned through scroll; the
   scrolled state below swaps it solid once the hero has scrolled past. */
.site-header--overlay {
	background: linear-gradient(180deg, rgba(var(--color-navy-rgb), 0.55) 0%, rgba(var(--color-navy-rgb), 0.25) 65%, rgba(var(--color-navy-rgb), 0) 100%);
	transition: background 250ms ease, box-shadow 250ms ease;
}

.site-header--overlay.site-header--scrolled {
	background: var(--color-navy);
	box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
}

.site-header--overlay .site-header__logo-name,
.site-header--overlay .site-header__logo-tagline,
.site-header--overlay .quick-links__trigger,
.site-header--overlay .site-search__toggle,
.site-header--overlay .site-search__input,
.site-header--overlay .primary-nav__list a:where(:not(.btn)) {
	color: var(--color-white);
	text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}

/* Solid variant: navy bar for pages without a hero image. */
.site-header--solid {
	background: var(--color-navy);
}

.site-header--solid .site-header__logo-name,
.site-header--solid .site-header__logo-tagline,
.site-header--solid .quick-links__trigger,
.site-header--solid .site-search__toggle,
.site-header--solid .site-search__input,
.site-header--solid .primary-nav__list a:where(:not(.btn)) {
	color: var(--color-white);
}

/* Both header variants give the CTA the same inverted treatment (white
   fill on the bar, outlined on hover) -- these were two byte-identical
   rule pairs, one per variant, merged here since neither the overlay's
   photo-scrim background nor the solid navy bar wants anything
   different from the other. Declared after both variants' own blocks
   above so it reads in place for either.
   Deliberately NOT folded into .btn-primary--on-navy further down,
   which carries the same three declarations: that variant's hover adds
   a translateY lift and shadow the header CTA doesn't have, so merging
   the two would change the header's behavior rather than just dedupe
   it. */
.site-header--overlay .btn-primary,
.site-header--solid .btn-primary {
	background: var(--color-white);
	color: var(--color-navy);
	border-color: var(--color-white);
}

.site-header--overlay .btn-primary:hover,
.site-header--solid .btn-primary:hover {
	background: transparent;
	color: var(--color-white);
	border-color: var(--color-white);
}

.eyebrow {
	margin: 0 0 12px;
	color: var(--color-navy-700);
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

.text-link {
	display: inline-block;
	font-weight: 700;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	font-size: 12px;
	border-bottom: 2px solid var(--color-navy);
	padding-bottom: 2px;
	transition: border-color 200ms ease, color 200ms ease;
}

/* This had no hover state at all -- the only interactive element family
   on the site without one. Gold underline on hover matches what the nav
   items and Quick Links trigger already do (border-bottom-color -> gold
   on hover), so the tertiary tier gives the same feedback as the header
   does, rather than inventing a new one.
   Note the .intro-section--has-bg variants (3 classes) outrank this
   one, so that instance gets its own matching hover alongside them. */
.text-link:hover {
	border-bottom-color: var(--color-gold-accent);
}

/* pointer-events:none -- purely decorative (aria-hidden on every
   instance), but without this its full rectangular bounding box (not
   just the visible painted curve) sits above whatever's underneath it
   at z-index:2, silently swallowing clicks in the overlap. Confirmed via
   direct hit-testing: the footer's own Back to Top button sits partly
   inside its top wave-divider's box (button spans the footer's first
   32px of content, y64-96; the wave's box runs y-1-89), so a click
   aimed at the button's own center never reached it at all. */
.wave-divider {
	position: absolute;
	left: 0;
	bottom: -1px;
	width: 100%;
	height: 90px;
	z-index: 2;
	display: block;
	pointer-events: none;
}

/* Hero slider (front page). The fixed header takes no flow space (same
   as the old absolute positioning), so this needs no margin trick to
   bleed under it — it's simply the first thing in the document. The
   section that follows it (see the adjacent-sibling rule further down)
   is what gets the header-height buffer, so scrolling the hero fully out
   of view doesn't leave the next heading tucked behind the header. */
.hero-slider {
	position: relative;
	height: 760px;
	overflow: hidden;
	background: var(--color-navy-900);
}

.hero-slide {
	position: absolute;
	inset: 0;
	opacity: 0;
	visibility: hidden;
	pointer-events: none;
	transition: opacity 600ms ease, visibility 0s 600ms;
}

.hero-slide.is-active {
	opacity: 1;
	visibility: visible;
	pointer-events: auto;
	transition: opacity 600ms ease, visibility 0s;
}

.hero-slide__image {
	width: 100%;
	height: 100%;
	object-fit: cover;
	transform: scale(var(--stjes-photo-zoom, 1));
}

/* Darkest at the right edge (where the text sits), fading toward
   transparent on the left, so the photo still reads full-color across
   most of its width instead of a flat all-over tint. */
.hero-slide__scrim {
	position: absolute;
	inset: 0;
	background: linear-gradient(270deg, rgba(19, 18, 17, 0.88) 0%, rgba(19, 18, 17, 0.55) 42%, rgba(19, 18, 17, 0.1) 65%);
}

/* bottom:60px (was 90px) gives more top clearance before a tall/
   wrapped headline can reach up into the header. NOTE: this was set
   when Request Information had its own row below the main 72px bar,
   extending the header's initial (non-scrolled) height further down
   than 72px alone — Request Information is inline in the nav row now
   (see header.php / .primary-nav__cta), so the header is back to a
   flat 72px and this value may have more headroom to give back than
   it needs; left as-is since re-tuning it wasn't part of this change. */
.hero-slide__content {
	position: absolute;
	right: 0;
	bottom: 60px;
	z-index: 1;
	display: flex;
	flex-direction: column;
	align-items: flex-end;
	text-align: right;
	padding: 0 max(24px, calc((100vw - 1240px) / 2));
	max-width: calc(38% - 24px);
}

.hero-slide__eyebrow {
	margin: 0 0 12px;
	color: var(--color-gold-accent);
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
	text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
	opacity: 0;
	transform: translateX(48px);
}

/* 68px (was 88px) — a long headline (e.g. "Where Faith and Curiosity
   Grow Together") wraps to several lines at 88px within this column's
   narrow max-width (38% of the container), tall enough that it could
   reach up into the header above. Smaller text wraps to a shorter
   block, independent of and in addition to the extra bottom clearance
   on .hero-slide__content above. */
/* Gold, deliberately, unlike the subpage banner heading (which uses
   white -- see .subpage-banner__content h1): tried switching this to
   white as part of a sitewide gold-as-dominant-color audit, but
   reverted at the site owner's preference. Gold stays the hero's own
   treatment specifically, not a sitewide rule. */
.hero-slide__title {
	margin: 0;
	color: var(--color-gold-accent);
	font-size: 68px;
	font-weight: 800;
	line-height: 0.98;
	letter-spacing: -0.01em;
	text-transform: uppercase;
	text-shadow: 0 1px 8px rgba(0, 0, 0, 0.55);
	opacity: 0;
	transform: translateX(48px);
}

/* Eyebrow and title slide in from the right on a slight stagger when
   their slide becomes active; each toggle of .is-active re-triggers
   the animation since the rule (and so the computed animation-name)
   only applies while active. */
@keyframes stjes-hero-slide-in {
	from {
		opacity: 0;
		transform: translateX(48px);
	}
	to {
		opacity: 1;
		transform: translateX(0);
	}
}

.hero-slide.is-active .hero-slide__eyebrow {
	animation: stjes-hero-slide-in 620ms cubic-bezier(0.22, 0.61, 0.36, 1) 120ms forwards;
}

.hero-slide.is-active .hero-slide__title {
	animation: stjes-hero-slide-in 620ms cubic-bezier(0.22, 0.61, 0.36, 1) 320ms forwards;
}

/* Scroll-triggered entrance reveal (see assets/js/scroll-reveal.js):
   elements start hidden and offset to the left, and slide/fade into
   place the first time an IntersectionObserver sees them cross into
   the viewport, via the .is-visible class the script adds. The
   reveal-delay-* modifiers give a section's eyebrow/heading/body a
   slight stagger, the same idea as the hero's eyebrow/title stagger
   above but driven by scroll position instead of slide change. */
.reveal {
	opacity: 0;
	transform: translateX(-32px);
	transition: opacity 600ms ease, transform 600ms cubic-bezier(0.22, 0.61, 0.36, 1);
}

.reveal.reveal-delay-1 {
	transition-delay: 100ms;
}

.reveal.reveal-delay-2 {
	transition-delay: 200ms;
}

.reveal.is-visible {
	opacity: 1;
	transform: translateX(0);
}

@media (prefers-reduced-motion: reduce) {
	.reveal,
	.hero-slide__eyebrow,
	.hero-slide__title,
	.nav-dropdown__text,
	.nav-dropdown__more {
		opacity: 1 !important;
		transform: none !important;
		transition: none !important;
		animation: none !important;
	}
}

.hero-slide__text {
	margin: 12px 0 0;
	color: var(--color-white);
	font-size: 17px;
	line-height: 1.5;
	text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}

.hero-slider__dots {
	position: absolute;
	left: max(24px, calc((100vw - 1240px) / 2));
	bottom: 34px;
	z-index: 10;
	display: flex;
	align-items: center;
	gap: 4px;
}

.hero-slider__dot {
	width: 24px;
	height: 24px;
	padding: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	border: none;
	background: transparent;
	cursor: pointer;
}

.hero-slider__dot::before {
	content: '';
	width: 7px;
	height: 7px;
	border-radius: 50%;
	border: 1.5px solid rgba(255, 255, 255, 0.9);
	background: transparent;
	transition: background 200ms ease, transform 200ms ease, width 200ms ease;
}

.hero-slider__dot.is-active::before {
	background: var(--color-gold-accent);
	border-color: var(--color-gold-accent);
	width: 16px;
	border-radius: 5px;
}

/* Intro section. The background photo sits behind a subtle dark scrim,
   so the section text sits in white over full-color photography instead
   of the flat off-white card it used to be. No background-attachment:
   fixed here (an earlier version had it, for a parallax effect) —
   combined with filter:grayscale(1) on the same element, that's a
   documented cross-browser hazard: filter creates its own compositing
   layer, which can break how a fixed-attachment background paints. On
   top of that, background-size:cover for a fixed-attachment background
   is computed against the viewport, not against the much taller
   .intro-section box it's actually meant to cover. Together those
   produced visible white slivers where the photo didn't fully paint.
   Plain scroll attachment has none of that ambiguity: cover + inset
   unconditionally fills the element's own box, full stop — no
   parallax, but no gap either.
   A clip-path restructure was also tried here (cutting a wave-shaped
   notch into .hero-slider's own bottom edge and extending this photo up
   behind it, so the hero's wave curve would reveal the photo directly)
   and then abandoned — reverted back to the plain wave-divider SVG
   capping the hero's bottom, plus the top/bottom gradient fades below,
   which blend far more predictably without needing an unverified
   cross-browser clip-path technique nothing else in the theme uses. */
/* Top was 88px -- 8px off .student-life-section/.events-section's 96
   for no recorded reason, and invisible as a difference. On the scale
   now (see :root); this section has no wave-divider of its own, so
   nothing constrains the value beyond rhythm. */
.intro-section {
	position: relative;
	padding: var(--space-section) 0 var(--space-section-lg);
	overflow: hidden;
}

/* Mobile-first base: plain scroll attachment. background-attachment:
   fixed is desktop-only (see the min-width:901px block below) —
   fixed backgrounds are unreliable on mobile Safari (often render as
   static/cropped, showing only the top of the photo regardless of
   scroll), so mobile always gets this scrolling version instead. */
.intro-section__bg {
	position: absolute;
	inset: 0;
	background-color: var(--color-navy-900);
	background-size: cover;
	background-position: center 8%;
	background-repeat: no-repeat;
	background-attachment: scroll;
	filter: grayscale(1);
}

/* transform lives ONLY here, never on the base rule above -- confirmed
   live in Chromium that even an identity transform (scale(1), what
   this resolves to whenever zoom is at its default) breaks
   background-attachment:fixed's actual painting, despite
   getComputedStyle still correctly reporting "fixed" as the resolved
   value. Since this modifier class and the desktop fixed-attachment
   rule below are already mutually exclusive (front-page.php only adds
   this class when the photo's zoom differs from 100 -- see that file's
   comment), scoping the transform property here means it genuinely
   doesn't exist in the CSS at all for the default/parallax case, not
   just resolve to a harmless value. */
.intro-section__bg--no-parallax {
	transform: scale(var(--stjes-photo-zoom, 1));
}

/* Dark scrim (legibility for the white text on top) plus a soft
   off-white fade at the very top and bottom edges, so the transition
   into the hero's wave-divider above and the Academics wave-divider
   below reads as a blend rather than a hard cut from photo to flat
   color. rgba(243, 242, 242, ...) is --color-offwhite spelled out in
   rgb — gradients need the fade-out end to share the same RGB and
   only vary alpha, or interpolating through CSS's "transparent"
   keyword (which is transparent *black*) would visibly darken/gray
   out the middle of the fade instead of just thinning it.
   Desktop replaces this whole overlay with a lighter wash instead —
   see the min-width:901px block below. */
.intro-section__bg::after {
	content: '';
	position: absolute;
	inset: 0;
	background:
		linear-gradient(to bottom, rgba(243, 242, 242, 1) 0, rgba(243, 242, 242, 0) 120px),
		linear-gradient(to top, rgba(243, 242, 242, 1) 0, rgba(243, 242, 242, 0) 120px),
		rgba(19, 18, 17, 0.15);
}

/* Desktop-only: scroll-in-place background (background-attachment:fixed)
   under a much lighter, increasingly-white gradient wash — replaces the
   dark scrim + edge fades above entirely, rather than layering on top
   of them. Text over this section switches to dark ink to match, since
   white text (the mobile/base treatment, sized for a dark scrim) would
   be close to unreadable against a backdrop this light.
   Confirmed live (not just a theoretical spec concern, and not just a
   getComputedStyle check): having a transform property on
   .intro-section__bg at all -- even one that resolves to the identity
   scale(1) -- breaks this fixed attachment's actual painting in
   Chromium, regardless of what the cascade computes
   background-attachment to. That's why transform: scale(...) (the
   focal-point zoom feature) lives ONLY on the
   .intro-section__bg--no-parallax rule now, never on the base
   .intro-section__bg rule -- the property has to be genuinely absent
   from the CSS for this photo, not just resolve to a harmless value.
   front-page.php only adds that modifier class when the photo's zoom
   actually differs from 100, so the two states stay mutually
   exclusive: parallax with no transform property at all, or zoom with
   plain scroll (same as mobile) and no fixed-attachment to break.
   filter:grayscale(1) (also on this element) was a separately-
   documented risk for the same fixed-attachment hazard, from before
   the zoom feature existed, but never actually confirmed broken on its
   own -- transform is the confirmed trigger here, not filter. */
@media (min-width: 901px) {
	.intro-section__bg:not(.intro-section__bg--no-parallax) {
		background-attachment: fixed;
	}

	.intro-section__bg::after {
		background: linear-gradient(to bottom, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0.9) 60%, #ffffff 100%);
	}
}

.intro-section > .container {
	position: relative;
}

.intro-section__grid {
	display: grid;
	grid-template-columns: 0.9fr 1.1fr;
	gap: 64px;
	align-items: start;
}

/* line-height overrides the base h1/h2 1.1, which is sized for the
   short all-caps headlines used elsewhere; this heading is a full
   sentence that wraps to two lines at 48px, where 1.1 sets them too
   tight to read as prose. */
.intro-section__heading {
	font-size: 48px;
	line-height: 1.15;
}

/* This paragraph is the site's mission statement -- the first prose a
   visitor reads -- and at 19px/1.5 it was set smaller and tighter than
   the subpage lead copy elsewhere. Larger and more open, so it reads as
   a statement rather than supporting text. */
.intro-section__body {
	color: var(--color-ink);
	font-size: 22px;
	line-height: 1.65;
}

/* Only when a background photo is actually set (see the intro_bg check in
   front-page.php) — otherwise the section falls back to the page's normal
   light background, and this white-on-dark palette would be unreadable. */
.intro-section--has-bg .eyebrow {
	color: var(--color-gold-accent);
	text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
}

.intro-section--has-bg .intro-section__heading {
	color: var(--color-white);
	text-shadow: 0 2px 16px rgba(0, 0, 0, 0.55);
}

.intro-section--has-bg .intro-section__body {
	color: rgba(255, 255, 255, 0.92);
	text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}

.intro-section--has-bg .intro-section__body .text-link {
	color: var(--color-white);
	border-bottom-color: var(--color-gold-accent);
}

/* Desktop override for the white/gold rules directly above: same
   .intro-section--has-bg selectors, so equal specificity — CSS breaks
   that tie by source order, which is *why* this block has to come
   after them rather than living up in the min-width:901px block near
   .intro-section__bg (where it previously sat and silently lost every
   tie to these later, unqualified rules, on desktop as well as
   mobile). Desktop's fixed background is a much lighter, increasingly-
   white wash (see .intro-section__bg::after above), so it switches to
   dark ink/navy text to match; mobile keeps the darker scroll scrim
   and white/gold text from the rules above, since this media query
   doesn't apply there. */
@media (min-width: 901px) {
	.intro-section--has-bg .eyebrow {
		color: var(--color-navy);
		text-shadow: none;
	}

	.intro-section--has-bg .intro-section__heading {
		color: var(--color-ink);
		text-shadow: none;
	}

	/* Full-strength ink (matching the heading right above, not the
	   muted variant) plus a modest weight bump — --color-ink-muted read
	   as too light against the photo here, even with the lighter
	   desktop wash (see .intro-section__bg::after). */
	.intro-section--has-bg .intro-section__body {
		color: var(--color-ink);
		font-weight: 600;
		text-shadow: none;
	}

	.intro-section--has-bg .intro-section__body .text-link {
		color: var(--color-navy);
		border-bottom-color: var(--color-navy);
	}

	/* Matches the generic .text-link:hover further up, which this
	   context's own 3-class rules would otherwise outrank. Only needed
	   on desktop: the mobile variant above already sits on a gold
	   underline, so there's nothing for the hover to change there. */
	.intro-section--has-bg .intro-section__body .text-link:hover {
		border-bottom-color: var(--color-gold-accent);
	}
}

.feature-grid {
	position: relative;
	margin-top: 48px;
	display: grid;
	grid-template-columns: repeat(3, minmax(0, 1fr));
	gap: 2px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

/* Full-photo hover cards: image zoom, a gold arrow badge, and a
   description that expands open — all revealed together on hover/focus. */
.feature-card {
	position: relative;
	display: block;
	height: 280px;
	overflow: hidden;
	background: var(--color-navy-800);
}

.feature-card__image {
	width: 100%;
	height: 100%;
	object-fit: cover;
	transform: scale(var(--stjes-photo-zoom, 1));
	transition: transform 400ms ease;
}

.feature-card:hover .feature-card__image,
.feature-card:focus-within .feature-card__image {
	transform: scale(calc(var(--stjes-photo-zoom, 1) * 1.06));
}

.feature-card__arrow {
	position: absolute;
	top: 20px;
	right: 20px;
	width: 34px;
	height: 34px;
	display: flex;
	align-items: center;
	justify-content: center;
	border-radius: 50%;
	background: var(--color-gold-accent);
	color: var(--color-navy);
	font-size: 15px;
	line-height: 1;
	opacity: 0;
	transform: translateY(-6px);
	transition: opacity 250ms ease, transform 250ms ease;
}

.feature-card:hover .feature-card__arrow,
.feature-card:focus-within .feature-card__arrow {
	opacity: 1;
	transform: translateY(0);
}

/* Caption box for the three full-photo overlay cards -- .feature-card
   (homepage), .academics-preview-card (homepage) and .about-pillar-card
   (About). All three were byte-identical rules declared separately in
   their own component sections; merged here, in the first/canonical one
   (the same card the other two already defer to elsewhere for the
   shared hover treatment). Neither class is overridden anywhere else in
   the file, at any breakpoint, so nothing depends on them being three
   rules.
   The gradient's rgba(11, 24, 48) is a dark desaturated navy that
   matches no token in :root -- not --color-navy (4, 26, 113) and not
   --color-navy-900 (1, 6, 26). Left as a literal rather than pointed at
   a token: swapping it for brand navy is a visible color shift, not a
   dedupe, and belongs with the wider scrim-color question (the hero and
   subpage banner use a third color again, rgba(19, 18, 17)).

   FOUR STOPS, STARTING AT ZERO. Two earlier shapes were wrong in
   different ways, both measured by rendering the live cards in headless
   Chrome and reading the composited pixels back.

   The original two-stop 0.25->0.92 ramp was too weak across the middle:
   the caption box roughly doubles in height on hover as the description
   expands, and because the stops are percentages the whole ramp
   restretches, so the text that appears on hover landed on much thinner
   scrim than the resting title did. The hover blurb measured 2.6-3.2:1
   on the three Arts photos, against the 4.5:1 a 14px paragraph needs.

   Replacing the low end with 0.30 fixed that (blurb 5.8:1) but bought
   it with a hard edge: alpha jumping 0 -> 0.30 at the top of the caption
   box darkened the photo by ~50% in a single pixel row, drawing a
   visible line across the card at 41% height. On a dark photo that line
   hides; on a bright one it reads as a slab covering much of the image.
   That is a difference in the photos, not in the cards -- all three Arts
   scrims are pixel-identical at 116px, and the one that looked worst
   simply has the brightest photo at that row (luminance 0.37 against
   0.19 for the darkest).

   So the top stop is fully transparent and the climb is front-loaded
   into the first quarter: the photo is 2% darker at the box's top edge
   instead of 50%, reaching about half its final density 12px lower.
   There is no line to see. Contrast improves at the same time, because
   the 26% stop puts real density above every line of text in both
   states: resting title 12.6:1 and hover blurb 9.4:1 across all three
   photos, both excluding the text-shadows below.

   CORRECTION to the 116px and 41% quoted above: those were measured
   while .subpage-content h3 was still overriding the title and adding
   32px/12px of heading margin to the box. With that displaced -- see the
   doubled-class rules further down -- the caption box is 94px, or 34% of
   a 280px card. What remains is the padding, the rule, the title's line
   box and the collapsed description's margin, so it does not shrink
   further without shrinking the title itself. Softening the edge is
   still what keeps it from reading as a slab. */
.feature-card__content,
.academics-preview-card__content,
.about-pillar-card__content {
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	z-index: 1;
	padding: 24px;
	background: linear-gradient(180deg, rgba(11, 24, 48, 0) 0%, rgba(11, 24, 48, 0.72) 26%, rgba(11, 24, 48, 0.90) 60%, rgba(11, 24, 48, 0.97) 100%);
}

.feature-card__rule {
	display: block;
	width: 40px;
	height: 3px;
	background: var(--color-gold-accent);
	margin-bottom: 12px;
}

.feature-card__title {
	margin: 0;
	color: var(--color-white);
	font-size: 22px;
	text-shadow: 0 1px 8px rgba(0, 0, 0, 0.65);
}

.feature-card__desc {
	margin: 8px 0 0;
	color: rgba(255, 255, 255, 0.9);
	font-size: 14px;
	text-shadow: 0 1px 6px rgba(0, 0, 0, 0.65);
	max-height: 0;
	opacity: 0;
	overflow: hidden;
	transition: max-height 300ms ease, opacity 300ms ease;
}

.feature-card:hover .feature-card__desc,
.feature-card:focus-within .feature-card__desc {
	max-height: 120px;
	opacity: 1;
}

.feature-grid--four {
	grid-template-columns: repeat(4, minmax(0, 1fr));
}

/* Student Life grid. Bottom padding is much smaller than the matching
   96px/120px pattern used by .events-section: that section reserves the
   extra 120px so its own wave-divider (absolutely positioned, 90px tall)
   has room to sit without covering its content. This section has no
   wave-divider, so it doesn't need that reserved clearance — the 120px
   was carried over from that pattern anyway, and with no wave to fill it,
   it just read as a large blank gap before the (near-identical-colored)
   page background met the CTA banner's navy. */

/* Reverses .academics-preview-section above (navy tint at top instead
   of bottom), so its top edge continues that section's navy-tinted
   bottom edge without a seam -- part of the same alternating wave, see
   that rule's comment. The light end changes from --color-surface-light
   to --color-offwhite here (and in .events-section below) so both
   gradients' light ends match the page's own background exactly,
   rather than the slightly different off-white/light-surface tokens
   this site uses for two different purposes elsewhere.
   0.22 alpha (up from an earlier 0.13) so the tint reads as genuine
   light blue rather than blending into near-neutral gray against the
   near-colorless off-white base -- confirmed against known color
   values: 0.13 landed almost exactly on "lightgray" (211,211,211);
   0.22 lands near "lightsteelblue" (176,196,222), unmistakably blue.
   Peak color hits at 15% (not 0%) so it eases in from the top instead
   of meeting Academics' closing wave-divider at full strength
   immediately -- that abrupt seam was the actual "hard block" look,
   confirmed by pixel-sampling the live page. */
.student-life-section {
	position: relative;
	background: linear-gradient(to bottom, var(--color-offwhite) 0%, rgba(var(--color-navy-rgb), 0.22) 15%, var(--color-offwhite) 100%);
	/* Same values as before, now on the scale. The smaller bottom step
	   is deliberate and documented above: no wave-divider here, so none
	   of the 90px clearance the other sections reserve is needed. */
	padding: var(--space-section) 0 var(--space-section-md);
}

.section-heading-row {
	display: flex;
	justify-content: space-between;
	align-items: baseline;
	gap: 24px;
	flex-wrap: wrap;
	margin-bottom: 40px;
}

.section-heading-row h2 {
	font-size: 36px;
}

/* 2 columns (was 3) — the homepage section has exactly 4 real items,
   and 4-in-3-columns left an awkward partial last row. .student-life-
   grid--4col (below) still overrides this for the Student Life page's
   own 8-item grid, unaffected by this change. */
.student-life-grid {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	gap: 2px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

.student-life-grid__item {
	position: relative;
	display: block;
	height: 260px;
	overflow: hidden;
	background: var(--color-offwhite);
}

.student-life-grid__item img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	transform: scale(var(--stjes-photo-zoom, 1));
	transition: transform 400ms ease;
}

/* Same image-zoom-on-hover treatment as .feature-card/
   .academics-preview-card (identical scale/timing) — the parent
   already has overflow:hidden (see above), so no separate change
   needed there to clip the scaled image. Shared by both the homepage
   Student Life section and the Student Life page's own category grid. */
.student-life-grid__item:hover img,
.student-life-grid__item:focus-within img {
	transform: scale(calc(var(--stjes-photo-zoom, 1) * 1.06));
}

/* Bottom-heavy dark gradient behind the title, so the title doesn't
   rely on text-shadow alone. Pseudo-element rather than a markup
   change: it paints between the <img> and the title span
   automatically, since the span's z-index:1 already beats this
   element's implicit z-index:auto.
   Stops start at 72% (not 45%) so the shading stays contained to the
   caption strip behind the title, the same way .feature-card/
   .academics-preview-card's version does — those apply their gradient
   to a bottom:0 div with no top, sized to just its own text content,
   so it never touches the photo above the caption. This is a full-tile
   inset:0 pseudo-element instead (no separate caption div exists here
   to anchor it to), so it has to fake that same contained look via the
   gradient stops themselves — the previous 45%/0.82 combination shaded
   over half the tile, reading as an all-over blue tint on the photo
   rather than a caption backdrop. */
.student-life-grid__item::after {
	content: '';
	position: absolute;
	inset: 0;
	background: linear-gradient(180deg, rgba(var(--color-navy-rgb), 0) 72%, rgba(var(--color-navy-rgb), 0.75) 100%);
}

.student-life-grid__title {
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	z-index: 1;
	padding: 20px;
	color: var(--color-white);
	font-family: var(--font-heading);
	font-size: 19px;
	font-weight: 700;
	text-shadow: 0 1px 8px rgba(0, 0, 0, 0.65);
}

/* Student Life page only (not the homepage's Student Life section,
   which keeps its existing 3-col/260px sizing as-is) — matches the
   design reference's 4-col/220px spec for this page's larger 8-tile
   grid without touching the shared component elsewhere. */
.student-life-grid--4col {
	grid-template-columns: repeat(4, minmax(0, 1fr));
}

.student-life-grid--4col .student-life-grid__item {
	height: 220px;
}

/* Student Life page: wraps the 4-col grid inside .container (which
   already handles max-width/margin/horizontal padding), so this only
   needs the vertical spacing — replaces a previous inline
   style="padding-top: 40px" that had no bottom padding at all, leaving
   no breathing room before the quote band below. */
/* Bottom is on the scale; the 40px top deliberately isn't. It's the
   tightest section opening on the site (every other one starts at 56 or
   more) and looks like drift, but this section sits directly under
   .about-intro's own zero bottom padding, so the 40 is the entire gap
   between them -- normalizing it to 56 is a visible change that wants
   eyes on the rendered page first, not a blind swap. */
.studentlife-grid-section {
	padding: 40px 0 var(--space-section-sm);
}

/* Student Life page main content: one blurb per program category,
   reusing the same title as the grid tiles above with a longer
   description — rendered from studentlife_categories' own "body"
   sub-field rather than a separate parallel list, so editors don't
   have to keep two lists of the same 8 names in sync. */
.blurb-list {
	display: grid;
	gap: 24px;
}

.blurb-list__item {
	padding: 20px 0;
	border-bottom: 1px solid var(--color-divider);
}

.blurb-list__item h3 {
	margin: 0 0 8px;
	font-size: 18px;
}

.blurb-list__item p {
	margin: 0;
	color: var(--color-ink-muted);
	font-size: 15px;
	line-height: 1.6;
	max-width: 700px;
}

/* Academics preview (front page). Three layers, same technique as
   .quote-band elsewhere in the theme: an off-white section base, a
   navy SVG blob (wavy top and bottom baked into one path) sitting on
   top of it behind the cards, and two dedicated .wave-divider caps —
   reusing the site's one wave-divider path/class, flipped via
   .wave-divider--top for the top one — layered above the blob at the
   section's own top/bottom edges.
   Earlier attempts got this wrong twice: giving the section a solid
   navy background (matching the blob's own fill) closed the gap
   against Intro's photo but made the blob invisible (flat rectangle,
   no wave); using a DIFFERENT navy shade for the two kept the blob
   visible but created a visible seam/color mismatch at the boundary,
   since two different navy shades meeting head-on just reads as an
   edge, not a wave. The wave-divider caps are what actually solve
   this: they're the same reliable, already-proven pattern used for
   the hero and quote-band transitions, and the blob stays one single
   consistent navy throughout — no seam, no gap. Bottom padding
   matches the wave-divider height (90px) so the last row of cards
   doesn't sit underneath it; no such adjustment needed on top since
   the existing 90px top padding already matched. */
/* Part of a continuous alternating wave down the homepage -- off-white
   at top, soft navy tint at bottom -- picked up by .student-life-section
   below (reversed) and .events-section further down (same direction
   again). rgba(var(--color-navy-rgb), ...) rather than a new color
   token: 0.22 alpha reads as genuine light blue (~"lightsteelblue"),
   not the near-gray 0.13 landed on -- see .student-life-section's
   comment for the exact color-value reasoning, same applies here.
   Peak color eases in over the last 15% (85% -> 100%) instead of
   hitting full strength right at the section's own closing
   wave-divider -- same abrupt-seam fix as .student-life-section's top,
   applied to this section's bottom edge instead. */
.academics-preview-section {
	position: relative;
	overflow: hidden;
	background: linear-gradient(to bottom, var(--color-offwhite) 0%, rgba(var(--color-navy-rgb), 0.22) 85%, var(--color-offwhite) 100%);
	/* Was a flat 90px both edges -- exactly this section's two
	   wave-dividers' height, i.e. bare clearance with nothing to spare.
	   --space-section is 96, so both waves stay fully cleared (96 > 90)
	   and the top edge now matches the other homepage sections. */
	padding: var(--space-section) 0 var(--space-section);
}

.academics-preview-section__bg {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	z-index: 0;
}

.academics-preview-section__inner {
	position: relative;
	z-index: 1;
}

/* Size/case/alignment reconciled to match .section-heading-row h2
   (Student Life, Upcoming Events) -- this was the one drifted property
   set among the three, per the design audit. Color stays white: unlike
   those two sections, this one sits on an actual navy background (see
   .academics-preview-section__bg), so white isn't a style choice to
   unify away, it's required for contrast. */
.academics-preview-section__heading {
	margin: 0 0 40px;
	color: var(--color-white);
	font-size: 36px;
}

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

/* Same hover treatment as .feature-card (image zoom, gold arrow badge,
   expanding description), plus a grade-range label and a full-width rule
   (vs. the feature card's fixed 40px rule). */
.academics-preview-card {
	position: relative;
	display: block;
	height: 320px;
	overflow: hidden;
	background: var(--color-navy-800);
}

.academics-preview-card__image {
	width: 100%;
	height: 100%;
	object-fit: cover;
	transform: scale(var(--stjes-photo-zoom, 1));
	transition: transform 400ms ease;
}

.academics-preview-card:hover .academics-preview-card__image,
.academics-preview-card:focus-within .academics-preview-card__image {
	transform: scale(calc(var(--stjes-photo-zoom, 1) * 1.06));
}

.academics-preview-card__arrow {
	position: absolute;
	top: 20px;
	right: 20px;
	width: 34px;
	height: 34px;
	display: flex;
	align-items: center;
	justify-content: center;
	border-radius: 50%;
	background: var(--color-gold-accent);
	color: var(--color-navy);
	font-size: 15px;
	line-height: 1;
	opacity: 0;
	transform: translateY(-6px);
	transition: opacity 250ms ease, transform 250ms ease;
}

.academics-preview-card:hover .academics-preview-card__arrow,
.academics-preview-card:focus-within .academics-preview-card__arrow {
	opacity: 1;
	transform: translateY(0);
}

.academics-preview-card__grade {
	margin: 0 0 6px;
	color: var(--color-gold-accent);
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	text-shadow: 0 1px 6px rgba(0, 0, 0, 0.65);
}

.academics-preview-card__rule {
	display: block;
	width: 100%;
	height: 3px;
	background: var(--color-gold-accent);
	margin-bottom: 12px;
}

.academics-preview-card__title {
	margin: 0;
	color: var(--color-white);
	font-size: 24px;
	text-shadow: 0 1px 8px rgba(0, 0, 0, 0.65);
}

.academics-preview-card__desc {
	margin: 8px 0 0;
	color: rgba(255, 255, 255, 0.9);
	font-size: 14px;
	text-shadow: 0 1px 6px rgba(0, 0, 0, 0.65);
	max-height: 0;
	opacity: 0;
	overflow: hidden;
	transition: max-height 300ms ease, opacity 300ms ease;
}

.academics-preview-card:hover .academics-preview-card__desc,
.academics-preview-card:focus-within .academics-preview-card__desc {
	max-height: 120px;
	opacity: 1;
}

/* Upcoming Events (front page). Same direction as
   .academics-preview-section (off-white top, navy tint at bottom), not
   a continuation of .student-life-section's edge -- .cta-banner (solid
   navy) sits between this section and Student Life, an intentional
   hard stop, not part of the wave. This section's own closing
   wave-divider is off-white (matching footer.php's own top wave, since
   Events is the last homepage section before it -- see that wave's own
   comment further down), so the peak tint here eases back down over
   the last 15% (85% -> 100%) before meeting it, same abrupt-seam fix
   as .academics-preview-section's bottom and .student-life-section's
   top. 0.22 alpha for the same "genuine light blue, not near-gray"
   reason -- see .student-life-section's comment. */
.events-section {
	position: relative;
	background: linear-gradient(to bottom, var(--color-offwhite) 0%, rgba(var(--color-navy-rgb), 0.22) 85%, var(--color-offwhite) 100%);
	/* Same values as before, now on the scale. The larger bottom step is
	   this section's closing wave-divider clearance (90px) plus
	   breathing room, not free rhythm -- don't drop it below 90. */
	padding: var(--space-section) 0 var(--space-section-lg);
}

/* --events-count (set inline in front-page.php from the real event
   count) instead of a hardcoded 4 -- fewer than 4 upcoming events left
   empty cells in a fixed 4-column grid, showing this grid's own grey
   background (var(--color-divider)) where a card should be. Capped at
   min(4, ...) rather than following --events-count past 4 -- now that
   the section can show up to 8 real events (live Google Calendar sync,
   not one manual placeholder), an uncapped column count would squeeze
   8 cards into a single row instead of wrapping to a second row of 4
   (CSS Grid wraps extra items into implicit rows automatically once
   the explicit column count is fixed). */
.events-grid {
	margin-top: 40px;
	display: grid;
	grid-template-columns: repeat(min(4, var(--events-count, 4)), minmax(0, 1fr));
	gap: 2px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

/* Left accent bar on hover/focus -- same "gold bar = interactive" language
   as the sidebar sub-nav's active state, adapted here since these cards
   have no photo to zoom (unlike feature-card/academics-preview-card).
   border-left stays 3px at all times (only the color changes) so nothing
   shifts size on hover. */
.event-card {
	padding: 28px;
	background: var(--color-white);
	border-left: 3px solid transparent;
	transition: background-color 200ms ease, border-color 200ms ease;
}

.event-card:hover,
.event-card:focus-visible {
	background: var(--color-surface-light);
	border-left-color: var(--color-gold-accent);
}

.event-card:hover .event-card__title,
.event-card:focus-visible .event-card__title {
	color: var(--color-navy);
}

.event-card__date {
	margin: 0 0 12px;
	display: flex;
	align-items: baseline;
	gap: 6px;
}

.event-card__day {
	font-family: var(--font-heading);
	font-size: 34px;
	font-weight: 700;
	color: var(--color-navy);
}

.event-card__month {
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	color: var(--color-ink-muted);
}

.event-card__title {
	margin: 0 0 6px;
	font-size: 17px;
}

.event-card__meta {
	margin: 0;
	font-size: 13px;
	color: var(--color-ink-muted);
}

/* CTA banner */
/* "The St. John's Episcopal School Spotlight" band (front-page.php).
   Navy with gold accents, waves top and bottom, same construction as
   .cta-banner below -- position:relative so the absolutely-positioned
   .wave-divider SVGs anchor to it, and no new tokens: --color-navy,
   --color-gold-accent, the 40x3 gold rule from .about-pillar-card, the
   13px/700/0.14em eyebrow, and --space-section for the vertical rhythm.

   Two columns rather than a grid of episode cards, which is what makes
   it read as finished with a single episode: the left column is the
   show's identity and the right is whatever episodes exist. One episode
   fills the right column with one card and nothing is missing. The
   archive list only exists from the second onward (front-page.php), so
   there is never a heading over an empty list. */
.podcast-section {
	position: relative;
	background: var(--color-navy);
	padding: var(--space-section) 0;
}

.podcast-section__grid {
	position: relative;
	z-index: 3;
	display: grid;
	grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
	gap: 56px;
	align-items: start;
}

/* The eyebrow is gold here rather than the white of .eyebrow--on-navy:
   it is the only small type in the band, so it carries the accent that
   the gold rule and play button pick up again below. */
.podcast-section__eyebrow {
	color: var(--color-gold-accent);
}

.podcast-section__heading {
	margin: 0 0 18px;
	color: var(--color-white);
	font-size: 34px;
	line-height: 1.15;
}

.podcast-section__rule {
	width: 40px;
	height: 3px;
	margin: 0 0 20px;
	background: var(--color-gold-accent);
}

.podcast-section__desc {
	margin: 0 0 28px;
	max-width: 46ch;
	color: rgba(255, 255, 255, 0.78);
	font-size: 16.5px;
}

.podcast-host {
	display: flex;
	align-items: center;
	gap: 14px;
	padding-top: 22px;
	border-top: 1px solid rgba(255, 255, 255, 0.18);
}

.podcast-host__photo {
	flex: 0 0 64px;
	width: 64px;
	height: 64px;
	object-fit: cover;
}

.podcast-host__label {
	margin: 0 0 2px;
	color: rgba(255, 255, 255, 0.55);
	font-size: 11px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

.podcast-host__name {
	margin: 0;
	color: var(--color-white);
	font-size: 16px;
	font-weight: 700;
}

/* White card on navy, the same contrast .stat-card uses, so the featured
   episode reads as the one thing in the band to act on. */
.podcast-featured {
	padding: 32px 34px 34px;
	background: var(--color-white);
}

.podcast-featured__label {
	display: inline-block;
	margin: 0 0 18px;
	padding: 5px 10px;
	background: var(--color-gold-accent);
	color: var(--color-navy-700);
	font-size: 11px;
	font-weight: 800;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

.podcast-featured__title {
	margin: 0 0 12px;
	color: var(--color-navy-700);
	font-size: 23px;
	line-height: 1.25;
}

.podcast-featured__meta {
	margin: 0 0 16px;
	color: var(--color-ink-muted);
	font-size: 12px;
	font-weight: 700;
	letter-spacing: 0.12em;
	text-transform: uppercase;
}

.podcast-featured__desc {
	margin: 0 0 26px;
	color: var(--color-ink);
	font-size: 15.5px;
}

/* Square, not round. The site sets --radius: 0 and nothing on it has
   rounded corners -- a circular play button would be the single
   exception. The gold square matches the icon square already used in
   the section-cards component. */
.podcast-featured__cta {
	display: inline-flex;
	align-items: center;
	text-decoration: none;
}

.podcast-play {
	flex: 0 0 52px;
	width: 52px;
	height: 52px;
	display: flex;
	align-items: center;
	justify-content: center;
	background: var(--color-gold-accent);
	transition: background-color 200ms ease;
}

.podcast-featured__cta-label {
	display: flex;
	align-items: center;
	height: 52px;
	padding: 0 22px;
	background: var(--color-navy);
	color: var(--color-white);
	font-size: 14px;
	font-weight: 700;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	transition: background-color 200ms ease;
}

.podcast-featured__cta:hover .podcast-play,
.podcast-featured__cta:focus-visible .podcast-play {
	background: #d79f00;
}

.podcast-featured__cta:hover .podcast-featured__cta-label,
.podcast-featured__cta:focus-visible .podcast-featured__cta-label {
	background: var(--color-navy-700);
}

.podcast-archive__heading {
	margin: 34px 0 0;
	color: rgba(255, 255, 255, 0.55);
	font-size: 11px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

/* Index rows, the same shape as the "In This Section" lists -- a
   compact list of links reads as navigation, and reusing that pattern
   keeps one idea in one visual language. */
.podcast-archive {
	margin: 12px 0 0;
	padding: 0;
	list-style: none;
	border-top: 1px solid rgba(255, 255, 255, 0.18);
}

.podcast-archive li {
	margin: 0;
}

.podcast-archive a {
	display: flex;
	align-items: center;
	gap: 18px;
	padding: 16px 4px;
	border-bottom: 1px solid rgba(255, 255, 255, 0.15);
	text-decoration: none;
	transition: background-color 200ms ease;
}

.podcast-archive a:hover,
.podcast-archive a:focus-visible {
	background: rgba(255, 255, 255, 0.05);
}

.podcast-archive__body {
	flex: 1 1 auto;
	min-width: 0;
}

.podcast-archive__title {
	display: block;
	color: var(--color-white);
	font-size: 16px;
	font-weight: 700;
	line-height: 1.3;
}

.podcast-archive__meta {
	display: block;
	margin-top: 3px;
	color: rgba(255, 255, 255, 0.5);
	font-size: 11px;
	font-weight: 700;
	letter-spacing: 0.12em;
	text-transform: uppercase;
}

.podcast-archive__arrow {
	flex: 0 0 auto;
	color: var(--color-gold-accent);
	font-size: 18px;
	font-weight: 700;
	transition: transform 200ms ease;
}

.podcast-archive a:hover .podcast-archive__arrow,
.podcast-archive a:focus-visible .podcast-archive__arrow {
	transform: translateX(6px);
}

@media (max-width: 860px) {
	.podcast-section__grid {
		grid-template-columns: 1fr;
		gap: 36px;
	}

	.podcast-section__heading {
		font-size: 28px;
	}

	.podcast-featured {
		padding: 26px 22px 28px;
	}
}

@media (prefers-reduced-motion: reduce) {
	.podcast-archive__arrow,
	.podcast-archive a:hover .podcast-archive__arrow,
	.podcast-archive a:focus-visible .podcast-archive__arrow {
		transition: none;
		transform: none;
	}
}

.cta-banner {
	position: relative;
	background: var(--color-navy);
	padding: 160px 24px;
}

.cta-banner__inner {
	display: grid;
	grid-template-columns: 1fr auto;
	align-items: center;
	gap: 32px;
}

.cta-banner h2 {
	color: var(--color-white);
	font-size: 36px;
	margin: 0;
}

.btn-primary--on-navy {
	background: var(--color-white);
	color: var(--color-navy);
	border-color: var(--color-white);
}

/* Same lift as .btn-primary, but a black-tinted shadow instead of navy --
   this variant sits ON a navy background, where a navy-tinted shadow
   would disappear against it. */
.btn-primary--on-navy:hover {
	background: transparent;
	color: var(--color-white);
	border-color: var(--color-white);
	transform: translateY(-2px);
	box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
}

/* Outline variant, for pairing with .btn-primary--on-navy where two CTAs
   sit side by side (e.g. Admissions quote band: "Request Information"
   filled + "Apply Today" outlined) — reference calls this .btn-secondary. */
.btn-secondary--on-navy {
	background: transparent;
	color: var(--color-white);
	border-color: var(--color-white);
}

.btn-secondary--on-navy:hover {
	background: var(--color-white);
	color: var(--color-navy);
	border-color: var(--color-white);
}

/* Subpage banner (shared partial) */
.subpage-banner {
	position: relative;
	/* See .hero-slider above: no margin trick needed, the fixed header
	   already takes no flow space. */
	height: 540px;
	overflow: hidden;
	background: var(--color-navy-900);
}

/* Darkest at the LEFT edge (90deg), matching where the text sits (see
   .subpage-banner__content below) rather than the hero's right-
   concentrated 270deg version this used while the text briefly lived
   bottom-right.
   This comment used to claim "same values as .quote-band__scrim". That
   hasn't been true since: the two now share only a direction. This one
   is a single gradient in rgba(19, 18, 17) (a warm near-black, the same
   literal .hero-slide__scrim uses) stopping at 0/42/65%; quote-band is
   two stacked gradients in brand navy (--color-navy-rgb) stopping at
   0/55/100% plus its own vertical edge darkening. Unifying the two is
   a real color change, not a cleanup -- deliberately not done here. */
.subpage-banner__scrim {
	position: absolute;
	inset: 0;
	background: linear-gradient(90deg, rgba(19, 18, 17, 0.88) 0%, rgba(19, 18, 17, 0.55) 42%, rgba(19, 18, 17, 0.1) 65%);
}

/* Left-aligned and vertically centered -- dialed back from the
   right-of-center, hero-matching treatment. Reference: how top private-
   school sites (Holton-Arms, Sidwell Friends, Rye Country Day) handle
   this same pattern -- left-anchored, not pinned to an edge inset. No
   max-width: these are short 1-2 word titles now (not a long headline
   needing a narrow column to wrap within), and white-space:nowrap on
   the h1 below is what actually guarantees no wrap, not a width
   constraint. */
.subpage-banner__content {
	position: absolute;
	top: 0;
	left: 0;
	bottom: 0;
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: flex-start;
	text-align: left;
	padding: 0 max(24px, calc((100vw - 1240px) / 2));
}

/* Muted white (same rgba-on-dark family the footer already uses for
   secondary text) rather than the site's light-background
   --color-ink-muted token -- this renders over a dark photo scrim, so a
   muted DARK gray would have poor contrast here. Only the current-page
   item (.subpage-banner__crumbs-current below) breaks from this muted
   default, using the full gold accent -- the same "gold marks the
   current/active thing" pattern as the tag dots and active nav
   underlines, so clickable links read as quieter than the page you're
   already on. */
.subpage-banner__crumbs {
	margin: 0 0 12px;
	color: rgba(255, 255, 255, 0.75);
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
	text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}

/* nowrap so a multi-word label ("Student Life", "Sports Calendar")
   can't split across two lines when the trail wraps -- the break should
   land between crumbs, not inside one. Paired with the non-breaking
   space before each separator (template-parts/banner.php), which keeps
   the slash attached to the label it follows. Safe because these are
   page titles, short by convention on this site; a label long enough to
   exceed the viewport on its own would be clipped by .subpage-banner's
   overflow:hidden rather than wrapped. */
.subpage-banner__crumbs a,
.subpage-banner__crumbs-current {
	white-space: nowrap;
}

.subpage-banner__crumbs a {
	color: rgba(255, 255, 255, 0.75);
}

.subpage-banner__crumbs-current {
	color: var(--color-gold-accent);
}

/* The same trail on a page with no banner -- the [stjes_lead_feature]
   pages (Drama, Art, Music, Diversity, Athletics, Spiritual Life, Annual
   Fund and the rest) and any plain page. Same component, same markup,
   same position above the h1; only the palette differs, because here it
   sits on the page's own background instead of over a photo, so the
   white-on-photo colours and the text-shadow that made them readable
   would be invisible.

   SPECIFICITY, not source order. These sit inside .subpage-content,
   whose own p / a rules are (0,1,1) and (0,2,1) -- the trap that has
   caught five components on this site. The p.subpage-crumbs form takes
   the link rule to (0,2,2) so it wins outright rather than depending on
   which rule happens to come later in this file. */
.subpage-content p.subpage-crumbs {
	max-width: none;
	margin: 0 0 12px;
	color: var(--color-ink-muted);
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

/* nowrap for the same reason as the banner version above: the break
   should land between crumbs, never inside a multi-word label. */
.subpage-content p.subpage-crumbs a,
.subpage-content p.subpage-crumbs .subpage-crumbs-current {
	white-space: nowrap;
}

.subpage-content p.subpage-crumbs a {
	color: var(--color-ink-muted);
	text-decoration: none;
}

.subpage-content p.subpage-crumbs a:hover,
.subpage-content p.subpage-crumbs a:focus-visible {
	color: var(--color-navy);
	text-decoration: underline;
}

/* Gold marks the page you are already on, matching the banner trail and
   the tag dots and active nav underlines. */
.subpage-content p.subpage-crumbs .subpage-crumbs-current {
	color: var(--color-gold-accent);
}

/* White, title case, moderate weight -- dialed back from the previous
   all-caps/800-weight/gold treatment, which read as too heavy-handed
   next to real private-school sites' own restrained version of this
   pattern. white-space:nowrap guarantees a short 1-2 word title never
   wraps to two lines regardless of container width (reset back to
   normal on mobile below, where forcing nowrap risks a horizontal
   scroll instead). */
.subpage-banner__content h1 {
	color: var(--color-white);
	font-size: 68px;
	font-weight: 600;
	text-shadow: 0 1px 8px rgba(0, 0, 0, 0.55);
	white-space: nowrap;
}

/* Gold is now just a small accent (a thin rule under the title)
   instead of being the heading's own color. */
.subpage-banner__content h1::after {
	content: '';
	display: block;
	width: 40px;
	height: 3px;
	margin-top: 16px;
	background: var(--color-gold-accent);
}

/* Subpage layout: sidebar sub-nav + content */
.subpage-layout {
	display: grid;
	grid-template-columns: 260px minmax(0, 1fr);
	gap: 56px;
	align-items: start;
	padding: var(--space-section-sm) 24px var(--space-section);
}

/* Both rules below handle the same case: this grid rendered WITHOUT a
   sidebar. That isn't hypothetical -- index.php (blog index, search
   results, every archive) uses .subpage-layout and renders no <aside>
   at all, so it hits this today.
   The failure mode is worse than an empty column: with a single child,
   grid auto-placement puts it in the FIRST track, so the content ends
   up crushed into the 260px sidebar column with a wide gap to its
   right, rather than sitting where the content belongs.
   Two layers, deliberately. :only-child works everywhere and has for a
   decade; it spans the content across both tracks, which fixes the
   squeeze but leaves the sidebar's 260px + 56px gap as dead space
   inside the spanned area. :has() then removes the track entirely for
   the tidy result. A browser too old for :has() still gets the
   readable :only-child version rather than the squeeze. */
.subpage-layout > .subpage-content:only-child {
	grid-column: 1 / -1;
}

.subpage-layout:not(:has(> .subpage-subnav)) {
	grid-template-columns: minmax(0, 1fr);
}

.subpage-subnav__label {
	margin: 0 0 16px;
	color: var(--color-ink-muted);
	font-size: 12px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

.subpage-subnav__list {
	border-top: 2px solid var(--color-divider);
}

.subpage-subnav__list li {
	border-bottom: 1px solid var(--color-divider);
}

.subpage-subnav__list a {
	display: block;
	padding: 12px 4px 12px 12px;
	font-size: 15px;
	border-left: 5px solid transparent;
	transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease;
}

/* Grandchildren are indented; everything above them keeps the padding it
   already had. Until the sub-nav listed grandchildren there was only one
   level here, so depth 0 and depth 1 deliberately have no rule of their
   own -- adding one would shift every existing sidebar to buy nothing.
   Academics now carries thirteen rows across three levels, and this is
   what keeps that reading as a hierarchy rather than a flat run.

   Padding rather than margin, so the row stays clickable across the full
   width of the column and the hover wash still fills it. */
.subpage-subnav__list .subpage-subnav__item--depth-2 a {
	padding-left: 30px;
	font-size: 14px;
}

/* Lighter version of the active-item treatment below (same gold-wash
   background, same left bar, both dialed down) so a visitor scanning the
   list gets feedback on every row, not just the current one -- excludes
   .is-current so this doesn't fight/duplicate that rule's own, stronger
   styling on the active row itself. */
.subpage-subnav__list li:not(.is-current) a:hover,
.subpage-subnav__list li:not(.is-current) a:focus-visible {
	color: var(--color-navy);
	background: rgba(var(--color-gold-rgb), 0.06);
	border-left-color: rgba(var(--color-gold-rgb), 0.4);
}

/* Gold accent bar (not navy) to echo the gold underline treatment used
   elsewhere for "current/active" state (e.g. .primary-nav__list
   li.current-menu-item, the "Explore Student Life" link style) — same
   language, different axis (left edge instead of underline) since this
   is a vertical list. Widened from 3px to 5px, plus a light gold wash
   across the whole row (same --color-gold-rgb the bar itself uses, at
   low opacity) so the active item reads as a filled row at a glance
   while scanning, not just a thin border easy to miss. */
.subpage-subnav__list li.is-current a {
	font-weight: 700;
	color: var(--color-navy-700);
	background: rgba(var(--color-gold-rgb), 0.12);
	border-left-color: var(--color-gold-accent);
}

/* Responsive 16:9 embed -- aspect-ratio (not the old padding-bottom:
   56.25% trick) since every browser this site targets already
   supports it. iframe fills the box via inset:0 on the positioned
   parent, so it scales with the container at any width. */
.video-embed {
	position: relative;
	aspect-ratio: 16 / 9;
	margin: 0 0 32px;
	background: var(--color-navy-900);
}

.video-embed iframe {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	border: 0;
}

.subpage-content h2 {
	font-size: 26px;
	margin: 0 0 20px;
}

.subpage-content > * + h2 {
	margin-top: 48px;
}

.subpage-content__lead {
	font-size: 18px;
	line-height: 1.6;
	color: var(--color-ink-muted);
	max-width: 760px;
}

.subpage-content__note {
	color: var(--color-ink-muted);
}

.subpage-content h1 {
	font-size: 34px;
	margin: 0 0 32px;
}

.subpage-content h3 {
	font-size: 19px;
	margin: 32px 0 12px;
}

/* Sitewide page-body copy (.subpage-content is the shared content
   wrapper across every content page template -- page.php plus all 5
   section-hub templates). Bumped from the original
   16px/400/--color-ink-muted (#605d5d), which read as faint/low-
   contrast -- a touch larger, a touch heavier, and darkened about 20%
   toward --color-ink (not all the way to it) for a subtle, still-
   secondary-feeling but noticeably crisper read. */
.subpage-content p {
	max-width: 760px;
	color: #4d4a4a;
	font-size: 17px;
	font-weight: 500;
	line-height: 1.65;
}

.subpage-content ul,
.subpage-content ol {
	max-width: 760px;
	margin: 0 0 1em;
	padding-left: 1.25em;
	color: var(--color-ink-muted);
}

.subpage-content ul {
	list-style: disc;
}

.subpage-content ol {
	list-style: decimal;
}

/* :not(.btn) so a .btn placed in freeform prose content (e.g. the
   Video Overview page's "Schedule a Tour" button) keeps its own
   color/no-underline styling — this selector's specificity (a class
   plus an element) would otherwise beat .btn-primary's (a single
   class) and render its text navy-on-navy. */
.subpage-content a:not(.btn) {
	color: var(--color-navy);
	text-decoration: underline;
	text-underline-offset: 3px;
}

/* "In This Section" grandchild-page link list (Lower School, Upper
   School, Athletics, The Arts -- see stjes_style_in_this_section_list(),
   functions.php). These are the only on-site path to the grade pages /
   Art, Drama, Music, since the sidebar sub-nav and header mega-menu
   only go one level deep, so they have to read as real navigation
   rather than a stray bulleted list buried in body text.

   Full-width index rows rather than the grid of small centred boxes
   this used to be: rows carry a type hierarchy the boxes had no room
   for, give a much larger touch target, and scale to a section with ten
   or twelve children instead of wrapping into a ragged grid. Rows also
   keep this visually distinct from [stjes_section_cards], which is the
   photo-card component -- two card-shaped things competing on one page
   is the confusion that had to be undone on the Arts hub.

   The item is a flex ROW today, but the class names (__row, __label)
   describe position rather than shape, and a photo added later slots in
   as a first child of the same anchor without the markup changing: give
   .in-this-section__grid a modifier, switch the anchor to
   flex-direction: column, and the existing children still stack in the
   right order. Nothing here has to be ripped out for that.

   Selectors stay prefixed with .subpage-content to match the
   specificity of .subpage-content ul / a:not(.btn) above, and are
   placed after them so this wins the cascade rather than relying on
   source order -- the single-class versions lose to those at (0,1,1),
   which is the trap that has caught five components on this site
   already. No separate heading-spacing rule needed: the existing
   .subpage-content > * + h2 rule already gives this <h2> its top
   margin, since it always follows the page's own paragraph content. */
.subpage-content .in-this-section__grid {
	max-width: none;
	margin: 0;
	padding: 0;
	list-style: none;
	display: grid;
	grid-template-columns: 1fr;
	gap: 0;
	border-top: 1px solid rgba(32, 30, 29, 0.16);
}

.subpage-content .in-this-section__grid li {
	margin: 0;
	padding: 0;
}

/* list-style is reset on the li above; ::marker is cleared here because
   .subpage-content ul li may set one. */
.subpage-content .in-this-section__grid li::marker {
	content: '';
}

.subpage-content .in-this-section__grid a {
	display: flex;
	align-items: center;
	gap: 20px;
	min-height: 64px;
	padding: 18px 16px 18px 4px;
	border-bottom: 1px solid rgba(32, 30, 29, 0.16);
	background: transparent;
	color: var(--color-navy-700);
	text-decoration: none;
	transition: background-color 200ms ease, padding-left 200ms ease;
}

/* Reserved only when at least one row actually has an ordinal, so a
   list of plain names has no empty gutter. */
.subpage-content .in-this-section__num {
	flex: 0 0 54px;
	text-align: right;
	font-size: 30px;
	font-weight: 800;
	line-height: 1;
	letter-spacing: -0.03em;
	color: var(--color-navy-200);
	transition: color 200ms ease;
}

.subpage-content .in-this-section__label {
	flex: 1 1 auto;
	min-width: 0;
	font-size: 18px;
	font-weight: 700;
	line-height: 1.25;
	color: var(--color-navy-700);
}

.subpage-content .in-this-section__arrow {
	flex: 0 0 auto;
	color: var(--color-gold-accent);
	font-size: 19px;
	font-weight: 700;
	transition: transform 200ms ease;
}

.subpage-content .in-this-section__grid a:hover,
.subpage-content .in-this-section__grid a:focus-visible {
	background: var(--color-surface-light);
	padding-left: 10px;
}

.subpage-content .in-this-section__grid a:hover .in-this-section__num,
.subpage-content .in-this-section__grid a:focus-visible .in-this-section__num {
	color: var(--color-gold-accent);
}

.subpage-content .in-this-section__grid a:hover .in-this-section__arrow,
.subpage-content .in-this-section__grid a:focus-visible .in-this-section__arrow {
	transform: translateX(6px);
}

@media (max-width: 560px) {
	.subpage-content .in-this-section__grid a {
		gap: 14px;
	}

	.subpage-content .in-this-section__num {
		flex-basis: 38px;
		font-size: 24px;
	}

	.subpage-content .in-this-section__label {
		font-size: 16px;
	}
}

/* The lift and slide are decoration; the colour and background changes
   above still mark hover and focus without them. */
@media (prefers-reduced-motion: reduce) {
	.subpage-content .in-this-section__grid a,
	.subpage-content .in-this-section__arrow,
	.subpage-content .in-this-section__num {
		transition: none;
	}

	.subpage-content .in-this-section__grid a:hover,
	.subpage-content .in-this-section__grid a:focus-visible {
		padding-left: 4px;
	}

	.subpage-content .in-this-section__grid a:hover .in-this-section__arrow,
	.subpage-content .in-this-section__grid a:focus-visible .in-this-section__arrow {
		transform: none;
	}
}

.subpage-content blockquote {
	margin: 24px 0;
	padding: 20px 24px;
	border-left: 4px solid var(--color-navy);
	background: var(--color-surface-light);
	font-style: italic;
}

.subpage-content blockquote cite {
	display: block;
	margin-top: 12px;
	font-size: 14px;
	font-style: normal;
	font-weight: 700;
	color: var(--color-navy);
}

.subpage-content img {
	margin: 24px 0;
}

.subpage-content table {
	width: 100%;
	max-width: 760px;
	border-collapse: collapse;
	margin: 0 0 24px;
}

.subpage-content table th,
.subpage-content table td {
	text-align: left;
	padding: 10px;
	border-bottom: 1px solid var(--color-divider);
}

/* Accordion (template-parts/accordion.php) — native <details>/<summary>,
   no JS. Links inside inherit .subpage-content a:not(.btn)'s existing
   navy/underline styling automatically, since this renders inside
   .subpage-content — no separate link-color rule needed here. */
.accordion-list {
	margin-top: 24px;
	border-top: 1px solid var(--color-divider);
}

.accordion-item {
	border-bottom: 1px solid var(--color-divider);
}

/* Hides the browser's own default disclosure marker (▶/▼) so the
   custom ::after chevron below is the only one shown — ::-webkit-
   details-marker for Chrome/Safari, list-style:none (summary has an
   implicit list-item display) for Firefox. */
.accordion-item__summary {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 16px;
	padding: 20px 4px;
	list-style: none;
	cursor: pointer;
	font-family: var(--font-heading);
	font-size: 19px;
	font-weight: 700;
	color: var(--color-navy);
}

.accordion-item__summary::-webkit-details-marker {
	display: none;
}

.accordion-item__summary:hover {
	color: var(--color-navy-700);
}

/* Same 6x6px rotated-border chevron as the primary nav's dropdown
   indicator (.primary-nav__list li.menu-item-has-children > a::after)
   — pointing down when closed, flipped to point up when the <details>
   is open, via the details[open] state rather than JS. */
.accordion-item__summary::after {
	content: "";
	flex-shrink: 0;
	width: 8px;
	height: 8px;
	border-right: 2px solid currentColor;
	border-bottom: 2px solid currentColor;
	transform: rotate(45deg);
	transition: transform 200ms ease;
}

.accordion-item[open] .accordion-item__summary::after {
	transform: rotate(225deg);
}

.accordion-item__body {
	padding: 0 4px 24px;
}

.accordion-item__body h3 {
	font-size: 16px;
	margin: 20px 0 8px;
}

.accordion-item__body h3:first-child {
	margin-top: 0;
}

.accordion-item__note {
	font-style: italic;
	color: var(--color-ink-muted);
}

.accordion-item__links {
	display: flex;
	flex-direction: column;
	gap: 8px;
	margin: 0 0 1em;
	padding: 0;
	list-style: none;
}

/* Full-width, min-height:44px tap targets -- the <a> had no padding/
   height of its own before, just hugging its text (as little as 21px
   tall for a short label like "Math"), which is what made Parent
   Portal's dense link lists (Supply Lists, Summer Assignments) hard to
   tap accurately on a phone. The 8px gap on .accordion-item__links
   above still separates one full-height row from the next. */
.accordion-item__links a {
	display: flex;
	align-items: center;
	width: 100%;
	min-height: 44px;
	padding: 8px 4px;
}

/* About: intro + join list */
.about-intro {
	padding: var(--space-section-md) 24px 0;
}

.about-intro__lead {
	font-size: 19px;
	line-height: 1.6;
	max-width: 780px;
}

.about-intro__join-label {
	font-size: 14px;
	font-weight: 700;
	letter-spacing: 0.1em;
	text-transform: uppercase;
	color: var(--color-navy-700);
}

.about-join-list {
	display: grid;
	gap: 12px;
	max-width: 780px;
	border-top: 2px solid var(--color-divider);
	padding-top: 20px;
}

.about-join-list li {
	padding-left: 20px;
	border-left: 3px solid var(--color-navy);
	color: var(--color-ink-muted);
	font-size: 16px;
	line-height: 1.55;
}

/* Pillar cards — shared component (About, Admissions). Reference wraps
   the pillar grid in its own section with 56px padding top AND bottom;
   ours gets the top spacing from .pillar-grid's own margin-top instead
   (adding section-level top padding too would double it), so this only
   needs to add the missing bottom half. */
.pillar-section {
	padding-bottom: var(--space-section-sm);
}

.pillar-grid {
	margin-top: 56px;
	display: grid;
	grid-template-columns: repeat(4, minmax(0, 1fr));
	gap: 2px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

/* Same image-zoom-on-hover treatment as .feature-card/
   .academics-preview-card (identical scale/timing — see those for the
   canonical version) — overflow:hidden is what clips the scaled image
   to the card's own bounds instead of spilling past it. */
.pillar-card {
	display: block;
	overflow: hidden;
	background: var(--color-offwhite);
}

/* aspect-ratio (was a fixed 150px height) so the crop stays 3:2 at
   every width. The fixed height meant the effective ratio swung with
   the grid's own column count -- roughly 2:1 in the 4-column desktop
   grid, near-square in the 2-column 900-480px range, back to ~2:1
   single-column below 480px. object-fit:cover below is what makes the
   ratio a crop rather than a squash. */
.pillar-card img {
	width: 100%;
	aspect-ratio: 3 / 2;
	object-fit: cover;
	transform: scale(var(--stjes-photo-zoom, 1));
	transition: transform 400ms ease;
}

.pillar-card:hover img,
.pillar-card:focus-within img {
	transform: scale(calc(var(--stjes-photo-zoom, 1) * 1.06));
}

.pillar-card__body {
	padding: 20px;
}

.pillar-card__body h3 {
	font-size: 17px;
	margin: 0 0 8px;
}

.pillar-card__body p {
	margin: 0 0 12px;
	color: var(--color-ink-muted);
	font-size: 14px;
}

/* About page's Traditions cards only -- same hover-overlay mechanics as
   .feature-card (see that block for the canonical version this mirrors):
   image zoom, gold arrow fade-in, title+rule always visible, description
   reveal-on-hover. Kept as its own class rather than changing the shared
   .pillar-card above, since that class is also used by page-support.php's
   own highlight cards, which this change isn't meant to touch. */
.about-pillar-card {
	position: relative;
	display: block;
	height: 280px;
	overflow: hidden;
	background: var(--color-navy-800);
}

/* DOUBLED CLASS on this and the title/description rules below, which
   looks redundant and is not. This component was written for
   page-about.php, where it sits outside .subpage-content. The
   [stjes_section_cards] shortcode renders it inside page content for the
   first time, which puts it under the sitewide body-copy rules --
   .subpage-content img, .subpage-content h3 and .subpage-content p.
   Those are descendant selectors at (0,1,1) and beat a single component
   class at (0,1,0), so they were silently winning: the photo took a 24px
   block margin and sat 24px down its own card, the title rendered at
   19px with 32px/12px heading margins, and the description came out
   #4d4a4a at 17px -- dark grey body copy on a dark scrim. Repeating the
   class lifts these to (0,2,0). .page-media__figure img,
   .store-gallery .gallery img and .staff-card__photo img all carry the
   same kind of override for the same reason.
   margin: 0 is the specific fix for .subpage-content img's 24px. */
.about-pillar-card .about-pillar-card__image {
	width: 100%;
	height: 100%;
	margin: 0;
	object-fit: cover;
	transform: scale(var(--stjes-photo-zoom, 1));
	transition: transform 400ms ease;
}

.about-pillar-card:hover .about-pillar-card__image,
.about-pillar-card:focus-within .about-pillar-card__image {
	transform: scale(calc(var(--stjes-photo-zoom, 1) * 1.06));
}

.about-pillar-card__arrow {
	position: absolute;
	top: 20px;
	right: 20px;
	width: 34px;
	height: 34px;
	display: flex;
	align-items: center;
	justify-content: center;
	border-radius: 50%;
	background: var(--color-gold-accent);
	color: var(--color-navy);
	font-size: 15px;
	line-height: 1;
	opacity: 0;
	transform: translateY(-6px);
	transition: opacity 250ms ease, transform 250ms ease;
}

.about-pillar-card:hover .about-pillar-card__arrow,
.about-pillar-card:focus-within .about-pillar-card__arrow {
	opacity: 1;
	transform: translateY(0);
}

/* margin-bottom is 6px where .feature-card__rule uses 12px. The caption
   box is anchored to the bottom of the card, so tightening the gap
   doesn't lift the title -- it drops the rule 6px onto it, which is what
   makes it read as an underline on the title rather than a short gold
   bar floating in its own space. Deliberately not applied to
   .feature-card__rule or .academics-preview-card__rule: those sit on the
   homepage, weren't part of this change, and .academics-preview-card__rule
   is a full-width rule where the same 12px reads as intended. */
.about-pillar-card__rule {
	display: block;
	width: 40px;
	height: 3px;
	background: var(--color-gold-accent);
	margin-bottom: 6px;
}

/* 24px matches .academics-preview-card__title; only .feature-card__title
   is still 22px. Doubled class and the explicit margin beat
   .subpage-content h3 -- see the image rule above. */
.about-pillar-card .about-pillar-card__title {
	margin: 0;
	color: var(--color-white);
	font-size: 24px;
	line-height: 1.25;
	text-shadow: 0 1px 8px rgba(0, 0, 0, 0.65);
}

/* max-width, font-weight and line-height are restated only to displace
   .subpage-content p, which sets all three for body copy -- see the
   image rule above. */
.about-pillar-card .about-pillar-card__desc {
	margin: 8px 0 0;
	max-width: none;
	color: rgba(255, 255, 255, 0.9);
	font-size: 14px;
	font-weight: 400;
	line-height: 1.5;
	text-shadow: 0 1px 6px rgba(0, 0, 0, 0.65);
	max-height: 0;
	opacity: 0;
	overflow: hidden;
	transition: max-height 300ms ease, opacity 300ms ease;
}

.about-pillar-card:hover .about-pillar-card__desc,
.about-pillar-card:focus-within .about-pillar-card__desc {
	max-height: 120px;
	opacity: 1;
}

/* Trailing CTA text within the description ("Explore Academics ->"),
   not a separate reveal-on-hover rule of its own -- it is a span nested
   inside .about-pillar-card__desc, so it appears and disappears with
   that element's transition automatically.

   display:block rather than inline-block: as inline-block it ran on from
   the last words of the description and shared a line with them, which
   read as part of the sentence rather than as a link. Blocking it costs
   one line of caption height -- the tallest card, About's "Accelerated
   Academics", goes from 191px to 216px inside a 280px card, and the
   description's 120px reveal cap is still not the binding constraint on
   either page. */
.about-pillar-card__link {
	display: block;
	margin-top: 8px;
	color: var(--color-gold-accent);
	font-weight: 700;
	white-space: nowrap;
}

/* Quote / CTA band with photo */
.quote-band {
	position: relative;
	height: 380px;
	overflow: hidden;
}

.quote-band__image {
	width: 100%;
	height: 100%;
	object-fit: cover;
	transform: scale(var(--stjes-photo-zoom, 1));
}

/* Two stacked gradients: the original horizontal one (left-to-right,
   darkening behind the text) stays the primary effect, layered over a
   second, much subtler vertical one that darkens slightly right at the
   top/bottom edges and fades to nothing across the middle 60%. On a
   photo with real color/light variation this is barely noticeable: the
   point is robustness on a flat, low-contrast photo (a plain building/
   signage shot, for instance) that would otherwise read as a flat,
   hard-edged block against the wave-dividers' curve at those exact
   edges -- the same "smooth tone flow, not a flat block" principle as
   the homepage section gradients, applied here at the photo-band
   layer instead of a flat-color one. */
.quote-band__scrim {
	position: absolute;
	inset: 0;
	background:
		linear-gradient(90deg, rgba(var(--color-navy-rgb), 0.88) 0%, rgba(var(--color-navy-rgb), 0.4) 55%, rgba(var(--color-navy-rgb), 0.05) 100%),
		linear-gradient(to bottom, rgba(var(--color-navy-rgb), 0.22) 0%, rgba(var(--color-navy-rgb), 0) 20%, rgba(var(--color-navy-rgb), 0) 80%, rgba(var(--color-navy-rgb), 0.22) 100%);
}

.wave-divider--top {
	top: -1px;
	bottom: auto;
	transform: scaleY(-1);
}

/* Fixed 24px padding, not the viewport-scaling max(24px, calc((100vw -
   1240px) / 2)) formula used by full-bleed containers elsewhere — this
   box is already capped at max-width:620px and left-anchored, not
   full-width, so that formula's wide-viewport padding (up to ~340px a
   side) exceeded the box's own 620px budget on wide desktop screens,
   squeezing the actual content area to near-zero and forcing the
   quote text into a one-word-per-line collapse that then got clipped
   by .quote-band's overflow:hidden. */
.quote-band__content {
	position: absolute;
	left: 0;
	top: 0;
	bottom: 0;
	display: flex;
	flex-direction: column;
	justify-content: center;
	padding: 0 24px;
	max-width: 620px;
}

.eyebrow--on-navy {
	color: var(--color-white);
}

.quote-band__text {
	margin: 0 0 20px;
	color: var(--color-white);
	font-family: var(--font-heading);
	font-size: 26px;
	font-weight: 700;
	line-height: 1.35;
}

.quote-band__actions {
	display: flex;
	gap: 12px;
	flex-wrap: wrap;
}

/* Academics: division cards (Lower School / Upper School) */
.division-grid {
	margin-top: 8px;
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	gap: 2px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

/* Same image-zoom-on-hover treatment as .feature-card/
   .academics-preview-card (identical scale/timing — see those for the
   canonical version) — overflow:hidden is what clips the scaled image
   to the card's own bounds instead of spilling past it. */
.division-card {
	overflow: hidden;
	background: var(--color-offwhite);
}

/* aspect-ratio (was a fixed 260px height) so the crop stays 16:9 at
   every width instead of drifting from ~2.3:1 in the 2-column desktop
   grid to ~1.26:1 stacked on a phone. 16:9 rather than the 3:2 used on
   the narrower cards above: at this grid's ~595px desktop column, 3:2
   would make these ~397px tall against the 260px they are now, a much
   bigger change than this pass intends. object-fit:cover below is what
   makes the ratio a crop rather than a squash. */
.division-card img {
	width: 100%;
	aspect-ratio: 16 / 9;
	object-fit: cover;
	transform: scale(var(--stjes-photo-zoom, 1));
	transition: transform 400ms ease;
}

.division-card:hover img,
.division-card:focus-within img {
	transform: scale(calc(var(--stjes-photo-zoom, 1) * 1.06));
}

.division-card__body {
	padding: 32px;
}

.division-card__range {
	font-size: 26px;
	margin: 0 0 12px;
}

.division-card__text {
	margin: 0 0 20px;
	color: var(--color-ink-muted);
	font-size: 15px;
	line-height: 1.6;
}

.division-card__tags {
	margin-bottom: 20px;
}

.division-card__link {
	margin: 0;
}

/* About: 4 featured facts as stat cards -- same hairline-divider grid
   recipe already used sitewide (.events-grid, .division-grid,
   .pillar-grid: 2px gap, divider-color background/border, cards butted
   against thin rules), not a new grid technique. */
/* --stat-count (set inline on the homepage from the real row count, the
   same technique .events-grid uses with --events-count) instead of a
   hardcoded 4. The About page sets nothing and falls back to 4, exactly
   as before; the homepage's At a Glance strip fills the row for
   whatever number of stats is actually saved, rather than stranding
   empty cells that show this grid's own divider-grey where a card
   should be -- the bug .events-grid's own comment documents. */
.stat-card-grid {
	margin: 0 0 32px;
	display: grid;
	grid-template-columns: repeat(var(--stat-count, 4), minmax(0, 1fr));
	gap: 2px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

.stat-card {
	padding: 28px 16px;
	background: var(--color-white);
	text-align: center;
}

.stat-card__value {
	display: block;
	font-family: var(--font-heading);
	font-size: 40px;
	font-weight: 800;
	line-height: 1;
	color: var(--color-navy);
}

.stat-card__caption {
	display: block;
	margin-top: 8px;
	font-size: 12px;
	font-weight: 700;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	color: var(--color-ink-muted);
}

/* Homepage "At a Glance" strip (front-page.php), between the intro and
   Academics. No background declared on purpose -- it inherits the
   page's off-white so .academics-preview-section's top wave-divider,
   which is filled with --color-offwhite to blend into whatever sits
   above it, still meets the color it expects. A white band here would
   leave that fill meeting white, a faint but visible seam.
   No wave of its own on either edge, so nothing constrains the padding
   beyond rhythm -- --space-section-sm at both, desktop and mobile
   alike, which is why it needs no mobile override. */
.stats-strip {
	padding: var(--space-section-sm) 0;
}

.stats-strip__heading {
	margin: 0 0 24px;
	font-size: 28px;
	text-align: center;
}

/* The 32px is for the About page, where a <dl> of remaining facts
   follows the grid directly. Here the section's own padding provides
   the separation, so the extra margin would just double it. */
.stats-strip .stat-card-grid {
	margin-bottom: 0;
}

/* Grid for [stjes_section_cards] (inc/section-cards.php). The cards
   themselves are .about-pillar-card and reuse that component's rules
   wholesale -- only the grid differs.
   .pillar-grid, which those cards normally sit in, is a fixed four
   columns. A hub with three children would leave a fourth cell showing
   this grid's own divider-grey background as a solid block -- the same
   artefact the staff directory took three attempts to remove. The
   column count here follows the number of cards instead, capped at 4
   so a hub with more of them still wraps rather than shrinking each one
   to a sliver. --section-card-count is set inline by the shortcode, and
   the fallback keeps the rule sane if the property ever goes missing. */
.section-card-grid {
	margin-top: 32px;
	display: grid;
	grid-template-columns: repeat(min(4, var(--section-card-count, 3)), minmax(0, 1fr));
	gap: 2px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

@media (max-width: 900px) {
	/* One per row rather than two: at three cards, two columns would
	   put a single card on the last row and reintroduce exactly the
	   empty grey cell this grid exists to avoid. */
	.section-card-grid {
		grid-template-columns: 1fr;
	}
}

/* Contact signature -- the "Questions?" block that closes Admissions
   pages. Two lines: who, then how to reach them.

   It had drifted into two different shapes. Tuition carried five
   <br>-separated lines including "St. John's Episcopal School", which a
   page on that school's own site does not need to say; Applying had been
   split by hand into four separate paragraphs, so WordPress's paragraph
   spacing pushed a four-item signature down the length of a screen.
   Neither read as a signature.

   The doubled selector is load-bearing for the same reason it is on the
   trustee grid and the pillar cards: .subpage-content p at (0,1,1)
   otherwise sets the line-height, and 1.65 is body-copy leading that
   undoes the tightening this rule exists for.

   The left rule is what makes it read as a signature block rather than
   another paragraph: 2px of the same gold the section headings and card
   rules already use, so it marks the block without introducing a colour
   the page does not have elsewhere. */
.contact-signature,
.subpage-content .contact-signature {
	margin: 20px 0 0;
	padding: 2px 0 2px 16px;
	max-width: 520px;
	border-left: 2px solid var(--color-gold-accent);
	font-size: 15px;
	line-height: 1.5;
}

.subpage-content .contact-signature strong {
	color: var(--color-navy);
	font-weight: 700;
}

/* A little less inset on phones, where 16px of padding on top of the
   page's own gutter costs more than the rule is worth. */
@media (max-width: 480px) {
	.subpage-content .contact-signature {
		padding-left: 12px;
	}
}

/* Board of Trustees roster (scripts/restructure-trustees-page.php).
   Fifteen people who read as one long column otherwise -- the old site
   laid them out in two columns, but only its markup came across, and
   none of its classes mean anything here.

   Deliberately generic rather than named for that one page: a roster of
   short name/role/committee entries is the shape the Parent Association
   and any future committee listing will want too.

   THE DOUBLED SELECTORS ARE LOAD-BEARING, same as .about-pillar-card and
   .lead-feature. This renders inside .subpage-content, where
   .subpage-content p sits at (0,1,1) and would otherwise impose the
   body-copy max-width of 760px on every cell -- which in a three-column
   grid is harmless, and in a one-column grid is not, since it would stop
   the entry filling its own row.

   auto-fit rather than a fixed column count, so the row finds its own
   number of tracks as the container narrows instead of needing a
   breakpoint per step. The one breakpoint below it is for phones, where
   auto-fit's answer depends on the width of whatever content column the
   grid is sitting in and cannot be relied on to be one. */
.trustee-grid {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
	gap: 4px;
	margin: 32px 0 40px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

/* An emptied grid still paints: the divider colour above is the grid's
   own background, showing through the gaps between cards, and the border
   draws around it whether or not there is anything inside. Delete every
   trustee in wp-admin and what is left is a bordered grey box. Same
   artefact as the fourth stat card on the About page, and the fourth
   cell of the section-cards grid before that.

   TWO RULES, NOT ONE COMMA-SEPARATED LIST. A browser that does not
   understand :has() discards a whole selector list containing it, which
   would take the :empty rule down with it. Kept apart so the first still
   applies on its own. */
.trustee-grid:empty {
	display: none;
}

/* :empty is the narrow case -- it matches only an element with no child
   nodes at all, and a newline counts as a text node. The container is
   written as "<div class="trustee-grid">\n<p class="trustee">..." (see
   scripts/restructure-trustees-page.php), so deleting the paragraphs by
   hand almost always leaves the newline behind and :empty never matches.
   That is why this rule exists: no element children, whatever
   whitespace remains.

   Deliberately :has(*) rather than :has(.trustee). Hiding on "no
   trustees" would read closer to the intent, but it would also hide a
   grid holding something real that simply lacks the class -- and content
   that silently disappears is a worse failure than a visible empty box,
   which at least gets reported. This hides only a container with nothing
   in it. */
.trustee-grid:not(:has(*)) {
	display: none;
}

.trustee-grid .trustee {
	margin: 0;
	max-width: none;
	padding: 20px 22px;
	background: var(--color-white);
	color: var(--color-ink);
	font-size: 15px;
	font-weight: 400;
	line-height: 1.5;
}

/* The name. Not a heading element -- these are list entries rather than
   sections, and fifteen h3s here would put fifteen phantom rows in the
   document outline.

   Left inline rather than display:block, because each line in the entry
   is already separated by its own <br>. Blocking it as well produced two
   breaks and a visible gap under every name. */
.trustee-grid .trustee strong {
	color: var(--color-navy);
	font-size: 16px;
	font-weight: 700;
}

/* The committee line, which is the one thing every entry has -- four
   trustees carry no middle role line at all. Inline for the same reason
   as the name above. */
.trustee-grid .trustee em {
	color: var(--color-ink-muted);
	font-style: italic;
}

/* One per row on phones, stated rather than left to auto-fit. The track
   count auto-fit lands on depends on the width of the content column
   it happens to sit in, which is not the same on every template, and a
   two-up grid of 170px cells is not what anyone wants on a phone. */
@media (max-width: 600px) {
	.trustee-grid {
		grid-template-columns: 1fr;
	}
}

/* Lead feature -- a photo with a navy card overlapping its bottom-right
   corner, carrying a short label and the page's opening paragraph. The
   second of the two lead treatments, alongside template-parts/banner.php;
   see inc/lead-feature.php for why both exist and which is for what.

   THE DOUBLED CLASSES BELOW ARE LOAD-BEARING. This block renders inside
   .subpage-content, where .subpage-content img and .subpage-content p sit
   at (0,1,1) and beat any single component class at (0,1,0). Left alone
   they would give the photo a 24px block margin -- pushing it down inside
   its own frame -- and paint the card's paragraph #4d4a4a, dark grey body
   copy on navy. That is the same trap .about-pillar-card fell into when
   the section-cards shortcode first put it inside page content.

   The card is placed by the grid, not by absolute positioning, so the row
   is as tall as the photo and the card can hang past it with a negative
   margin without anything needing a fixed height. */
.lead-feature {
	position: relative;
	display: grid;
	grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
	align-items: end;
	margin: 0 0 80px;
}

/* Stretches to the row rather than sitting at its own natural height.
   The row is as tall as whichever is taller, photo or card, and a long
   intro makes the card the taller one -- left to its own height the
   photo would then sit at the bottom of the row with a band of empty
   page above it. Stretching means a longer intro grows the photo
   instead. min-height is what stops a short intro from collapsing it. */
.lead-feature__media {
	grid-column: 1;
	grid-row: 1;
	align-self: stretch;
	min-height: 280px;
}

.lead-feature .lead-feature__image {
	display: block;
	width: 100%;
	height: 100%;
	margin: 0;
	object-fit: cover;
}

/* Sits in column 2 but pulled left over the photo, and down past its
   bottom edge, so it breaks both. The bottom overhang is what puts the
   card's wave over page background rather than over the photo. */
.lead-feature__card {
	grid-column: 2;
	grid-row: 1;
	position: relative;
	z-index: 2;
	align-self: end;
	margin-left: -96px;
	margin-bottom: -56px;
	padding: 36px 36px 76px;
	background: var(--color-navy);
	overflow: hidden;
}

.lead-feature__label {
	display: block;
	margin-bottom: 14px;
	color: var(--color-gold-accent);
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.08em;
	text-transform: uppercase;
}

.lead-feature .lead-feature__body p {
	margin: 0;
	max-width: none;
	color: rgba(255, 255, 255, 0.92);
	font-size: 16px;
	font-weight: 400;
	line-height: 1.7;
}

.lead-feature .lead-feature__body p + p {
	margin-top: 14px;
}

/* Shorter than the 90px .wave-divider default -- at full height the
   curve eats most of a card this size. */
.lead-feature .lead-feature__wave {
	height: 56px;
}

/* Stacked below 900px: the overlap becomes vertical, the card rising
   into the foot of the photo rather than crossing its corner. */
@media (max-width: 900px) {
	.lead-feature {
		display: block;
		margin-bottom: 56px;
	}

	.lead-feature__card {
		margin: -56px 0 0;
		padding: 28px 24px 64px;
	}
}

/* Photo-beside-text block for migrated subpage content -- a paragraph
   with a supporting photo alongside it, alternating sides down the
   page. Deliberately generic rather than named after the first page to
   use it (Drama): the same pattern fits most of the Student Life and
   Academics pages still to be migrated from the old site.
   align-items:start so a short paragraph doesn't stretch the photo. */
.page-media {
	display: grid;
	grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
	gap: 32px;
	align-items: start;
	margin: 32px 0;
}

/* Photo on the right instead, for alternating blocks. Only the columns
   swap -- the DOM order stays photo-then-text, so the reading order a
   screen reader follows is unchanged. */
.page-media--reverse .page-media__figure {
	grid-column: 2;
	grid-row: 1;
}

.page-media--reverse .page-media__body {
	grid-column: 1;
	grid-row: 1;
}

/* Natural size up to the column width, never upscaled: one of the
   migrated photos is only 206px wide on the old site, and stretching it
   to fill the column would just make it blurry. Sharp and small beats
   soft and large.
   margin:0 overrides .subpage-content img's own 24px block margin,
   which would otherwise misalign the photo against its paragraph. */
.page-media__figure img {
	display: block;
	width: auto;
	max-width: 100%;
	height: auto;
	margin: 0;
}

.page-media__body > p:first-child {
	margin-top: 0;
}

@media (max-width: 700px) {
	/* Stacked, photo first in both directions -- side-by-side leaves
	   neither the photo nor the text enough width to work. */
	.page-media {
		grid-template-columns: 1fr;
		gap: 16px;
	}

	.page-media--reverse .page-media__figure,
	.page-media--reverse .page-media__body {
		grid-column: 1;
		grid-row: auto;
	}
}

/* Online Store product photos (scripts/build-online-store-page.php).
   WordPress's core [gallery] shortcode floats its items and ships no
   usable layout of its own, so this overrides the float with a simple
   even row. Scoped to .store-gallery rather than styling .gallery
   globally -- nothing else on the site uses a classic gallery yet, and
   a blanket rule would silently reshape any that appear later.
   The 20px gutter matches the staff directory's, the site's convention
   where a grid has real gutters. */
.store-gallery .gallery {
	display: grid;
	grid-template-columns: repeat(3, minmax(0, 1fr));
	gap: 20px;
	margin: 32px 0;
}

.store-gallery .gallery-item {
	float: none;
	width: auto;
	margin: 0;
}

/* margin:0 overrides .subpage-content img's own 24px block margin,
   which would otherwise push each photo away from its cell edges and
   break the row's alignment. 4:3 keeps the three landscape product
   shots the same height whatever their native ratios. */
.store-gallery .gallery img {
	display: block;
	width: 100%;
	height: 100%;
	margin: 0;
	aspect-ratio: 4 / 3;
	object-fit: cover;
	border: none;
}

.store-gallery .gallery-caption {
	margin: 6px 0 0;
	font-size: 13px;
	color: var(--color-ink-muted);
}

@media (max-width: 700px) {
	/* Three across leaves each product photo too small to make out on a
	   phone. */
	.store-gallery .gallery {
		grid-template-columns: repeat(2, minmax(0, 1fr));
		gap: 12px;
	}
}

/* Faculty & Staff directory (template-parts/faculty-directory.php).
   Same hairline-divider grid recipe the rest of the site already uses
   -- 2px gap over a divider-coloured background/border, cards butted
   against thin rules (see .stat-card-grid, .pillar-grid, .events-grid)
   -- rather than a new card treatment. */
.staff-directory {
	margin-top: 40px;
}

.staff-directory__heading {
	margin: 0 0 20px;
	font-size: 24px;
}

/* Real gutters between tiles rather than the hairline-divider mosaic
   the other card grids on the site use. Each headshot needs to read as
   a separate person, not as one panel of a photo wall -- so this drops
   the divider-coloured background and border those grids rely on, and
   the gap becomes actual space rather than a 2px rule. 20px sits just
   under the 24px gutter .academics-preview-grid uses, which is the
   site's convention wherever a grid has real gutters.
   Still flex, not grid: a group's people rarely divide evenly into
   rows (7 and 30 here), and grid would leave the shortfall as empty
   cells showing through. Flex wrapping has no cells at all -- a short
   last row simply holds fewer items.
   justify-content centres each line, which only has any effect on a
   short one: a full row of 4 already measures exactly 100%, so there's
   nothing left to distribute. A short row is centred instead of being
   stretched (which made its tiles visibly wider than everyone else's)
   or left-aligned with a gap trailing off to the right. */
.staff-grid {
	margin: 0 0 40px;
	display: flex;
	flex-wrap: wrap;
	justify-content: center;
	gap: 20px;
}

/* The basis is what fixes the cards-per-row: for N per row with a 20px
   gap it's (100% - (N-1) x 20px) / N, which is 25% - 15px at 4 across.
   Exactly 4 fit (4 x basis + 3 x gap = 100%) and a 5th can never fit,
   so the wrap point is stable.
   grow AND shrink are both 0 on purpose: every tile stays exactly one
   quarter-width whether its row holds 4 of them or 2, so a person on
   the last row isn't drawn larger than everyone above them. The short
   row is handled by centring it (see .staff-grid) rather than by
   letting its tiles expand.
   No padding: the photo is full-bleed to the card's edges, and the
   overlay covers it exactly. position:relative is what the overlay's
   inset:0 anchors to. */
.staff-card {
	position: relative;
	flex: 0 0 calc(25% - 15px);
	min-width: 0;
	background: var(--color-white);
	text-align: center;
}

/* Portrait 4:5 rather than a fixed height, so every card matches
   whatever the row's width works out to and the photos never distort.
   The scraped headshots are 300x400 (exactly 3:4) and 239x320, so
   object-fit trims a little top and bottom rather than stretching. */
.staff-card__photo {
	position: relative;
	aspect-ratio: 4 / 5;
	overflow: hidden;
	background: var(--color-navy-100);
}

/* margin:0 overrides .subpage-content img's own 24px block margin --
   this renders inside .subpage-content, where that rule would push the
   photo out of its frame. */
.staff-card__photo img {
	display: block;
	width: 100%;
	height: 100%;
	margin: 0;
	object-fit: cover;
}

/* Stand-in when a person has no photo yet. Fills the same 4:5 frame so
   a mixed directory keeps a regular grid. */
.staff-card__monogram {
	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	font-family: var(--font-heading);
	font-size: 34px;
	font-weight: 800;
	line-height: 1;
	color: var(--color-navy);
}

/* Hidden with opacity, never display:none or visibility:hidden --
   both of those would drop the name and title out of the accessibility
   tree, and they should be announced whether or not anyone is
   hovering. pointer-events:none is what stops the invisible mailto
   link swallowing clicks; keyboard focus is unaffected by it, so Tab
   still reaches the link and :focus-within below then reveals the
   overlay for sighted keyboard users. */
.staff-card__overlay {
	position: absolute;
	inset: 0;
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	gap: 8px;
	padding: 16px 12px;
	background: rgba(var(--color-navy-rgb), 0.88);
	color: var(--color-white);
	opacity: 0;
	pointer-events: none;
	transform: translateY(8px);
	transition: opacity 250ms ease, transform 250ms ease;
}

.staff-card:hover .staff-card__overlay,
.staff-card:focus-within .staff-card__overlay {
	opacity: 1;
	pointer-events: auto;
	transform: translateY(0);
}

/* Both qualified with .staff-card__overlay (2 classes) rather than
   written as bare single-class selectors, and every inherited property
   restated rather than left to cascade. This directory renders inside
   .subpage-content, where `.subpage-content p` is one class plus one
   element -- specificity 0,1,1 -- which beats a lone 0,1,0 class. That
   rule was winning and painting both lines #4d4a4a on the navy
   overlay: 1.26:1 against the lightest photo, effectively invisible.
   It was also overriding the sizes here with its own 17px/500/1.65 and
   capping the text at max-width 760px. Same specificity trap the nav
   dropdown rules further up document. */
.staff-card__overlay .staff-card__name {
	max-width: none;
	margin: 0;
	font-family: var(--font-heading);
	font-size: 16px;
	font-weight: 700;
	line-height: 1.25;
	color: var(--color-white);
}

.staff-card__overlay .staff-card__title {
	max-width: none;
	margin: 0;
	font-size: 13px;
	font-weight: 400;
	line-height: 1.45;
	color: rgba(255, 255, 255, 0.92);
}

.staff-card__email {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 38px;
	height: 38px;
	margin-top: 2px;
	border-radius: 50%;
	color: var(--color-navy);
	background: var(--color-white);
	transition: background-color 200ms ease, color 200ms ease;
}

.staff-card__email:hover {
	color: var(--color-white);
	background: var(--color-gold-accent);
}

.staff-card__email:focus-visible {
	outline: 2px solid var(--color-gold-accent);
	outline-offset: 3px;
}

/* Touch devices can't hover, and the sticky-hover emulation that some
   of them fake on first tap is inconsistent -- on Android the state
   often clears again immediately. Rather than gamble a person's name
   and title on that, the overlay is simply always shown where there's
   no hover capability: nothing is hidden, and the mailto link is one
   tap rather than two.

   BUT NOT AS THE FULL-CARD PANEL THE HOVER STATE USES. Always-on plus
   inset:0 and a flat rgba(navy, 0.88) meant every face on the directory
   sat behind a heavy navy wash permanently -- the treatment reads as a
   reveal when it appears on hover, and as a fault when it never leaves.
   Here the overlay is pinned to the bottom instead and painted with a
   gradient that starts fully transparent, so the photo above the text is
   untouched and only the strip behind the two lines is darkened. Same
   idea as the section cards' hover gradient, held open permanently.

   The box is taller than the tint it draws: 40px of top padding sits
   inside the transparent part of the gradient, which is what lets the
   darkening arrive gradually rather than as an edge across the face.
   The overlay carries only a name and a title -- no email buttons are
   populated -- so two lines is all it ever has to hold.

   THE STOPS ARE MEASURED, not chosen by eye. Rendering the directory at
   phone width and reading the composite back gives, for white text over
   every card on the page: worst 5.92:1, first percentile 6.97:1, median
   12.22:1, and no line of text with a pixel under 4.5:1. A gentler ramp
   was tried first -- 0.5 at 40% -- and bottomed out at 3.75:1 on the
   lightest photos, over the 3:1 large-text floor but under the 4.5:1
   that 16px bold and 13px regular actually need. */
@media (hover: none) {
	.staff-card__overlay {
		opacity: 1;
		pointer-events: auto;
		transform: none;
		inset: auto 0 0 0;
		justify-content: flex-end;
		gap: 2px;
		padding: 40px 10px 12px;
		background: linear-gradient(
			180deg,
			rgba(var(--color-navy-rgb), 0) 0%,
			rgba(var(--color-navy-rgb), 0.62) 32%,
			rgba(var(--color-navy-rgb), 0.95) 100%
		);
	}
}

/* Someone who has asked for less motion still gets the overlay, just
   without the slide. */
@media (prefers-reduced-motion: reduce) {
	.staff-card__overlay {
		transform: none;
		transition: opacity 250ms ease;
	}
}

/* About: facts / hours / opportunities / traditions / grad schools */
.facts-grid {
	margin: 0 0 48px;
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	border-top: 2px solid var(--color-divider);
}

.facts-grid__item {
	padding: 14px 16px 14px 0;
	border-bottom: 1px solid var(--color-divider);
}

.facts-grid__item dt {
	margin: 0 0 4px;
	font-size: 12px;
	font-weight: 700;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	color: var(--color-navy-700);
}

.facts-grid__item dd {
	margin: 0;
	font-size: 16px;
}

.hours-box {
	margin: 0 0 48px;
	padding: 24px;
	background: var(--color-surface-light);
	border-top: 4px solid var(--color-navy);
}

.hours-box__heading {
	margin: 0 0 8px;
}

.hours-box__row {
	margin: 0 0 4px;
	color: var(--color-ink-muted);
}

.hours-box__note {
	margin: 12px 0 0;
	color: var(--color-ink-muted);
}

.opportunity-grid {
	margin: 0 0 48px;
	display: grid;
	grid-template-columns: repeat(3, minmax(0, 1fr));
	gap: 2px;
	background: var(--color-divider);
	border: 2px solid var(--color-divider);
}

.opportunity-card {
	background: var(--color-offwhite);
	padding: 24px;
}

.opportunity-card h3 {
	font-size: 17px;
	margin: 0 0 10px;
}

.opportunity-card p {
	margin: 0;
	color: var(--color-ink-muted);
	font-size: 14px;
}

.tag-list {
	margin: 0 0 48px;
	display: flex;
	flex-wrap: wrap;
	gap: 10px;
}

/* Plain highlight labels (About's Traditions list, Academics' division
   subject highlights and Specials & Enrichment row) -- none of these are
   links, so no border/padding pill look implying they're clickable. A
   small gold dot marks each label instead of an underline, which would
   read as a link affordance even with no hover/cursor state to back it
   up. */
.tag {
	display: inline-flex;
	align-items: center;
	font-size: 1rem;
	font-weight: 600;
	letter-spacing: 0.03em;
	color: var(--color-navy-700);
}

.tag::before {
	content: '';
	flex-shrink: 0;
	width: 6px;
	height: 6px;
	margin-right: 8px;
	border-radius: 50%;
	background: var(--color-gold-accent);
}

.two-col-list {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	gap: 8px 32px;
	border-top: 2px solid var(--color-divider);
	padding-top: 16px;
}

.two-col-list li {
	font-size: 15px;
	color: var(--color-ink-muted);
}

/* Site footer. Official STJES Blue exactly (var(--color-navy),
   #041A71) rather than the darker --color-navy-800 shade used
   elsewhere for large dark surfaces (feature cards, academics preview
   cards) — a deliberate exception for the footer specifically. */
.site-footer {
	position: relative;
	background: var(--color-navy);
	padding: 64px 24px 48px;
	color: rgba(255, 255, 255, 0.85);
	/* No overflow:hidden here — the top/bottom wave-dividers are
	   deliberately positioned 1px outside this box (see their own
	   top:-1px/bottom:-1px), and clipping that sliver is exactly what
	   would break them again. */
}

/* Faint full-color photo behind everything, low opacity so it reads
   as atmosphere/texture rather than a visible picture — the solid
   navy .site-footer background above still does the real legibility
   work; this is layered on top of it, not instead of it. A background-
   image on its own div (not a real <img>) so opacity only affects the
   photo, same technique as .intro-section__bg. z-index:0, under both
   the wave-dividers (2) and the actual content (1, see
   .site-footer__inner) so it never competes with either. */
.site-footer__bg {
	position: absolute;
	inset: 0;
	z-index: 0;
	background-size: cover;
	background-position: center;
	opacity: 0.18;
	pointer-events: none;
}

/* position:relative + z-index so this sits above .site-footer__bg —
   without an explicit stacking context here, the positioned bg photo
   would paint over this plain-flow content regardless of DOM order. */
.site-footer__inner {
	position: relative;
	z-index: 1;
}

/* A normal-flow row of its own, right-aligned, rather than absolutely
   positioning the button itself — .site-footer__top's own "Quick
   Links" column already sits at that same top-right corner of
   .site-footer__inner, so an absolutely-positioned button there would
   overlap it instead of sitting cleanly above it. */
.site-footer__back-to-top-row {
	display: flex;
	justify-content: flex-end;
	margin-bottom: 24px;
}

/* Text label + a separate filled circular icon button, rather than one
   bordered rectangle around both — matches the small-italic-label +
   compact-pill-icon treatment referenced from Holton-Arms' site. */
.site-footer__back-to-top {
	display: inline-flex;
	align-items: center;
	gap: 10px;
	padding: 0;
	background: none;
	border: none;
	color: rgba(255, 255, 255, 0.85);
	font: inherit;
	cursor: pointer;
	transition: color 200ms ease;
}

.site-footer__back-to-top:hover {
	color: var(--color-white);
}

.site-footer__back-to-top-label {
	font-style: italic;
	font-weight: 700;
	font-size: 12px;
	letter-spacing: 0.05em;
	text-transform: uppercase;
}

.site-footer__back-to-top-icon {
	display: inline-flex;
	flex-shrink: 0;
	align-items: center;
	justify-content: center;
	width: 32px;
	height: 32px;
	border-radius: 50%;
	background: var(--color-gold-accent);
	color: var(--color-navy);
	transition: transform 200ms ease;
}

.site-footer__back-to-top:hover .site-footer__back-to-top-icon {
	transform: scale(1.08);
}

/* Its own column now (was stacked above the name/motto in a single
   brand column, which made that one column much taller than the two
   nav columns beside it — splitting the crest out into a dedicated
   column fixes that instead of shrinking it). flex-shrink:0 so the
   240px image keeps its full size rather than getting squeezed by the
   flex row. Back to a stacked (block) layout for the caption below
   the crest -- a side-by-side flex row read as disconnected from the
   seal instead of tucked against it. The caption itself is nudged
   right (see .site-footer__crest-caption) to land under "MD.", not
   centered under the whole circle. */
.site-footer__crest-col {
	flex-shrink: 0;
}

/* No color filter — stjes-crest-ring-white.png already has its outer
   ring (text + border circles) recolored to white at the file level
   (see footer.php's docblock), with the interior artwork kept at its
   real navy/white, so it reads correctly against the navy footer using
   its own true colors. A brightness(0) invert(1) filter here would
   crush all of that back to solid white, same as the old plain
   navy/white file needed the filter for in the first place — the two
   approaches are alternatives, not additive. Detailed circular badge
   (ring text, eagle, banner), not a simple wordmark, so it needs real
   size (240px) for the fine detail to actually read instead of
   blurring into a mushy circle. */
.site-footer__crest {
	height: 240px;
	width: auto;
}

/* Real fact, sourced from the seal's own banner text (see
   assets/images/real-photos/stjes-crest-transparent.png), not
   invented for this design. System serif stack rather than loading a
   second webfont for one small line of text — everything else on the
   site is Archivo (sans-serif); this deliberately breaks from that for
   a traditional, heraldic feel appropriate to a seal motto. */
.site-footer__motto {
	margin: 6px 0 0;
	font-family: Georgia, 'Times New Roman', serif;
	font-style: italic;
	font-size: 14px;
	color: rgba(255, 255, 255, 0.75);
}

/* Real fact, sourced from the seal's own banner text (see
   assets/images/real-photos/stjes-crest-transparent.png), not
   invented for this design. display:inline-block + margin-left:60%
   (rather than text-align:right, which would flush it to the full
   column's right edge) so it sits roughly under "MD." -- "OLNEY, MD."
   reads clockwise around the bottom of the ring, so "MD." itself
   lands a bit right of center, not at the circle's true right edge. */
.site-footer__crest-caption {
	display: inline-block;
	margin: 6px 0 0 60%;
	font-size: 11px;
	font-weight: 700;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	color: rgba(255, 255, 255, 0.55);
}

/* space-between now applies to just 2 groups (brand-group, nav-group)
   rather than 4 individual columns — with 4 flat columns, space-between
   spread them edge-to-edge across the full 1240px row, leaving a big
   gap between the brand column and "Our School" that had nothing to
   do with actual content width. Grouping keeps the brand block tight
   on the left and both nav columns tight together on the right,
   matching the old stjes.org footer's layout. */
.site-footer__top {
	display: flex;
	justify-content: space-between;
	/* Without this, flex's default align-items:stretch would stretch
	   the shorter nav-group to match the brand-group's own height —
	   much taller now that the crest is 240px — leaving it with
	   pointless empty space below its actual content. */
	align-items: flex-start;
	flex-wrap: wrap;
	gap: 40px 28px;
}

.site-footer__brand-group {
	display: flex;
	align-items: flex-start;
	flex-wrap: wrap;
	gap: 28px;
}

.site-footer__nav-group {
	display: flex;
	align-items: flex-start;
	flex-wrap: wrap;
	gap: 56px;
}

.site-footer__brand-col {
	max-width: 340px;
}

.site-footer__name {
	font-family: var(--font-heading);
	font-size: 20px;
	font-weight: 700;
	color: var(--color-white);
}

.site-footer__contact {
	margin-top: 8px;
	font-size: 14px;
}

.site-footer__contact a {
	color: rgba(255, 255, 255, 0.85);
}

/* Without this, the phone number's own hyphens (1-301-774-6804) are
   valid line-break points to a browser's default text wrapping —
   .site-footer__brand-col's 340px max-width is narrower than the full
   address + phone + email line, so it wraps somewhere regardless; this
   just keeps the number itself from being the thing that splits
   mid-digit (e.g. "1-301-774-" / "6804") when it does. */
.site-footer__contact-phone {
	white-space: nowrap;
}

.site-footer__social {
	margin-top: 16px;
	display: flex;
	gap: 12px;
}

.site-footer__social-link {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 44px;
	height: 44px;
	color: var(--color-navy);
	background: var(--color-white);
	border: none;
	border-radius: 50%;
	transition: color 200ms ease, background-color 200ms ease;
}

.site-footer__social-link:hover {
	color: var(--color-navy-900);
	background: var(--color-gold-accent);
}

.site-footer__nav-col {
	min-width: 160px;
}

.site-footer__nav-heading {
	margin: 0 0 16px;
	font-size: 12px;
	font-weight: 700;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	color: var(--color-gold-accent);
}

.site-footer__nav-list {
	display: flex;
	flex-direction: column;
	gap: 12px;
	font-size: 16px;
}

.site-footer__nav-list a {
	font-weight: 600;
	color: rgba(255, 255, 255, 0.85);
	border-bottom: 1px solid transparent;
}

.site-footer__nav-list a:hover {
	color: var(--color-white);
	border-bottom-color: var(--color-gold-accent);
}

/* Left unstyled/plain if a badge has no image yet — see .site-footer__badge
   span below for the plain-text fallback badge treatment. */
.site-footer__accreditations {
	margin-top: 40px;
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 24px;
}

.site-footer__badge {
	display: inline-flex;
	align-items: center;
}

/* Defensive: nothing in this stylesheet currently applies
   padding/background/border to .site-footer__badge itself (only to
   its span child, the text-fallback case — see below), but reset them
   explicitly on the anchor whenever it contains an image anyway, in
   case a box is coming from somewhere this file doesn't control (e.g.
   Customizer "Additional CSS" or a plugin's own custom-CSS field —
   both live in the database, outside this repo, invisible here and
   unaffected by deploys). */
.site-footer__badge:has(img) {
	padding: 0;
	background: none;
	border: none;
	min-height: 0;
}

/* No color filter here on purpose — accreditation seals are
   conventionally shown in their own original colors, not forced
   monochrome, so real badge artwork renders as-is.
   max-height:48px matches the smallest of the 3 real logo files'
   native height (NAIS, 82x48) — max-height only caps images taller
   than that down, it never stretches a smaller one up to fill it, so
   none of the 3 (all low-res source files, 48-67px tall natively) get
   upscaled/softened.
   Explicit no-box reset (padding/background/border) even though the
   badge-box treatment below already only targets .site-footer__badge
   span (the text-fallback case), never img — this guarantees a real
   logo always floats clean on the footer background with nothing
   behind it, rather than relying on that scoping never changing. */
.site-footer__badge img {
	max-height: 48px;
	width: auto;
	padding: 0;
	background: none;
	border: none;
}

/* More padding + a subtle fill + a gold top accent — reads as an
   actual badge/seal now rather than a thin-bordered label. Bumped up
   from a plain 10px/16px outline box, which felt more like an
   accidental placeholder than a deliberate design element. */
.site-footer__badge span {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	min-height: 56px;
	padding: 14px 28px;
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	text-align: center;
	color: rgba(255, 255, 255, 0.9);
	background: rgba(255, 255, 255, 0.06);
	border: 1px solid rgba(255, 255, 255, 0.35);
	border-top: 3px solid var(--color-gold-accent);
	transition: color 200ms ease, background-color 200ms ease, border-color 200ms ease;
}

.site-footer__badge:hover span {
	color: var(--color-white);
	background: rgba(255, 255, 255, 0.1);
	border-color: var(--color-gold-accent);
}

.site-footer__bottom {
	margin-top: 32px;
	padding-top: 24px;
	border-top: 1px solid rgba(255, 255, 255, 0.15);
}

.site-footer__copyright {
	margin: 0;
	font-size: 12px;
	color: rgba(255, 255, 255, 0.6);
}

/* WPForms — on-brand overrides so submitted forms match the flat,
   flush-left, zero-radius design language rather than the plugin's
   generic default styling. */
.wpforms-container {
	max-width: 560px;
	margin: 0;
	font-family: var(--font-body);
}

.wpforms-container .wpforms-field-label {
	font-family: var(--font-heading);
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	color: var(--color-ink);
}

.wpforms-container .wpforms-field-required {
	color: var(--color-navy);
}

.wpforms-container input[type="text"],
.wpforms-container input[type="email"],
.wpforms-container input[type="tel"],
.wpforms-container input[type="url"],
.wpforms-container select,
.wpforms-container textarea {
	font-family: var(--font-body);
	font-size: 16px;
	color: var(--color-ink);
	background: var(--color-white);
	border: 2px solid var(--color-divider);
	border-radius: var(--radius);
	padding: 12px 14px;
}

.wpforms-container input:focus,
.wpforms-container select:focus,
.wpforms-container textarea:focus {
	outline: none;
	border-color: var(--color-navy);
}

.wpforms-container .wpforms-submit-container {
	margin-top: 8px;
}

.wpforms-container button[type="submit"].wpforms-submit {
	display: inline-flex;
	align-items: center;
	font-family: var(--font-heading);
	font-weight: 700;
	font-size: 13px;
	letter-spacing: 0.05em;
	text-transform: uppercase;
	padding: 14px 22px;
	background: var(--color-navy);
	color: var(--color-white);
	border: 2px solid var(--color-navy);
	border-radius: var(--radius);
	cursor: pointer;
}

.wpforms-container button[type="submit"].wpforms-submit:hover {
	background: var(--color-white);
	color: var(--color-navy);
}

.wpforms-container .wpforms-error,
.wpforms-container label.wpforms-error {
	color: var(--color-navy-700);
	font-size: 13px;
}

/* Responsive collapse for front-page sections and header */
@media (max-width: 900px) {
	/* The bleed-under-hero look (hero starts at document y:0, header
	   floats transparently over its top) only works when the header is
	   short relative to the hero — true on desktop (~112px vs a
	   640px hero). The header stays that same short single-row height on
	   mobile now too (the hamburger toggle replaces the nav row instead
	   of it wrapping into several stacked rows the way it used to), but
	   mobile keeps this solid-navy override rather than switching to
	   bleed-under: at a narrower viewport the hero's own text has less
	   room to avoid the header overlapping it, and this wasn't something
	   that was asked to change here. Solid background regardless of
	   scroll state, since there's no image behind it left to show
	   through once they're no longer overlapping. */
	.site-header--overlay {
		background: var(--color-navy);
	}

	/* --header-height is still the :root default (72px) here — no mobile
	   override anymore. It used to jump to 460px for the old stacked
	   header (logo row, then a wrapped/scrolling nav row); this padding
	   was sized against that value, so once the header stopped actually
	   being that tall (nav now collapses behind the hamburger toggle
	   instead of wrapping), this reserved a large empty gap above
	   whatever came next — the dead space above the homepage hero. */
	body:not(.site-body--no-hero) {
		padding-top: var(--header-height);
	}

	/* No breakout/shrink on mobile — mobile's header never bleeds under
	   the hero (see the .site-header--overlay override above), so
	   there's no photo for an oversized logo to spill onto. Stays at the
	   compact 48px size always. */
	.site-header--overlay:not(.site-header--scrolled) .site-header__logo img {
		height: 48px;
	}

	.mobile-nav-toggle {
		display: flex;
	}

	/* Full-screen slide-down overlay instead of the old wrapped/scrolling
	   inline row — toggled by .is-nav-open on .site-header (see
	   assets/js/mobile-nav.js). top matches --header-height so it sits
	   directly under the (now short, single-row) header bar; no z-index
	   needed since .site-header already establishes the stacking context
	   this is a descendant of. */
	.primary-nav {
		position: fixed;
		top: var(--header-height);
		left: 0;
		right: 0;
		/* height (not bottom: 0) using the dynamic viewport unit -- on
		   mobile, bottom: 0 on a fixed element can resolve against the
		   taller layout viewport rather than the shorter visual one while
		   the browser's own toolbar chrome is showing, leaving part of
		   this box (and the nav items in it) rendered behind that chrome
		   where no amount of scrolling the list can reach it. dvh tracks
		   the visible viewport as the toolbar shows/hides instead. */
		height: calc(100dvh - var(--header-height));
		margin-left: 0;
		width: auto;
		background: var(--color-navy);
		overflow-y: auto;
		opacity: 0;
		visibility: hidden;
		transform: translateY(-12px);
		transition: opacity 200ms ease, transform 200ms ease, visibility 0s 200ms;
	}

	.site-header.is-nav-open .primary-nav {
		opacity: 1;
		visibility: visible;
		transform: translateY(0);
		transition: opacity 200ms ease, transform 200ms ease, visibility 0s;
	}

	.primary-nav__list {
		flex-direction: column;
		align-items: stretch;
		gap: 0;
		overflow-x: visible;
		padding: 8px 24px 40px;
	}

	.primary-nav__list li {
		border-bottom: 1px solid rgba(255, 255, 255, 0.15);
	}

	.primary-nav__list a:where(:not(.btn)),
	.quick-links__trigger {
		width: 100%;
		min-height: 56px;
		font-size: 15px;
	}

	.primary-nav__list li.menu-item-has-children > a,
	.quick-links__trigger {
		justify-content: space-between;
	}

	.primary-nav__list .primary-nav__cta {
		border-bottom: none;
		margin-top: 16px;
	}

	.primary-nav__list .primary-nav__cta .btn {
		width: 100%;
		justify-content: center;
	}

	/* Chevron rotates to point up once expanded -- same corner-rotation
	   technique as the accordion's own open/closed chevron (see
	   .accordion-item__summary::after), just tap-driven here (see
	   assets/js/mobile-nav.js) instead of the native <details> toggle. */
	.primary-nav__list li.menu-item-has-children.is-expanded > a::after,
	.quick-links.is-expanded .quick-links__trigger::after {
		transform: translateY(2px) rotate(225deg);
	}

	/* Static + collapsed by default instead of the desktop absolutely-
	   positioned hover panel -- stacks inline in the overlay's own
	   vertical flow, single-column (not the desktop 220px/280px grid),
	   shown only once its parent gets .is-expanded (tap-toggled). */
	.nav-dropdown,
	.quick-links__panel {
		position: static;
		display: none;
		grid-template-columns: 1fr;
		margin-top: 0;
		background: transparent;
		border-top: none;
		box-shadow: none;
		opacity: 1;
		visibility: visible;
		transform: none;
		transition: none;
	}

	.primary-nav__list li.menu-item-has-children.is-expanded > .nav-dropdown,
	.quick-links.is-expanded .quick-links__panel {
		display: block;
	}

	/* Side-by-side left border made sense for the desktop 2-column grid;
	   meaningless once the feature panel stacks below the links list
	   instead of beside it. Its own light background/dark text (set
	   elsewhere) is untouched and stays legible as-is against the dark
	   overlay around it. */
	.nav-dropdown__feature {
		border-left: none;
	}

	/* Desktop's dropdown/Quick Links links are navy text meant for a
	   white popup panel; this overlay's panel is navy background
	   instead, so they need to flip to light text to stay readable.
	   Same selector specificity as their desktop counterparts (earlier
	   in this file), so this wins purely by being later in the file, at
	   a breakpoint where it's the only one of the pair that applies.
	   Extra left padding signals these are nested under their parent
	   item, not another top-level row. */
	.site-header .nav-dropdown .nav-dropdown__links a,
	.site-header .quick-links .quick-links__panel a {
		padding: 14px 12px 14px 24px;
		color: rgba(255, 255, 255, 0.85);
	}

	.site-header .nav-dropdown .nav-dropdown__links a:hover,
	.site-header .quick-links .quick-links__panel a:hover {
		background: rgba(255, 255, 255, 0.08);
	}

	.nav-dropdown__links li,
	.quick-links__panel li {
		border-bottom-color: rgba(255, 255, 255, 0.15);
	}

	.site-header .nav-dropdown .nav-dropdown__more {
		color: var(--color-white);
	}

	.hero-slider {
		height: 520px;
	}

	/* Hero's own wave-divider only (a more specific selector than the
	   shared .wave-divider class every other section reuses, so this
	   doesn't touch them). Its viewBox is scaled with
	   preserveAspectRatio="none", so the curve's fixed viewBox
	   coordinates get squeezed horizontally into a much narrower actual
	   width on an actual phone -- its peak, comfortably clear of
	   .hero-slider__dots at desktop widths, shifts left into roughly
	   the same horizontal footprint as the dots (which sit pinned to
	   the left edge). Combined with mobile's much shorter hero (520px
	   vs desktop's 760px) while the wave keeps its full 90px height,
	   that peak crowds the dots far more here than at desktop width.
	   Shrinking the box (bottom stays flush at -1px, so the seam with
	   the section below is unaffected) pulls the whole curve, peak
	   included, down and clear. */
	.hero-slider .wave-divider {
		height: 60px;
	}

	.hero-slide__content {
		left: 0;
		right: 0;
		bottom: 60px;
		align-items: flex-start;
		text-align: left;
		max-width: 100%;
		padding: 0 24px;
	}

	.hero-slide__title {
		font-size: 32px;
	}

	.hero-slide__text {
		font-size: 15px;
	}

	.intro-section__grid,
	.feature-grid,
	.student-life-grid {
		grid-template-columns: 1fr;
	}

	/* Neither of these had a mobile size before -- the desktop 42px
	   heading and 19px body held at every width, and the new 48px/22px
	   would have been worse still on a phone. Line-heights carry over
	   from the base rules unchanged; only the sizes step down. */
	.intro-section__heading {
		font-size: 32px;
	}

	.intro-section__body {
		font-size: 18px;
	}

	/* First mobile vertical padding on this site -- until now every
	   section kept its full desktop padding at phone widths, so the
	   homepage carried several hundred px of dead space a phone user
	   had to scroll past. 56px (--space-section-sm) is the mobile step.
	   Only sections with NO wave-divider on either edge are handled
	   here. A wave is 90px tall and painted over its section's own
	   padding, so anything below that would slide content underneath
	   it -- .academics-preview-section, .cta-banner and .events-section
	   all need a paired wave-height reduction first and are left at
	   their desktop values for now, deliberately. .intro-section and
	   .student-life-section are free: the waves adjacent to them
	   (academics-preview's top, cta-banner's top) live inside those
	   sections, not these. */
	.intro-section {
		padding: var(--space-section-sm) 0 var(--space-section-sm);
	}

	.student-life-section {
		padding: var(--space-section-sm) 0 var(--space-section-sm);
	}

	.cta-banner__inner {
		grid-template-columns: 1fr;
		text-align: left;
	}

	/* Bottom only -- the top is already --space-section-sm at every
	   width. No waves anywhere near this one. */
	.subpage-layout {
		grid-template-columns: 1fr;
		padding: var(--space-section-sm) 24px var(--space-section-sm);
	}

	.about-intro {
		padding: var(--space-section-sm) 24px 0;
	}

	.pillar-grid,
	.opportunity-grid {
		grid-template-columns: repeat(2, minmax(0, 1fr));
	}

	.division-grid,
	.academics-preview-grid {
		grid-template-columns: 1fr;
	}

	.events-grid {
		grid-template-columns: repeat(min(2, var(--events-count, 4)), minmax(0, 1fr));
	}

	.facts-grid,
	.two-col-list {
		grid-template-columns: 1fr;
	}

	.stat-card-grid {
		grid-template-columns: repeat(2, minmax(0, 1fr));
	}

	/* 2 per row instead of 4 -- at phone widths 4 across leaves each
	   tile too narrow for a face to read. Same basis formula as the
	   base rule: (100% - 1 x 20px) / 2. */
	.staff-card {
		flex-basis: calc(50% - 10px);
	}

	/* Odd-count guard, scoped to this 2-column layout: an odd number of
	   stats leaves the last row half empty, showing the grid's own
	   divider-grey through where the missing card would be. Spanning the
	   final card across both columns fills it instead. No effect at an
	   even count (4 stats, the recommended number). Deliberately NOT in
	   the base rule -- at desktop the column count tracks the card count
	   via --stat-count, so an odd count there is already a full row, and
	   an unscoped version of this would wrongly stretch the last card of
	   a 3-stat strip across the whole grid. */
	.stat-card:last-child:nth-child(odd) {
		grid-column: 1 / -1;
	}

	.subpage-banner {
		height: 340px;
	}

	/* The base rule is already left-aligned now, so this just widens the
	   box to the full viewport (right:0) and swaps the container's
	   wide-viewport padding formula for a flat 24px -- everything else
	   (left-align, vertical centering) already carries over from the
	   base rule unchanged. */
	.subpage-banner__content {
		right: 0;
		padding: 0 24px;
	}

	/* The desktop 0.14em is what makes this run wide out of proportion
	   to its 13px size -- uppercase and heavily tracked, a 3-level trail
	   ("Student Life / Athletics / Sports Calendar") measures roughly
	   450px against the ~327px a 375px-wide phone leaves, so it wraps to
	   a second line it would otherwise mostly fit on. Tightened here
	   only; desktop keeps the wider tracking, where there's room for it. */
	.subpage-banner__crumbs {
		letter-spacing: 0.08em;
	}

	/* Un-forces the desktop nowrap: at 32px this is very unlikely to
	   overflow a phone-width viewport for any realistic title, but
	   letting a rare long one wrap to 2 lines is a far smaller risk
	   than forcing nowrap and causing a horizontal scroll instead. */
	.subpage-banner__content h1 {
		white-space: normal;
		font-size: 32px;
	}

	.quote-band {
		height: 460px;
	}

	.quote-band__content {
		max-width: 100%;
		padding-right: 24px;
	}
}

/* Below this, .pillar-grid (About, Admissions, Support) and
   .opportunity-grid (About, Support) still had the 2-column layout the
   900px breakpoint above sets -- at ~375px that leaves each column only
   ~160px wide (375px viewport, minus 24px container padding each side,
   minus the grid's own 2px gap/border), genuinely cramped rather than
   just narrow. Single column instead, full-width cards, only for the
   phones actually narrow enough to need it. */
@media (max-width: 480px) {
	.pillar-grid,
	.opportunity-grid {
		grid-template-columns: 1fr;
	}
}

/* Per-event detail page (single-stjes-event.php, routed by
   inc/event-detail.php). Deliberately plain: no banner photo exists for
   a Google Calendar event, so this is a narrow reading column under the
   solid header rather than anything hero-like. Max-width matches
   .subpage-content's own measure so the description reads the same as
   body copy elsewhere. */
.event-detail {
	padding: var(--space-section-sm) 24px var(--space-section);
	max-width: 800px;
}

.event-detail__crumbs {
	margin: 0 0 16px;
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
	color: var(--color-navy-700);
}

.event-detail__title {
	margin: 0 0 24px;
	font-size: 42px;
	line-height: 1.15;
}

.event-detail__meta {
	margin: 0 0 32px;
	padding: 24px;
	background: var(--color-surface-light);
	border-left: 3px solid var(--color-gold-accent);
}

.event-detail__meta-row {
	display: grid;
	grid-template-columns: 90px minmax(0, 1fr);
	gap: 12px;
	align-items: baseline;
}

.event-detail__meta-row + .event-detail__meta-row {
	margin-top: 12px;
}

.event-detail__meta dt {
	margin: 0;
	font-size: 12px;
	font-weight: 700;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	color: var(--color-ink-muted);
}

.event-detail__meta dd {
	margin: 0;
	font-size: 17px;
	color: var(--color-ink);
}

.event-detail__back {
	margin: 40px 0 0;
}

@media (max-width: 900px) {
	.event-detail {
		padding: var(--space-section-sm) 24px var(--space-section-sm);
	}

	.event-detail__title {
		font-size: 30px;
	}

	/* Stacks the label above its value -- a fixed 90px label column
	   leaves too little room for a date range at phone widths. */
	.event-detail__meta-row {
		grid-template-columns: 1fr;
		gap: 4px;
	}
}

/* Simple Calendar (google-calendar-events) plugin overrides -- the
   /calendar/ page's [calendar id="357"] shortcode. Confirmed via the
   plugin's own source that it ships zero CSS for its month-grid event
   titles (.simcal-event-title, a plain <span>, not a link) -- no
   color/font-weight rule exists anywhere in its compiled stylesheets,
   so titles were just inheriting the page's default text color and
   reading as flat/muted. Bolded and given the site's real navy brand
   color to match the legibility of the old stjes.org calendar. */
.simcal-calendar .simcal-event-title {
	color: var(--color-navy);
	font-weight: 700;
}

.simcal-calendar .simcal-event:hover .simcal-event-title {
	color: var(--color-navy-600);
}

/* assets/js/calendar-event-links.js swaps each title <span> for an <a>
   pointing at that event's own page, so the grid needs the anchor to
   keep reading as a title rather than picking up default link styling.
   Selector matches the span rule above on purpose (the JS copies the
   class across), so the two stay in step -- this only adds what an
   anchor brings with it that a span doesn't. */
.simcal-calendar a.simcal-event-title {
	display: block;
	text-decoration: none;
	cursor: pointer;
}

.simcal-calendar a.simcal-event-title:hover {
	text-decoration: underline;
	text-underline-offset: 2px;
}

.simcal-calendar a.simcal-event-title:focus-visible {
	outline: 2px solid var(--color-navy);
	outline-offset: 2px;
}

/* ---------------------------------------------------------------------
   Site search: the header's expanding control, and the results page.
   --------------------------------------------------------------------- */

/* Collapsed to an icon by default -- a permanent input would cost the
   nav horizontal room it does not have, and this nav already collapses
   at 900px. The width transition is what makes the expansion read as the
   icon opening rather than a box appearing from nowhere. */
.site-search {
	position: relative;
	display: flex;
	align-items: center;
}

/* 30px rather than 34px: this control is the newest thing in a nav row
   that was already tight, and every pixel it gives back is one the nav
   does not have to wrap for. Still comfortably above the 24px minimum
   for a pointer target, and the tap target on touch is the surrounding
   list item rather than this box. */
.site-search__toggle {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 30px;
	height: 30px;
	padding: 0;
	background: none;
	border: 0;
	color: inherit;
	cursor: pointer;
	transition: color 200ms ease;
}

.site-search__toggle:hover,
.site-search__toggle:focus-visible,
.site-search.is-open .site-search__toggle {
	color: var(--color-gold-accent);
}

/* width rather than display, so it can animate. visibility keeps the
   collapsed input out of the tab order -- width:0 alone would leave a
   focusable field nobody can see, which is the classic way an expanding
   search traps a keyboard user. */
.site-search__form--header {
	display: flex;
	align-items: center;
	width: 0;
	overflow: hidden;
	opacity: 0;
	visibility: hidden;
	border-bottom: 2px solid transparent;
	transition: width 260ms ease, opacity 200ms ease, border-color 200ms ease;
}

.site-search.is-open .site-search__form--header {
	width: 210px;
	opacity: 1;
	visibility: visible;
	border-bottom-color: var(--color-gold-accent);
}

.site-search__input {
	width: 100%;
	padding: 6px 4px;
	background: none;
	border: 0;
	outline: none;
	color: inherit;
	font-family: inherit;
	font-size: 14px;
	font-weight: 600;
}

.site-search__input::placeholder {
	color: currentColor;
	opacity: 0.55;
	font-weight: 400;
}

.site-search__submit {
	flex: 0 0 auto;
	display: flex;
	padding: 4px;
	background: none;
	border: 0;
	color: var(--color-gold-accent);
	cursor: pointer;
}

/* COLOUR COMES FROM THE HEADER VARIANT RULES, not from inheritance.
   color:inherit above looks like it should pick the header's white up,
   and does not: .site-header--overlay / --solid set colour on a named
   list of elements (the logo, the Quick Links trigger, the nav links),
   never on a container. So an unlisted control inherits straight past
   the header to body's --color-ink, which is near-black -- and a
   near-black icon on a navy bar is not a missing icon, it is an
   invisible one. That is exactly how this shipped the first time.

   .site-search__toggle and .site-search__input are therefore added to
   both variant rules, up beside the logo and the Quick Links trigger,
   so anything else added to this header later is one line away from
   being correct instead of one debugging session. */

/* --- the results page --- */

.search-summary {
	margin: 0 0 8px;
	color: var(--color-ink-muted);
	font-size: 13px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

.search-summary strong {
	color: var(--color-navy-700);
}

.search-rule {
	width: 40px;
	height: 3px;
	margin: 0 0 34px;
	background: var(--color-gold-accent);
}

/* The same index rows as the "In This Section" lists. Search results are
   navigation, and the site already has a shape for that. */
.subpage-content .search-results {
	max-width: 820px;
	margin: 0;
	padding: 0;
	list-style: none;
	border-top: 1px solid rgba(32, 30, 29, 0.16);
}

.subpage-content .search-results li {
	margin: 0;
	padding: 0;
}

.subpage-content .search-results li::marker {
	content: '';
}

.subpage-content .search-result {
	display: block;
	padding: 24px 4px;
	border-bottom: 1px solid rgba(32, 30, 29, 0.16);
	text-decoration: none;
	transition: background-color 200ms ease, padding-left 200ms ease;
}

.subpage-content .search-result:hover,
.subpage-content .search-result:focus-visible {
	background: var(--color-surface-light);
	padding-left: 10px;
}

/* Where the page sits, above its title. On a site three levels deep,
   "Kindergarten" alone does not say whether this is the Academics page
   or the admissions visit policy. */
.search-result__crumb {
	display: block;
	margin: 0 0 5px;
	color: var(--color-ink-muted);
	font-size: 11px;
	font-weight: 700;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

.search-result__title {
	display: block;
	color: var(--color-navy-700);
	font-size: 20px;
	font-weight: 700;
	line-height: 1.25;
}

.search-result__arrow {
	color: var(--color-gold-accent);
	font-weight: 700;
}

.search-result__excerpt {
	display: block;
	max-width: 70ch;
	margin-top: 7px;
	color: var(--color-ink);
	font-size: 15.5px;
}

.search-empty {
	max-width: 70ch;
}

.subpage-content .search-empty h2 {
	margin: 0 0 12px;
	font-size: 24px;
}

/* Always visible, unlike the header's: this is the page someone reaches
   when the header's search did not find anything, so the box has to be
   here rather than back up in the nav. */
.site-search__form--page {
	display: flex;
	align-items: center;
	width: 100%;
	max-width: 420px;
	margin-top: 20px;
	border-bottom: 2px solid var(--color-navy);
	color: var(--color-ink);
}

.site-search__form--page .site-search__submit {
	color: var(--color-navy);
}

.search-pagination {
	margin-top: 40px;
}

@media (max-width: 900px) {
	.site-search.is-open .site-search__form--header {
		width: 150px;
	}
}

@media (prefers-reduced-motion: reduce) {
	.site-search__form--header,
	.subpage-content .search-result {
		transition: none;
	}

	.subpage-content .search-result:hover,
	.subpage-content .search-result:focus-visible {
		padding-left: 4px;
	}
}
