/* GlitchCraft Engine — shared design tokens.
   Spacing/radius/container scale is deliberately fixed here; each site's
   colors/fonts come from its own theme block in base.leaf's <style>. */

:root {
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;

  --radius-sm: 6px;
  --radius-md: 8px;
  --radius-lg: 20px;

  --container: 1180px;
  --prose-width: 68ch;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body), -apple-system, BlinkMacSystemFont, sans-serif;
  line-height: 1.6;
}

/* Background depth for jford is now the terminal-window (see
   catalog.css / terminal-window.leaf) — the mesh-blob + grain-texture
   mechanism that used to live here (opt-in via theme.mesh-secondary) was
   removed along with it. Checked before deleting: no other site's
   site.json set mesh-secondary or bg-gradient, so nothing else depended
   on it. */

/* Opt-in colorful body background (theme.gradient-bg — currently just
   jford), same gating pattern as .has-terminal-companion. Three soft
   blobs, one per accent token, each mixed only 32-38% into --bg rather
   than used at full strength — enough to read as "colorful" without
   getting so saturated that --ink text loses contrast directly on top of
   it, or that the glass cards below (which lighten whatever's behind
   them toward white) end up sampling something too vivid. Falls back to
   --accent when a site only defines one accent color (accent-2/3 both
   alias to --accent via the fallback in var(), same pattern used by
   .terminal-glow), so this wouldn't break if some other single-accent
   site opted in later. */
body.has-gradient-bg {
  background:
    radial-gradient(circle at 15% 10%, color-mix(in srgb, var(--accent-3, var(--accent)) 36%, var(--bg)) 0%, transparent 45%),
    radial-gradient(circle at 85% 20%, color-mix(in srgb, var(--accent-2, var(--accent)) 32%, var(--bg)) 0%, transparent 50%),
    radial-gradient(circle at 50% 95%, color-mix(in srgb, var(--accent) 34%, var(--bg)) 0%, transparent 55%),
    var(--bg);
  background-attachment: fixed;
}

.container {
  max-width: var(--container);
  margin: 0 auto;
  padding: 0 var(--space-5);
}

/* Real page content needs its own stacking context above any full-bleed
   `position: fixed` ambient effect (grid-glow/star-field/noise-overlay, or
   a site's own bespoke background) — per CSS paint order, non-positioned
   block content (main/footer's default) paints BEHIND a positioned
   z-index:0 sibling regardless of DOM order, not above it just because it
   comes later in the document. Invisible for a faint effect like
   grid-glow's 3%-opacity lines, but a fully broken (content-hidden) page
   for an opaque one like a WebGL canvas background — caught while wiring
   glitchcraft.studio's background3d.js up to these effects. */
main, .site-footer {
  position: relative;
  z-index: 1;
}

/* ---- nav ----
   Sticky by default for every site — this is the general "always-visible
   header" every site should have, not just the video-hero one. Hyde's
   floating-nav (fixed, starts invisible over the full-bleed video, fades
   in on scroll — see catalog.css body.has-floating-nav + floating-nav.js)
   is a distinct enhancement layered on top via higher specificity; it
   overrides position/background/opacity but nothing here conflicts with
   it needing to. */
.site-nav {
  position: sticky;
  top: 0;
  z-index: 40;
  background: color-mix(in srgb, var(--bg) 76%, transparent);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border-bottom: 1px solid color-mix(in srgb, var(--ink) 15%, transparent);
}
.nav-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-5);
  padding-top: var(--space-4);
  padding-bottom: var(--space-4);
}
.brand {
  font-family: var(--font-display), Georgia, serif;
  font-weight: 600;
  font-size: 1.1rem;
  color: var(--ink);
  text-decoration: none;
}
.nav-inner nav { display: flex; gap: var(--space-5); flex-wrap: wrap; }
.nav-inner nav a {
  color: var(--ink);
  opacity: 0.75;
  text-decoration: none;
  font-size: 0.92rem;
}
.nav-inner nav a[aria-current="page"] { opacity: 1; color: var(--accent); }

/* ---- hero ---- */
.hero {
  padding: var(--space-8) 0 var(--space-7);
  text-align: center;
  position: relative;
  overflow: hidden;
}
/* A quiet wash of the site's own accent behind the hero text — the one
   place every theme gets to show its color prominently, per feedback that
   a flat single-color hero reads as unfinished rather than restrained. */
.hero::before {
  content: "";
  position: absolute;
  inset: -20% -10% auto -10%;
  height: 140%;
  background: radial-gradient(ellipse at 50% 0%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%);
  pointer-events: none;
  z-index: 0;
}
.hero-video::before,
.hero-image::before { content: none; } /* the video/image already carries the visual weight */
.hero-inner { position: relative; z-index: 1; }
.hero .eyebrow {
  font-family: monospace;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 0.75rem;
  color: var(--accent);
  margin: 0 0 var(--space-3);
}
.hero h1 {
  font-family: var(--font-display), Georgia, serif;
  font-size: clamp(2rem, 5vw, 3.2rem);
  margin: 0;
}
.hero-tagline {
  font-size: 1.75rem;
  opacity: 0.7;
  font-family: var(--font-display), Georgia, serif;
  margin: 0;
}

/* ---- profile image (between the hero and the first section) ---- */
.profile-image-container { max-width: 240px; margin: 0 auto var(--space-7); margin-bottom: 0; text-align: center; }
.profile-image {
  width: 100%;
  display: block;
  border-radius: 100%;
  border: 4px solid var(--bg);
  box-shadow: 0 4px 20px color-mix(in srgb, var(--ink) 25%, transparent);
}

/* ---- sections ----
   Every other section gets a full-bleed tinted band (set server-side —
   SectionViewModel.alternate — not nth-child, since heroes are <section>
   too and would throw off a CSS-only odd/even count). Without this a page
   with several sections back to back (six on jford now) reads as one
   continuous wall with no sense of where one ends and the next begins. */
.content-section { padding: var(--space-7) 0; }
.content-section.section-alt {
  background: color-mix(in srgb, var(--ink) 3%, transparent);
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
}
.section-title {
  font-family: var(--font-display), Georgia, serif;
  font-size: 1.6rem;
  margin: 0 0 var(--space-3);
}
.lede { color: color-mix(in srgb, var(--ink) 70%, transparent); max-width: var(--prose-width); }

.content-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--space-5);
}
/* Both of these cap width narrower than their parent .container (1180px)
   — max-width alone just left-aligns a narrower block inside a wider
   one, leaving empty space on one side instead of centering it. Audited
   every other max-width added this session for the same missing
   `margin: 0 auto` after finding it here (see round-4 commit message for
   the full list: video-item's poster/player had it too; .lede and
   .timeline-photo were checked and left alone deliberately — they're
   flowing text/media matching a left-aligned heading beside them, not
   standalone blocks, so centering them would look wrong instead of
   fixing anything). */
.content-container.timeline { grid-template-columns: 1fr; max-width: var(--prose-width); margin: 0 auto; }
/* Wider than pure prose-width — flow sections hold side-by-side
   text+photo feature cards (see .feature-card below), which need more
   room than a single reading column. Solo/trailing text and panels still constrain
   themselves individually (panels via their own padding, plain text via
   the direct-child rule below) so nothing sprawls to the full width. */
.content-container.flow { grid-template-columns: 1fr; max-width: 900px; gap: var(--space-6); }
.content-container.flow > .text-item { max-width: var(--prose-width); margin: 0 auto; }

/* Plain text items read as captions/asides, not full-weight grid cells —
   they're the lightest-weight content an items section can hold. */
.text-item { margin: 0; font-size: 0.92rem; opacity: 0.75; align-self: center; }
/* In a flow section, text items are the real body copy (e.g. a project's
   paragraph beside its photo), not lightweight asides — full weight and
   real typographic care (size/line-height), not the caption treatment
   above, since this now sits next to an image as a design element. */
.content-container.flow .text-item {
  font-size: 1.05rem;
  line-height: 1.7;
  opacity: 1;
}

/* Each paragraph (plus every photo immediately following it — see
   SiteController.buildFeatureCards) is its own bounded card, stacked
   vertically: photo(s) full-width at the top, then the title, then the
   body text — one visual unit reading top-to-bottom, not a side-by-side
   split (photo column vs. text column) that read as two disconnected
   halves rather than one thing. Lighter than .panel below — a project's
   intro should still read as the bold lead-in, these as the supporting
   detail underneath it, in a grid (see .project-cards).

   Glass material (round 2): translucent white over blur instead of a
   flat ink tint — only reads as "glass" now that .has-gradient-bg gives
   it something varied to blur. White (not --ink or --bg) mixed to ~55%,
   matching the inspiration's own ~0.5-0.55 white-alpha cards — since
   glass always lightens whatever's behind it toward its own tint, using
   white here is what keeps --ink text legible regardless of which part
   of the gradient a given card happens to sit over. */
.feature-card {
  background: color-mix(in srgb, white 55%, transparent);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid color-mix(in srgb, white 60%, transparent);
  border-radius: var(--radius-md);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.feature-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 14px 36px rgba(0, 0, 0, 0.12);
}
/* A `group` item can appear in a plain (non-flow) items section too —
   hobbies' ray tracer entry, sitting alongside plain text-item/link-item
   siblings that stay unboxed on purpose. Left to the default
   auto-fit-columns grid, its narrow column would squeeze .feature-card-
   media's fixed-height photo and risk overflow — the same full-width-row
   trick .panel already uses solves it, capped rather than stretched
   edge-to-edge since (unlike .panel) it's not meant to read as a section
   header. Left-aligned, not centered, to match its unboxed siblings. */
.content-container:not(.flow) > .feature-card {
  grid-column: 1 / -1;
  max-width: 640px;
}
.feature-card-body {
  padding: var(--space-5);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
/* Was .feature-card-photos (photo-only) before explicit `group` items
   introduced mixed photo+video cards — renamed alongside
   ItemViewModel.photos -> media. Rendered last in the card now (after
   title/date/text/link — see feature-item.leaf). Row layout (wrap
   allowed) so multiple items (Atlasify's 2 photos, Vision Pro's
   photo+2videos) sit side by side sharing the row instead of stacking.

   width: 100% (not a small fixed max-width) — items are sized by their
   own real aspect ratio now (flex: 0 0 auto + a fixed height, see
   .photo-item/.video-item below), not by growing to fill an arbitrary
   row width, so a small cap just forced multi-item rows to wrap far
   before they needed to: checked the real math against .feature-card's
   actual available width (.content-container.flow's 900px, minus
   .feature-card's 2px border and .feature-card-body's 48px padding,
   border-box throughout, = ~850px) rather than assuming a fixed value
   would work — Atlasify's 2 real photos (2048x1426, 996x726) and Vision
   Pro's photo+2videos (1084x906, a square 1366x1366, 1280x720) both
   genuinely need several hundred px more than the old 360/480px cap ever
   gave them. flex-wrap stays on as a real fallback (a card with enough
   items, or a narrow/mobile viewport) rather than something that fired
   this early — no separate mobile override needed any more either, 100%
   already means 100% of whatever .feature-card-body's width is at any
   viewport. */
/* align-items: flex-start (not the flex row default of stretch) — stretch
   was forcing every item in the row to the row's tallest item's height
   regardless of its own aspect ratio, which is what the object-fit:cover
   below was then cropping to fill. Each item now gets a fixed height of
   its own (see .photo-item/.video-item below) instead, so cross-axis
   stretching has nothing useful left to do and would only fight that. */
.feature-card-media {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--space-3);
  width: 100%;
}
/* flex: 0 0 auto + a fixed height + width: auto — each item's width comes
   from its own real aspect ratio at that shared height (flex-basis: auto
   sizes a flex item to its content's intrinsic width when width itself
   is auto), not from splitting the row's width evenly. */
/* max-width: 100% + min-width: 0 is a real safety net, not a no-op: the
   desktop math in the comment above (and the 3+-item one below) was only
   ever checked against .content-container.flow's 900px desktop budget,
   never against a phone's ~320-390px viewport. A wide-aspect screenshot's
   natural width at a fixed 220px height can easily exceed that on mobile
   — Live Activities' 16:9 MacBook shot alone computes to 391px, wider
   than a 390px viewport by itself — and since this item won't flex-shrink
   (flex: 0 0 auto), nothing was stopping it from overflowing the row,
   the card, and (via every ancestor's own min-width: auto content-based
   floor) the whole page horizontally.

   Both declarations are needed, not just one: max-width alone clamps the
   item's actual rendered box once real layout runs, but a *percentage*
   max-width is ignored while an ancestor (.feature-card, .project-cards,
   .project-group, ...) computes ITS OWN automatic minimum size — that
   calculation can't resolve a percentage against a not-yet-known
   container size, so it fell back to this item's full content-based
   min-content (391px) regardless, and every ancestor's min-width: auto
   inherited that same floor right up the tree. min-width: 0 is what
   actually removes this item from that content-based-minimum
   contribution — confirmed by measuring .feature-card's own min-content
   directly (via a width: min-content probe) before and after: it stayed
   at 441px with max-width alone, and only dropped once min-width: 0 was
   added too. max-width still lets width: auto size from the real aspect
   ratio up to that cap once layout is known — object-fit: contain on the
   img below (see .feature-card-media .photo-item img) is what then
   letterboxes cleanly inside the now-clamped box instead of cropping or
   distorting it. */
.feature-card-media .photo-item { flex: 0 0 auto; height: 220px; width: auto; max-width: 100%; min-width: 0; }
/* The flex-row sizing (flex/width) now targets .video-item-container —
   that's the actual direct child of .feature-card-media's row since the
   caption fix above, not the figure any more (the figure is nested one
   level deeper inside it). align-items: flex-start overrides the column's
   own default stretch (see the base .video-item-container rule) so the
   figure inside sizes from its own aspect-ratio instead of being
   stretched to the container's row-cross-axis width — same
   fixed-height/auto-width mechanism as the photo case above, just via a
   nested flex column rather than a single flex item, since this one
   also needs to make room for an optional caption above the figure
   without the caption affecting the figure's own aspect-ratio box. */
.feature-card-media .video-item-container {
  /* max-width: none above overrides the base .video-item-container's own
     640px cap on purpose (this row wants real-aspect-ratio sizing, not a
     fixed cap) — but "none" also removed any ceiling at all, which is
     exactly what let a wide video overflow the row on mobile the same
     way .photo-item could (see its own comment above, including why
     min-width: 0 has to come with it). 100% restores a real ceiling —
     the row's own available width — without reintroducing the
     desktop-inappropriate fixed 640px this was written to get away from. */
  max-width: 100%;
  min-width: 0;
  flex: 0 0 auto;
  width: auto;
  align-items: flex-start;
}
/* The actual overflow driver, found by measuring: SiteController/
   VideoDimensionProbe write a real `aspect-ratio: W / H` inline style
   onto this <figure> server-side, and with height fixed at 220px + width:
   auto (its default) that resolves to width = 220 * (W/H) — 391px for
   Live Activities' 1920x1080 MacBook capture, wider than a 390px phone
   on its own. .video-item-container/.video-item-player etc. all got a
   max-width: 100% + min-width: 0 pass above, but this figure is the one
   actually carrying the aspect-ratio, and it had neither — so it stayed
   the true min-content floor propagating up through every ancestor
   (.feature-card, .project-cards, .project-group) regardless of what its
   wrapper or children were clamped to. */
.feature-card-media .video-item { height: 220px; max-width: 100%; min-width: 0; }
/* 3+-item rows need more headroom than 220px leaves inside the card's
   real available width to stay on one line — checked the math, didn't
   assume: Vision Pro's photo+2videos (1084x906, a square 1366x1366,
   1280x720) needs ~898px at 220px height, genuinely over the ~850px
   available (.content-container.flow's 900px minus .feature-card's 2px
   border and .feature-card-body's 48px padding, border-box throughout)
   even with the row's own max-width cap removed entirely above. Scoped
   to 3+ items specifically (:has selects a 3rd direct child) rather than
   lowering the height everywhere: Atlasify's 2 photos and Live
   Activities' photo+video already fit fine at the full 220px, so the
   common 1-2 item case is untouched — only a row that actually needs the
   extra room gives any up. 200px brings Vision Pro's row down to
   ~819px, comfortably inside budget. */
.feature-card-media:has(> :nth-child(3)) .photo-item,
.feature-card-media:has(> :nth-child(3)) .video-item {
  height: 200px;
}
/* Mobile: switch the whole row from "shared fixed height, width from
   aspect ratio" to "full width, height from aspect ratio" — the opposite
   axis. The max-width: 100% / min-width: 0 passes added above (on
   .photo-item, .video-item-container, .video-item, and the innermost
   img/video-item-player) all turned out to be necessary but NOT
   sufficient on their own: percentage max-width can't participate in the
   min-content pass an ancestor (.feature-card, .project-cards,
   .project-group) runs to size itself in the first place, since that
   percentage can't be resolved without already knowing the very size
   that pass exists to determine — a circular reference the CSS sizing
   spec resolves by just ignoring percentages there, so every one of
   those max-widths was quietly a no-op for that specific calculation
   (confirmed by re-measuring .feature-card's min-content directly after
   each attempt — it stayed at 441px through all of them). A definite,
   non-percentage constraint breaks the cycle: width: 100% doesn't need
   to ask an ancestor "how much room do I actually have," it just always
   means "all of it" — the browser can resolve it immediately without
   waiting to find out how big anything else's content wants to be, so
   there's nothing left for a min-content pass to need to guess at.
   Duplicated onto the :has() selector too so this actually wins over the
   200px rule immediately above it — same specificity as that rule, not
   the lower specificity a plain .feature-card-media .photo-item selector
   would have lost to. */
@media (max-width: 700px) {
  .feature-card-media .photo-item,
  .feature-card-media .video-item-container,
  .feature-card-media .video-item,
  .feature-card-media:has(> :nth-child(3)) .photo-item,
  .feature-card-media:has(> :nth-child(3)) .video-item {
    height: auto;
    width: 100%;
    max-width: 100%;
    flex: 1 1 100%;
  }
  .feature-card-media .photo-item img,
  .feature-card-media .video-item-player,
  .feature-card-media .video-item-poster img {
    width: 100%;
    height: auto;
  }
}
/* Hover language borrowed from elsewhere on the site rather than
   invented fresh: a gentle scale (same family as .feature-card's own
   translateY lift and the photo-viewer nav buttons' scale-on-hover),
   matching .photo-grid-item:hover img's existing 0.25s-ease/1.04 values
   almost exactly. Scoped to .feature-card-media specifically — .photo-item
   is also used standalone elsewhere (e.g. a flow section's plain photo
   items) where this hover isn't necessarily wanted and wasn't asked for.
   overflow: hidden lives on the stable outer element (the .photo-item
   button itself has no fixed frame of its own, unlike .photo-grid-item's
   grid cell) so the scaled-up image clips at its own rounded corners
   instead of visually spilling into the surrounding gap. No
   prefers-reduced-motion guard — checked first: none of this site's
   other hover-triggered transforms (.feature-card, .photo-grid-item,
   .link-item-icon, the photo-viewer nav buttons) guard themselves either,
   only continuous/ambient animations do (terminal cursor blink, hero
   scroll indicator) — a hover effect only plays when the user's own
   cursor triggers it, not the ambient motion prefers-reduced-motion
   targets. */
.feature-card-media .photo-item {
  border-radius: var(--radius-md);
  overflow: hidden;
}
/* height: 100%; width: auto (not the base .photo-item img's width: 100%)
   + object-fit: contain (not cover) — with the fixed-height row above,
   this is what actually stops the image being cropped: the img sizes
   itself to its own real aspect ratio at that height instead of being
   stretched/cropped to fill a box shaped by its neighbor. object-fit:
   contain is a safety net here more than the active mechanism (an img
   with only height set and width: auto already preserves its intrinsic
   aspect ratio on its own), but keeps this correct if anything upstream
   ever forces both dimensions again. Scoped to .feature-card-media
   specifically — the base .photo-item/.video-item-player rules below
   (used by the photo-grid section, hobbies' click-to-play cloth video,
   and everywhere else these components render) are untouched. */
.feature-card-media .photo-item img {
  height: 100%;
  width: auto;
  /* max-width: 100% — the same reasoning as .photo-item's own max-width
     (see its comment above) but one level deeper: a replaced element
     (img) with width: auto sizes itself from its OWN intrinsic ratio
     independent of its parent's box, so even with .photo-item correctly
     clamped, this img would still render at its full natural width and
     overflow that now-smaller parent. object-fit: contain is what turns
     that clamp into a clean letterbox instead of a crop once this is in
     place. */
  max-width: 100%;
  object-fit: contain;
  transition: transform 0.25s ease;
}
.feature-card-media .photo-item:hover img { transform: scale(1.04); }
/* Same sizing fix for a video's own frame — .video-item-player covers
   both the click-to-play swapped-in player (not currently used inside a
   group, but kept consistent in case one ever is) and, via cascade, the
   autoplay player below (that rule only adds position/opacity/transition,
   it doesn't redeclare object-fit itself, so this is what wins). width:
   auto is inert on the two absolutely-positioned autoplay layers
   specifically (inset: 0 mathematically forces their width to fill the
   parent regardless of what width is declared) — object-fit: contain is
   what actually does the work for those two, letterboxing instead of
   cropping within that fixed box. For a non-autoplay video in this
   context (no inset: 0 in play) width: auto genuinely shrink-wraps it to
   its real aspect ratio, same mechanism as the photo case above. */
.feature-card-media .video-item-player,
.feature-card-media .video-item-poster img,
.feature-card-media .video-item-autoplay-poster {
  height: 100%;
  width: auto;
  /* Same reasoning as .feature-card-media .photo-item img's own
     max-width: 100% above — a no-op for the two absolutely-positioned
     autoplay layers (inset: 0 already forces their width regardless) but
     what actually stops .video-item-player/.video-item-poster img from
     overflowing their own now-clamped parent in the non-autoplay case. */
  max-width: 100%;
  object-fit: contain;
}
/* Same treatment for an autoplay video's own frame — scoped to the
   autoplay case specifically (not the click-to-play .video-item-poster
   path above, which already has its own distinct hover language via the
   play button scaling up; stacking a second whole-figure scale on top of
   that would compete with it rather than fit alongside it). Both layers
   (poster and player) scale together so whichever is currently visible
   during the crossfade stays visually consistent. */
.feature-card-media .video-item-autoplay {
  border-radius: var(--radius-md);
  overflow: hidden;
}
.feature-card-media .video-item-autoplay-poster,
.feature-card-media .video-item-autoplay .video-item-player {
  transition: opacity 0.3s ease, transform 0.25s ease;
}
.feature-card-media .video-item-autoplay:hover .video-item-autoplay-poster,
.feature-card-media .video-item-autoplay:hover .video-item-player {
  transform: scale(1.04);
}
.feature-card-title {
  font-family: var(--font-display), Georgia, serif;
  font-weight: 700;
  font-size: 1.15rem;
  margin: 0;
}
.feature-card-text-only .text-item { margin: 0; }

/* Institution header row, then its project-cards stacked in a single
   column underneath — reverted from a multi-column auto-fit grid per
   explicit correction: "We shouldn't place them horizontally anymore.
   They are all their own thing in this design." Each card is its own
   full-width unit, not a grid cell alongside its siblings. */
/* Indented from the left, flush on the right — reads as "nested under
   the header above" the way an outline indents its children, rather than
   the cards just being another same-width block stacked underneath it.
   Left-only (not centered/inset on both sides): the panel stays the one
   full-width anchor of the group, and the cards read as subordinate to
   it, not just narrower for their own sake. */
.project-cards {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-5);
  margin-left: var(--space-6);
}

/* Just a spacer between the panel and its .project-cards grid, and
   between one project's whole group and the next — the panel and each
   .feature-card already carry their own real, visible boundary, so this
   never needs one of its own. */
.project-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-6);
}

/* Full-width, like a panel — a standalone block that resets the grid row,
   so whatever follows (a video, a photo) starts its own row instead of
   sitting beside the panel as if the two were equal-weight peers. */
.panel {
  grid-column: 1 / -1;
  background: color-mix(in srgb, var(--ink) 6%, transparent);
  border-radius: var(--radius-md);
  padding: var(--space-2);
  position: sticky;
  top: 80px;
  z-index: 10;
}

.panel-header {
    display: flex;
    /* flex-wrap: wrap (not the default nowrap) is what stops a long
       institution name from being a hard, unbreakable horizontal-overflow
       floor at narrow widths — found by tracing the last bit of mobile
       overflow (jford at 320px) back to École Polytechnique's panel:
       icon (48px) + "Polytechnique" as one unbreakable word (~156px) +
       gap + date ("2025 — Present", ~57px) summed to a real min-content
       of ~272px, right at the edge of what a 320px phone's panel padding
       leaves — nowrap meant that could never shrink, only overflow.
       Wrapping lets the date drop to its own line (still right-aligned,
       margin-left: auto still applies within whichever line it ends up
       on) instead. align-items: center still centers each line's own
       content; it doesn't fight the wrap. */
    flex-wrap: wrap;
    align-items: center;
    width: 100%;
}

.panel-header .panel-icon {
  margin-right: 10px;
  margin-bottom: 0px;
  flex-shrink: 0;
}

.panel-header .panel-title {
  margin: 0;
}

.panel-header .timeline-date {
  margin: 0 0 0 auto;
}

@media (max-width: 560px) {
  /* `top: none` isn't a valid value (top only accepts a length/%/auto), so
     the parser drops that whole declaration — it was doing nothing, and
     `position: inherit` only happened to cancel the sticky out because
     .panel's actual parent (.project-group, or .content-container for a
     plain non-grouped .panel) is itself unpositioned, making the inherited
     value resolve to static. Written explicitly instead of relying on
     that: static + auto is what "not sticky" actually means, regardless
     of what position a future ancestor might set. */
  .panel {
    position: static;
    top: auto;
  }
}
/* object-fit: contain (not the box's default stretch-to-fill) — not
   every institution logo is square (umn/epic/seagate aren't, unlike
   Apple's), so a plain fixed-box img would distort them. The white
   background is what an img element's own background-color renders
   through a transparent PNG's see-through areas, no wrapper needed —
   covers whatever's letterboxed on the constrained axis instead of
   showing the page's gradient through it. */
.panel-icon { width: 40px; height: 40px; border-radius: var(--radius-sm); margin-bottom: var(--space-3); object-fit: contain; background: #fff; }
.panel-title { font-family: var(--font-display), Georgia, serif; margin: 0 0 var(--space-2); color: rgba(28, 27, 27, 0.79); }
.panel-desc { margin: 0 0 var(--space-3); opacity: 0.85; }
.panel-detail { font-size: 0.92rem; opacity: 0.75; }
.panel .timeline-date {
    color: var(--accent-2);
    opacity: 0.8;
    font-size: 1.05em;
}
/* Institution panels (Apple, Research) are the section header for
   everything under them — same border/radius family as .feature-card
   below it, just scaled up (heavier tint, a real border instead of a
   hairline, bigger padding, a bigger/bolder title) rather than a
   different-colored block that reads as arbitrary. Reads as "the big
   card that heads this group," not a differently-styled one. Scoped to
   project-group specifically: .panel elsewhere (nova, voidforge) is a
   plain grid-cell panel among peers, not a header, and shouldn't inherit
   this weight. */
/* Glass, like .feature-card above, but a heavier dose of every knob that
   makes it glass — more opaque (68% white vs. the card's 55%), more
   blurred (20px vs. 16px), a thicker/brighter border, a bigger shadow —
   so the header-vs-card weight difference that already exists in
   padding/title/icon size carries into the material itself instead of
   the two ending up visually similar once both are just "white blur." */
.project-group .panel {
  /* Matches the comment above for real now — background/blur/border had
     all drifted from the "68% white / 20px / brighter border" this
     documents: a greenish rgba() mixed at only 54% (barely more opaque
     than the card's own plain-white 55%), 14px blur (LESS than the card's
     16px), and a dim custom teal border at 25%. Measured the actual
     render against the page background before touching this: the old
     panel came out at rgb(197,228,212) vs. a page background of
     rgb(193,227,219) — a ~4/1/7 difference, while the plain card below it
     hit rgb(219,241,234), a ~26/14/16 difference. The "header" was
     reading as LESS present than an ordinary card beneath it, the
     opposite of the intended weight — a green tint this close to a green
     gradient background all but disappears into it, where a whiter one
     (like the card already uses, just more opaque) actually contrasts. */
  background: color-mix(in srgb, white 68%, transparent);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1.5px solid color-mix(in srgb, white 75%, transparent);
  /* var(--radius-lg) (20px), not the base .panel's --radius-md (8px) — the
     same "heavier than a card" weight bump as the background/blur/border
     above, and matches the other genuinely bold/floating element on this
     page (.terminal-card also uses --radius-lg) rather than sharing the
     card family's tighter corner. */
  border-radius: var(--radius-lg);
  /* The comment above has always claimed "bigger padding" as part of the
     header-vs-card weight difference, but this was actually just
     inheriting the base .panel's var(--space-2) (8px) unchanged — with a
     56px icon (vs. the base panel's 40px) that left only 8px of breathing
     room above/below it, reading as cramped rather than like a header.
     Bumped for real now, and asymmetric: more horizontal room than
     vertical, matching how a header bar (wide, not tall) usually reads
     rather than just scaling padding uniformly. */
  padding: var(--space-4) var(--space-5);
  box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
}
/* 56px (dropped alongside the title above, round 3) was sized against the
   old 1.6rem title — now that the title's down to 1.35rem the icon read as
   the heaviest thing in the row instead of sitting alongside the title as
   a peer. 48px keeps it bigger than the base .panel's 40px (still reads
   as "the header's icon") without dominating the smaller title next to it. */
.project-group .panel-icon { width: 48px; height: 48px; }
/* 1.6rem (dropped from here, round 3) tied this exactly with .section-title
   ("Projects") above it — the institution name was competing with, not
   subordinate to, the section title it sits under, while sitting barely
   above a card title (.feature-card-title, 1.15rem). 1.35rem actually
   slots it between the two: Projects (1.6) > Apple (1.35) > Live
   Activities on macOS (1.15), a real three-step hierarchy instead of two
   sizes doing three jobs. */
.project-group .panel-title {
  font-size: 1.35rem;
  font-weight: 700;
  letter-spacing: -0.01em;
}
/* Shared "Learn more" CTA — used by panel.leaf AND timeline-item.leaf
   (same interaction, one component, not two copies). Plain colored text
   by default; the underline grows in and the arrow slides/fades in on
   hover, rather than a static arrow that's always there. Same 150-250ms
   range as the rest of catalog.css's transitions. */
.learn-more-link {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  color: var(--accent);
  font-size: 0.88rem;
  text-decoration: none;
}
.learn-more-link-text { position: relative; }
.learn-more-link-text::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -2px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.2s ease;
}
.learn-more-link:hover .learn-more-link-text::after,
.learn-more-link:focus-visible .learn-more-link-text::after { transform: scaleX(1); }
.learn-more-link-arrow {
  display: inline-block;
  transform: translateX(-6px);
  opacity: 0;
  transition: transform 0.2s ease, opacity 0.2s ease;
}
.learn-more-link:hover .learn-more-link-arrow,
.learn-more-link:focus-visible .learn-more-link-arrow { transform: translateX(0); opacity: 1; }

/* Photos embedded in a mixed section (not the dedicated photo-grid) are
   now real lightbox triggers too — same button-reset photo-grid-item
   needs, since this is also a <button> now. */
.photo-item {
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  text-align: left;
  cursor: pointer;
}
.photo-item img { width: 100%; border-radius: var(--radius-md); display: block; }
.photo-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Full-width, same reasoning as .panel — a video wants to be watched, not
   squeezed into an auto-fit grid cell beside unrelated text. */
/* .video-item-container carries the outer sizing (grid span, max-width)
   that used to sit directly on the figure, and lays out the caption and
   the figure as a column of two SIBLING flex items — not the caption as
   a child inside the figure, which is what used to let the caption's own
   height eat into the figure's aspect-ratio box and squish the video.
   Default (here): align-items stays the flex initial "stretch", so the
   figure stretches to the container's full width (up to max-width) and
   its height is computed FROM that width by its own inline aspect-ratio
   style (set server-side by VideoDimensionProbe — see SiteController) —
   same sizing direction this always used, just one level removed from
   the caption now. Falls back to no aspect-ratio (sized by the poster
   image itself) if a video's dimensions couldn't be read. Left-aligned
   (no auto-centering) so it sits flush with the paragraph text above it. */
.video-item-container {
  display: flex;
  flex-direction: column;
  grid-column: 1 / -1;
  margin: 0;
  max-width: 640px;
}
/* Small label reading as "this is what the video is," not a competing
   heading — same lightweight caption values .text-item already
   establishes elsewhere (0.92rem/opacity 0.75), not something new. */
.video-item-label { 
  margin: 0 0 var(--space-2); 
  font-family: var(--font-display), Georgia, serif;
  font-weight: 700;
  font-size: 1.15rem;
}
/* Just resets the <figure> element's own browser-default margin (1em/
   40px) — the actual outer sizing lives on .video-item-container above;
   real width/height come from the flex column's stretch behavior (here)
   or the feature-card-media override (below), not from anything set
   directly on the figure itself. */
.video-item { margin: 0; }
.video-item-player { width: 100%; height: 100%; border-radius: var(--radius-md); display: block; object-fit: cover; }

/* Click-to-play: a static poster image by default, a play button that
   only appears on hover/focus, crossfaded for a real <video> on click
   (see video-item.js) — not an always-visible player. */
.video-item-poster {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.video-item-poster img { width: 100%; height: 100%; border-radius: var(--radius-md); display: block; object-fit: cover; }
/* Crossfade: both poster and player animate opacity (video-item.js toggles
   these rather than swapping the DOM instantly), so the transition reads
   as one continuous moment instead of a hard cut. */
.video-item-poster,
.video-item-player { transition: opacity 0.3s ease; }
.video-item-play {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transition: opacity 0.2s ease;
}
/* Same bordered-circle language as the photo-viewer's close/prev/next
   controls (translucent fill, 30%-white border, accent border + scale on
   hover) instead of the old one-off tinted-blur disc. 68px circle — a
   little bigger than the 48px nav controls, since this is the single
   call-to-action on the whole item rather than one of three small nav
   buttons — but the triangle fills most of it (34px, ~50% of the
   circle's diameter) rather than sitting in a lot of empty padding. */
.video-item-play::before {
  content: "";
  position: absolute;
  width: 48px;
  height: 48px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--bg) 76%, transparent);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.3);
  transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.video-item-play svg { position: relative; z-index: 1; color: #fff; width: 28px; height: 28px; }
.video-item-poster:hover .video-item-play,
.video-item-poster:focus-visible .video-item-play { opacity: 1; }
.video-item-poster:hover .video-item-play::before,
.video-item-poster:focus-visible .video-item-play::before {
  background: rgba(255, 255, 255, 0.24);
  border-color: var(--accent);
  transform: scale(1.1);
}

/* Autoplay: poster (if any) shown immediately, full-bleed; the <video>
   itself is in the markup from the start (not JS-inserted, unlike the
   click-to-play path above) so it can start loading right away — see
   video-item.leaf / video-item-autoplay.js. No play button — nothing to
   click. Crossfades in over the poster once it can play, using the same
   opacity-transition technique as the click-to-play path, just triggered
   by a load event instead of a click. */
.video-item-autoplay { position: relative; width: 100%; height: 100%; }
.video-item-autoplay-poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: var(--radius-md);
  display: block;
  object-fit: cover;
  transition: opacity 0.3s ease;
}
.video-item-autoplay .video-item-player {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.3s ease;
}

.prose-section .prose { max-width: var(--prose-width); }
.prose-section .prose h1,
.prose-section .prose h2,
.prose-section .prose h3 { font-family: var(--font-display), Georgia, serif; }
.prose-section .prose a { color: var(--accent); }
/* Default styling for a Markdown bullet list in prose — the hand-rolled
   renderer (FrontmatterMarkdown) can't emit a class on the <ul> it
   generates, so this has to target the element itself to have anywhere to
   attach to. No bullets/indent; each item reads as its own short entry via
   a colored left rule instead — extracted from gamena's original
   site-specific .tone-list into a real shared default (theme's own
   --accent, not a hardcoded color) since any prose list wants the same
   basic treatment, not just gamena's seven tones. */
.prose-section .prose ul {
  margin: 0 0 0.9rem;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.prose-section .prose ul li {
  padding-left: var(--space-3);
  border-left: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);
}

/* ---- mobile ---- */
@media (max-width: 560px) {
  .container { padding: 0 var(--space-4); }
  .nav-inner { gap: var(--space-3); }
  .brand { font-size: 1rem; }
  .content-section { padding: var(--space-6) 0; }
  .content-container { grid-template-columns: 1fr; }
}

/* ---- footer ---- */
.site-footer {
  border-top: 1px solid color-mix(in srgb, var(--ink) 15%, transparent);
  margin-top: var(--space-8);
}
.footer-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  text-align: center;
  padding: var(--space-5) 0;
  font-size: 0.85rem;
  opacity: 0.7;
}
.engine-credit { font-size: 0.78rem; opacity: 0.75; }
