/* font-display: optional (not swap) — a swap-in after paint is exactly the
   layout "nudge"/"glitch" users noticed, especially on About's centered
   layout where a text reflow visibly shifts the whole block. optional
   accepts the font only if it's ready almost immediately (already cached,
   most navigations after the first) and otherwise commits to the fallback
   for that paint instead of swapping in late. Google Fonts honors the
   display param we pass in the URL below, but Fontshare's API ignores it
   and always hands back font-display:swap regardless — General Sans (the
   body/nav/caption typeface used almost everywhere) is self-hosted here
   instead specifically so we can force optional on it too. */
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=optional');

@font-face {
  font-family: 'General Sans';
  src: url('/fonts/general-sans-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: optional;
}

@font-face {
  font-family: 'General Sans';
  src: url('/fonts/general-sans-500.woff2') format('woff2');
  font-weight: 500;
  font-style: normal;
  font-display: optional;
}

@font-face {
  font-family: 'General Sans';
  src: url('/fonts/general-sans-600.woff2') format('woff2');
  font-weight: 600;
  font-style: normal;
  font-display: optional;
}

/* Native browser crossfade between page navigations (progressive enhancement —
   unsupported browsers just get a normal hard cut, no error). The @view-transition
   opt-in itself is ALSO inlined in a tiny <style> at the very top of each HTML
   file's <head> (see there for why) — the browser has to know about the opt-in
   before it starts fetching the next document, and depending on it loading from
   this external, render-blocking-but-still-a-network-request file was a race
   that could lose on a real connection (fine on an instant local server, flaky
   over the real internet) and silently skip the transition for that navigation.
   Redeclaring it here too is harmless. The animation styling below doesn't have
   that problem — it's only needed once the transition pseudo-elements actually
   render, well after this file has loaded — so it stays here as the one source
   of truth for how the transition actually looks.
   Uses the same --nav-duration/--nav-ease/--nav-distance recipe as every
   other entrance/exit on the site (project panels, the mobile browse list) —
   one consistent fade + small upward drift, triggered immediately rather
   than staggered behind a choreographed delay, so navigating feels the same
   everywhere instead of each transition having its own timing. */
@view-transition {
  navigation: auto;
}

@keyframes page-fade-out {
  from { opacity: 1; transform: translateY(0); }
  to { opacity: 0; transform: translateY(calc(-1 * var(--nav-distance))); }
}

@keyframes page-fade-in {
  from { opacity: 0; transform: translateY(var(--nav-distance)); }
  to { opacity: 1; transform: translateY(0); }
}

::view-transition-old(root) {
  animation: page-fade-out var(--nav-duration) var(--nav-ease) forwards;
}

::view-transition-new(root) {
  animation: page-fade-in var(--nav-duration) var(--nav-ease) forwards;
}

/* Home's entrance on a true cold landing ONLY (first visit, hard reload,
   typed URL, bookmark, external link) — never when navigating back to home
   from Work/About, which keeps the ordinary page-transition crossfade. See
   the inline script in index.html's <head> for how "cold-load" gets added.
   The header/footer/kicker settle in together first with a plain, quick
   fade, then each thumbnail "develops" into view one at a time — starting
   warm and slightly soft/bright, resolving to true color and full sharpness,
   like a print emerging in a darkroom bath — rather than the whole page
   arriving as one block. Explicit per-item nth-child delays (not a formula)
   since there are always exactly 7 featured thumbnails. */
@keyframes chrome-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

html.cold-load body.home-page .site-header,
html.cold-load body.home-page .site-footer,
html.cold-load body.home-page .home-kicker {
  animation: chrome-fade-in var(--nav-duration) var(--nav-ease) both;
}

/* Two variants, not one — each ends exactly at its item's real resting look
   (full brightness for the active hero, the slight brightness dip for the
   rest — see the plain .thumb rule) instead of every thumbnail ending bright
   and then visibly correcting itself down to idle afterward. Ending where it
   should actually rest is what lets the inline script's later removal of
   "cold-load" (once the animation finishes, handing control back to the
   normal hover/active rules) be truly invisible.
   Every thumbnail fades in together now, not staggered one-at-a-time —
   the previous per-item nth-child delays (7 separately hand-tuned timings)
   were exactly the kind of thing that could drift out of sync with the
   removal timeout in index.html and read as a glitch/shift on some loads.
   One animation, one duration, applied to the whole group at once, is what
   "seamless" actually means here. */
@keyframes thumb-develop-in-active {
  from {
    opacity: 0;
    transform: translateY(var(--nav-distance));
  }
  to {
    opacity: 1;
    transform: translateY(-10px);
    filter: brightness(1);
  }
}

@keyframes thumb-develop-in-idle {
  from {
    opacity: 0;
    transform: translateY(var(--nav-distance));
  }
  to {
    opacity: 1;
    transform: translateY(0);
    filter: brightness(0.82);
  }
}

/* Targets .thumb, not .thumb-item — .thumb-item is display:contents on
   desktop (an invisible layout wrapper, see its rule above), so it has no
   box of its own to animate. .thumb-item:nth-child(1) is still how the
   active hero thumbnail gets picked out (see renderThumbs() in main.js),
   since that structure is unaffected by display:contents (a rendering
   hint, not a DOM change) — only the actual animated element (its .thumb
   descendant) changes. It alone gets the "active" variant, matching
   .thumb.is-active's own translateY(-10px).
   :not(.is-detail) on .gallery-stage is a structural guard, not just belt-
   and-suspenders: openProject() also clears "cold-load" the instant a
   project opens (see its own comment), but #filmstrip's thumbnails get
   completely rebuilt as new elements every time renderThumbs() runs, and
   those fresh elements would match this selector again regardless of JS
   timing if the ancestor is-detail class weren't also excluding them here.
   That mismatch — this entrance animation firing on the docked switcher's
   thumbnails instead of just the initial browse grid — is what actually
   caused the intermittent "thumbnails glitch/sit wrong" reports, and only
   on a true cold load, never on internal navigation (which never sets
   cold-load at all). */
html.cold-load body.home-page .gallery-stage:not(.is-detail) #filmstrip .thumb-item .thumb {
  animation-name: thumb-develop-in-idle;
  animation-duration: var(--nav-duration);
  animation-timing-function: var(--nav-ease);
  animation-fill-mode: both;
}

html.cold-load body.home-page .gallery-stage:not(.is-detail) #filmstrip .thumb-item:nth-child(1) .thumb {
  animation-name: thumb-develop-in-active;
}

/* Pseudo-elements aren't matched by the `*` reduced-motion rule further down,
   so view transitions need their own opt-out. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.001ms !important;
  }
}

:root {
  --black: #f7f5f0;
  --paper: #141210;
  --ink: #201d19;
  --body: #4a4640;
  --gray: #85807a;
  --gray-dim: #b6b2a9;
  --hair: rgba(20, 18, 15, 0.13);
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* The one shared recipe for every "content appears/disappears" moment on
     the site — page navigation, opening/closing a project, the browse list
     reappearing on mobile. A single consistent fade + small upward drift
     everywhere, rather than each spot having its own duration/easing/
     distance, is also what makes "did this load correctly" easy to reason
     about: one animation to get right instead of several. */
  --nav-duration: 0.3s;
  --nav-ease: ease-in-out;
  --nav-distance: 12px;
  --font-body: 'General Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  --font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif;

  --space-1: 8px;
  --space-2: 16px;
  --space-3: 24px;
  --space-4: 40px;
  --space-5: 64px;
  --space-6: 96px;
  --space-7: 144px;

  --meta: 12px;
  --meta-tracked: 0.03em;
}

* { box-sizing: border-box; }

html {
  background: var(--black);
}

body {
  margin: 0;
  background: var(--black);
  color: var(--ink);
  font-family: var(--font-body);
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

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

button {
  font-family: inherit;
  background: none;
  border: none;
  color: inherit;
  padding: 0;
  cursor: pointer;
}

img, video {
  display: block;
  max-width: 100%;
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
}

.kicker {
  font-size: var(--meta);
  letter-spacing: var(--meta-tracked);
  color: var(--gray);
  text-transform: uppercase;
}

/* ---------- Layout shell ---------- */

.shell {
  max-width: 1680px;
  margin: 0 auto;
  padding: 0 var(--space-4);
}

@media (max-width: 700px) {
  .shell { padding: 0 var(--space-2); }
}

/* ---------- Header ---------- */

.site-header {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-3) var(--space-4);
}

@media (max-width: 700px) {
  /* Mobile pages scroll under the fixed header, so it needs a solid ground —
     otherwise scrolled content shows straight through the nav text. */
  .site-header {
    padding: var(--space-2);
    background: var(--black);
    border-bottom: 1px solid var(--hair);
  }
}

.brand {
  font-size: 13px;
  letter-spacing: 0.05em;
  font-weight: 500;
}

.primary-nav {
  display: flex;
  gap: var(--space-4);
  list-style: none;
  margin: 0;
  padding: 0;
}

.primary-nav a {
  font-size: 12px;
  letter-spacing: var(--meta-tracked);
  padding-bottom: 2px;
  border-bottom: 1px solid transparent;
  transition: border-color 0.3s var(--ease);
}

.primary-nav a:hover,
.primary-nav a[aria-current="page"] {
  border-bottom-color: currentColor;
}

/* ---------- Frame (placeholder cinematic imagery) ---------- */

.frame {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #111;
}

.frame::before {
  content: "";
  position: absolute;
  inset: -10%;
  background-image: var(--frame-grad);
  filter: saturate(0.85);
  transform: scale(1);
  transition: transform 1.4s var(--ease);
  animation: breathe 22s ease-in-out infinite alternate;
}

@keyframes breathe {
  from { transform: scale(1) translate3d(0, 0, 0); }
  to { transform: scale(1.025) translate3d(-0.6%, -0.6%, 0); }
}

.frame::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.05'/></svg>");
  mix-blend-mode: overlay;
  pointer-events: none;
}

/* Real photography/video stills — same frame treatment, real <img> instead of a gradient */
.frame.has-image::before {
  display: none;
}

.frame-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.5s var(--ease);
}

/* Tonal placeholder gradients standing in for real film stills */
.tone-1 { --frame-grad: linear-gradient(160deg, #1c1a22, #3a2e2e 55%, #16130f); }
.tone-2 { --frame-grad: linear-gradient(160deg, #12181c, #263a3a 55%, #0c0f10); }
.tone-3 { --frame-grad: linear-gradient(160deg, #201418, #4a2530 55%, #120c0e); }
.tone-4 { --frame-grad: linear-gradient(160deg, #1a1712, #4a3a1c 55%, #100e0a); }
.tone-5 { --frame-grad: linear-gradient(160deg, #14161c, #2a2e42 55%, #0c0d10); }
.tone-6 { --frame-grad: linear-gradient(160deg, #1c1414, #402424 55%, #100c0c); }
.tone-7 { --frame-grad: linear-gradient(160deg, #10181a, #1f3a3c 55%, #0a0e0f); }
.tone-8 { --frame-grad: linear-gradient(160deg, #1a1420, #382a4a 55%, #0e0c12); }
.tone-9 { --frame-grad: linear-gradient(160deg, #181614, #3a3226 55%, #0e0c0a); }
.tone-10 { --frame-grad: linear-gradient(160deg, #1c1216, #442030 55%, #100a0d); }
.tone-11 { --frame-grad: linear-gradient(160deg, #141810, #2c3a1e 55%, #0c0f0a); }
.tone-12 { --frame-grad: linear-gradient(160deg, #161414, #322c26 55%, #0d0c0a); }
.tone-13 { --frame-grad: linear-gradient(160deg, #10141c, #1e2a44 55%, #0a0c10); }
.tone-14 { --frame-grad: linear-gradient(160deg, #181410, #3a2a18 55%, #0e0b08); }
.tone-15 { --frame-grad: linear-gradient(160deg, #14181a, #24383a 55%, #0b0e0f); }
.tone-16 { --frame-grad: linear-gradient(160deg, #16140f, #362e1c 55%, #0d0b08); }

/* ---------- Play glyph ---------- */

.play-glyph {
  position: absolute;
  top: 50%; left: 50%;
  width: 52px; height: 52px;
  transform: translate(-50%, -50%) scale(0.9);
  border: 1px solid rgba(243, 239, 232, 0.55);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transition: opacity 0.35s var(--ease), transform 0.35s var(--ease);
  z-index: 2;
}

.play-glyph::before {
  content: "";
  width: 0; height: 0;
  margin-left: 3px;
  border-top: 6px solid transparent;
  border-bottom: 6px solid transparent;
  border-left: 9px solid var(--paper);
}

.play-glyph--inline {
  position: static;
  width: 22px;
  height: 22px;
  opacity: 1;
  transform: none;
  flex: 0 0 auto;
}

.play-glyph--inline::before {
  border-top-width: 4px;
  border-bottom-width: 4px;
  border-left-width: 6px;
}

/* ---------- Single-viewport gallery pages (Home + Work) ---------- */

body.gallery-page {
  min-height: 100vh;
  /* dvh tracks the address bar collapsing/expanding on mobile browsers —
     plain vh is pinned to the largest possible viewport, so on first paint
     (bar still visible) the page can be taller than what's actually on
     screen, forcing a moment of scroll until the bar collapses. Kept as a
     second declaration so browsers without dvh support just keep the vh
     value above. */
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

body.gallery-page .filmstrip-section {
  flex: 1 1 auto;
}

body.gallery-page .site-footer {
  flex: 0 0 auto;
}

.filmstrip-section {
  display: flex;
  align-items: stretch;
  justify-content: center;
  padding: calc(var(--space-5) + 24px) var(--space-4) var(--space-4);
}

.gallery-stage {
  width: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-5);
}

/* A bit tighter than browse mode's gap — with flex:1 removed from
   .stage-content above, the detail panel and the docked switcher are
   already centered together as one snug group; the full --space-5 gap
   between them still read as more sparse than intentional once nothing was
   artificially padding the space above it anymore. */
.gallery-stage.is-detail {
  gap: var(--space-4);
}

/* No flex:1 here — that used to make this grow to fill all the leftover
   space in .gallery-stage, then center its own content within that
   oversized box. Since the switcher below it doesn't grow the same way,
   that produced a big gap sitting between the detail panel and the switcher
   instead of the two being centered together as one snug group (which is
   what .gallery-stage's own justify-content:center already does correctly
   once this isn't fighting it for space). display:flex + centering here
   still matters for horizontally centering the detail panel itself. */
.gallery-stage.is-detail .stage-content {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Detail-panel height varies per project (title wrap, credit count), and
     .gallery-stage centers the whole detail-panel + switcher group as one
     block — so without a floor here, the switcher (and the "Selected Work"
     title above the whole stage) visibly shifts up/down depending on which
     project is open. 290px only covered every title actually staying on one
     line — but "Her Name is Carol" (the longest featured title) wraps to two
     lines whenever it's rendered a little wider than usual (e.g. the
     Google Font hasn't swapped in yet and Georgia's fallback metrics are
     wider), which pushes that one panel to ~329px and re-introduces the same
     visible jump. Floored at one full extra title line (~58px, the display
     font's max clamped size) above the two-line height actually measured, so
     a two-line title never exceeds this even under fallback-font metrics. */
  min-height: 350px;
}

/* ---------- Filmstrip (browse + project switcher) ---------- */

.stage-filmstrip {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
}

.home-kicker {
  font-family: var(--font-display);
  font-size: 20px;
  letter-spacing: 0;
  text-transform: none;
  color: var(--ink);
}

/* A grid that wraps and resizes, not a horizontal-scrolling row — shrinking
   the window used to just scroll thumbnails off past the edge instead of
   keeping them all visible. auto-fill + minmax lets each row settle on
   however many columns actually fit at the current width, resizing them to
   fill it, and drop to a new line once even the minimum no longer fits.
   Work's rows share this class and each resolve their own column count
   independently, based on their own item count. The compact docked switcher
   (once a project is open) is the one exception that keeps the old
   horizontal-scroll behavior — see .gallery-stage.is-detail .filmstrip. */
/* 168px = 140px +20% — matches the same bump on Home's #filmstrip browse
   thumbnails just below, so Work's category-row grid and Home's flex-wrap
   grid grow by the same proportion on desktop. Mobile overrides this away
   entirely (see .gallery-row-strip's own mobile rules), so this only
   affects desktop/tablet browse widths. */
.filmstrip {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
  gap: var(--space-2);
  max-width: 100%;
  padding: var(--space-2) var(--space-1) var(--space-3);
}

.gallery-stage.is-detail .filmstrip {
  display: flex;
  overflow-x: auto;
  scrollbar-width: none;
}

/* Desktop only — #filmstrip's ID specificity would otherwise also beat
   mobile's own, completely different #filmstrip layout (the "hero item +
   2-column grid" rules), which is a set of plain class selectors further
   down and so weaker than an ID regardless of media query. Wrapping this in
   min-width keeps it from ever being present to win that fight on mobile.

   Overrides the grid with flex-wrap instead. Grid's auto-fill leaves an
   incomplete last row's empty cells sitting to its right rather than
   centering the row's actual items — invisible on Work, where every row
   already sits flush-left under its own left-aligned category label, but
   visibly off-center against Home's centered "Selected Work" label and hero
   image above it. Flex-wrap's per-row justify-content doesn't have that
   empty-cell concept, so a ragged last row just centers like any other row.
   max-width keeps a lone leftover item from stretching to fill the entire
   row on its own. */
@media (min-width: 701px) {
  /* nowrap — Home's browse strip stays a single row on desktop no matter
     how narrow the window gets (down to the 700px mobile switch-over,
     where it becomes the hero+2-col grid instead). Wrapping to a second
     row was tried earlier to stop items scrolling out of view, but once
     thumbnails could also wrap, a short last row's items grew larger than
     a full row above it — fixable, but the simpler fix is to just never
     wrap: divide the available width evenly among all of them instead (see
     .thumb below), so they shrink together and stay visible rather than
     needing two rows or a scrollbar. */
  #filmstrip {
    display: flex;
    flex-wrap: nowrap;
    justify-content: center;
    align-items: flex-start;
  }

  /* .filmstrip-track (not #filmstrip itself, now that arrow buttons flank
     it — see the .filmstrip-track rules further down) is what needs to span
     full width here: without it, .stage-filmstrip's own align-items:center
     sizes the track to its shrink-to-fit content instead of stretching it,
     leaving no room for more than one item per wrapped row regardless of
     viewport size. #filmstrip then fills the track via flex:1 1 auto. */
  .filmstrip-track {
    width: 100%;
  }

  /* flex: 1 1 0 splits the row's width evenly across every thumbnail
     (flex-basis:0 means that division ignores each item's own content size)
     and lets them shrink together as the window narrows — nothing wraps,
     nothing scrolls, nothing disappears off-edge, they just get smaller as
     a group. max-width is the +20% cap (was 209px) so they don't grow
     absurdly large on an ultra-wide monitor with only a few items to fill it. */
  #filmstrip .thumb {
    width: auto;
    height: auto;
    flex: 1 1 0;
    max-width: 251px;
  }

  /* #filmstrip's ID specificity would otherwise also beat the docked-mode
     overrides above (both are two classes, weaker than an ID) — restated
     here with the ancestor class added so the compact horizontal-scroll
     switcher still wins once a project is open, same as it does for Work. */
  /* align-items:center (not the browse rule's flex-start) — Work's docked
     strip mixes in .filmstrip-label dividers (see main.js/CSS) whose
     rotated text needs more vertical room than a thumbnail's own height,
     which stretches the row taller than any single thumb. flex-start left
     the (shorter) thumbnails pinned to the top of that taller row while the
     labels' centered text sat in the middle of the full row height —
     visually low relative to the images. Centering everything on the row's
     cross-axis puts thumbnails and label text at the same vertical center
     regardless of which one is dictating the row's height. */
  .gallery-stage.is-detail #filmstrip {
    flex-wrap: nowrap;
    justify-content: flex-start;
    align-items: center;
  }

  /* width:auto (not the browse rule's 100%) is what lets the track shrink to
     its own content (arrows + strip) and get centered by .stage-filmstrip's
     align-items:center, instead of stretching full-width with everything
     packed flush left in the leftover space. */
  .gallery-stage.is-detail .filmstrip-track {
    width: auto;
    max-width: 100%;
  }

  .gallery-stage.is-detail #filmstrip .thumb {
    flex: 0 0 auto;
    width: 143px;
    max-width: none;
  }
}

.filmstrip::-webkit-scrollbar {
  display: none;
}

/* Wraps the docked, horizontally-scrolling switcher strip (Home's #filmstrip
   and Work's flat docked strip — see renderDockedStrip() in main.js) with a
   pair of arrow buttons. No width set here deliberately — it shrinks to fit
   its content (arrows + the strip's own compact width) so the whole group
   still gets centered by .stage-filmstrip/.browse-rows's align-items:center,
   same as before the arrows existed. Browse-mode rows (the fluid, wrapping
   grid) never use this wrapper — they never overflow, so there's nothing to
   scroll. */
.filmstrip-track {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  max-width: 100%;
}

.filmstrip-track .filmstrip {
  /* Fills whatever width .filmstrip-track allocates to it (full width in
     browse mode with the arrows hidden, or the remainder after the two
     arrow buttons once docked) instead of using its own intrinsic content
     width, which for a nowrap flex row would be the sum of every thumbnail
     — i.e. never actually constrained enough to need the scrolling these
     arrows exist for. */
  flex: 1 1 auto;
  min-width: 0;
}

/* Replaces the old edge-fade cue — a row that overflowed used to fade at
   the edge to hint there was more to scroll, with the scrollbar itself
   hidden and no other affordance. Explicit prev/next buttons are a clearer,
   more discoverable way to say the same thing, and give the user a way to
   act on it without a trackpad. Hidden entirely (via .filmstrip-track's own
   .has-overflow toggle from updateFilmstripArrows() in main.js) when the
   strip already fits — nothing to scroll to, nothing to show. */
.filmstrip-arrow {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: 1px solid var(--hair);
  background: none;
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: opacity 0.3s var(--ease), border-color 0.3s var(--ease);
}

.filmstrip-arrow:hover {
  border-color: currentColor;
}

.filmstrip-arrow:disabled {
  opacity: 0.25;
  cursor: default;
}

.filmstrip-track:not(.has-overflow) .filmstrip-arrow {
  display: none;
}

/* Work's docked strip (see renderDockedStrip() in main.js) flattens three
   categories into one continuous scrollable row — these mark where each
   category actually starts as you scroll through it, since the flat strip
   on its own gives no hint that "Short Films" ends and "Music Videos"
   begins. Vertical type keeps the label from eating into the strip's
   horizontal space the way a normal caption would; align-self:stretch
   matches whatever height the row's thumbnails are at that breakpoint, and
   the hairline border reads as a divider between groups without needing a
   separate element for it. The first label (before any thumbnail) has
   nothing to its left to divide from, hence :first-child dropping it. */
.filmstrip-label {
  flex: 0 0 auto;
  align-self: stretch;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 var(--space-2);
  border-left: 1px solid var(--hair);
}

.filmstrip-label:first-child {
  border-left: none;
  padding-left: 0;
}

.filmstrip-label span {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-size: 10px;
  letter-spacing: var(--meta-tracked);
  text-transform: uppercase;
  color: var(--gray);
  white-space: nowrap;
}

/* Full color and opacity always — dropping both opacity and saturation for
   the idle state read fine against gradient placeholders, but against real
   film stills it just looked dull/washed out. A gentle brightness dip is
   enough of an "off" cue to make hover/active read as a distinct lift
   without draining the color out of the actual footage. */
.thumb {
  flex: 0 0 auto;
  width: 100%;
  aspect-ratio: 16 / 9;
  cursor: pointer;
  filter: brightness(0.82);
  transition: transform 0.5s var(--ease), filter 0.5s var(--ease);
}

.thumb:hover,
.thumb:focus-visible,
.thumb.is-active,
.thumb.is-current {
  filter: brightness(1);
  transform: translateY(-10px);
}

.thumb.is-current {
  cursor: default;
}

/* Desktop: the wrapper is invisible in the flex layout (the shared hover
   caption below the strip does the job) — mobile turns it into a real block
   with its own caption. See the mobile section for that half. */
.thumb-item {
  display: contents;
}

.thumb-caption {
  display: none;
}

/* Only shown in detail mode on mobile — see mobile section below */
.mobile-back {
  display: none;
}

/* Smaller, tighter filmstrip once a project is open — it's a switcher now,
   not the main event. Fixed pixel width here (not the grid's fluid sizing)
   is deliberate — this is the compact docked strip, which stays a plain
   horizontal-scrolling row (see the display:flex override further up). */
.gallery-stage.is-detail .filmstrip {
  gap: var(--space-1);
}

.gallery-stage.is-detail .thumb {
  /* 143px = 119px +20% — same bump as the browse-grid thumbnails (see
     .filmstrip and #filmstrip .thumb above) applied to the docked switcher
     too, so every thumbnail on the site grew together. */
  width: 143px;
}

.thumb:focus-visible {
  outline: 1px solid var(--hair);
  outline-offset: 4px;
}

.filmstrip-caption {
  text-align: center;
  min-height: 32px;
}

/* Sized up 20% alongside the browse thumbnails themselves (22px/12px ->
   26.4px/14.4px) — this caption only ever shows on desktop (hidden on
   mobile, see below), so it grows in proportion with the bigger tiles
   instead of looking small next to them. project-meta gets its own literal
   value here rather than the shared --meta variable, since that variable
   also drives unrelated text (kickers, footer) that isn't part of this
   scale-up. */
.filmstrip-caption .project-title {
  font-family: var(--font-display);
  font-size: 26.4px;
  font-weight: 400;
  margin-right: var(--space-2);
}

.filmstrip-caption .project-meta {
  font-size: 14.4px;
  letter-spacing: var(--meta-tracked);
  color: var(--gray);
  text-transform: uppercase;
}

/* The browse grid resizes fluidly on its own now (see .filmstrip above), so
   this only still needs to shrink the compact docked switcher — the one
   piece that kept the old fixed-width horizontal-scroll behavior — on a
   narrower desktop window. */
@media (max-width: 1300px) {
  .gallery-stage.is-detail .thumb {
    width: 100px;
  }

  /* Same specificity issue as above — #filmstrip's ID would otherwise beat
     this too. */
  .gallery-stage.is-detail #filmstrip .thumb {
    width: 100px;
  }
}

@media (max-width: 700px) {
  /* Mobile drops the single-viewport "fit everything, no scroll" layout —
     content flows naturally and the page scrolls past 100vh when it's tall
     enough to need it. Deliberately NOT resetting body.gallery-page's base
     flex-column/min-height:100vh here (like an earlier version of this rule
     did) — that flex setup is what pins the footer to the bottom of the
     screen on short content (e.g. Work's accordion collapsed); dropping it
     left the footer stranded right after the content instead of anchored to
     the viewport edge. Flex-grow on .filmstrip-section still lets content
     taller than the viewport overflow and scroll normally. */
  .filmstrip-section {
    align-items: stretch;
    padding: calc(var(--space-5) + 24px) var(--space-2) var(--space-4);
  }

  /* Work has no equivalent of Home's "Selected Work" label anchoring the top,
     so the same top padding reads as more dead space there — tightened up
     (~12px) to sit closer to the header, shifting both the collapsed
     category list and the detail view's "← All Work" up. */
  body.work-page .filmstrip-section {
    padding-top: calc(var(--space-5) + 12px);
  }

  .gallery-stage {
    justify-content: flex-start;
    gap: var(--space-4);
  }

  .gallery-stage.is-detail .stage-content {
    flex: none;
    display: block;
    /* Mobile stacks in normal flow (justify-content:flex-start above), so
       there's no shared-centered-group to destabilize — the min-height fix
       is desktop-only; here it'd just leave dead space under short panels. */
    min-height: 0;
  }

  /* Makes the wrapper invisible to layout so .filmstrip lays out exactly as
     if it weren't there — the docked switcher (the only place arrows show)
     is hidden entirely on mobile anyway (see .gallery-stage.is-detail
     .stage-filmstrip / .gallery-rows-wrap below), so the arrow buttons
     inside would otherwise just be unused flex-row children fighting the
     vertical-list treatment below for space. */
  .filmstrip-track {
    display: contents;
  }

  /* Browse filmstrip becomes a simple vertical list, full-width, each item
     with its own caption — no dimming, no hover-only affordances on touch. */
  .filmstrip {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    overflow: visible;
    width: 100%;
    padding: 0;
  }

  /* Fades the browse list (this or #browseRows, Work's equivalent) back in
     when returning from a project's detail view — see fadeInBrowse() in
     main.js. Desktop already gets a reveal there from flip()'s slide, which
     is a no-op on mobile (no docked switcher to move), so without this the
     list used to just snap back with no transition at all. Same
     --nav-duration/--nav-ease/--nav-distance recipe as everything else (see
     the @view-transition comment near the top of this file). Not applied
     on the initial page load — Home's own cold-load fade already covers
     that case. */
  .browse-enter {
    opacity: 0;
    transform: translateY(var(--nav-distance));
  }

  .browse-enter-active {
    opacity: 1;
    transform: translateY(0);
    transition: opacity var(--nav-duration) var(--nav-ease), transform var(--nav-duration) var(--nav-ease);
  }

  .thumb {
    width: 100%;
    /* Same fix as the lightbox: .frame's inherited height:100% can resolve to
       a definite value inside this nested flex chain, which makes the browser
       ignore aspect-ratio entirely (both dimensions become explicit). Forcing
       auto here lets aspect-ratio actually govern the height again. */
    height: auto;
    opacity: 1;
    filter: none;
  }

  .thumb.is-active,
  .thumb.is-current { transform: none; }

  /* Mobile's actual resting state for every thumbnail — active/hero
     included — is transform:none and filter:none (see .thumb and
     .thumb.is-active above); there's no "raised" hover-style look here,
     touch doesn't have hover. The desktop cold-load keyframes (defined
     earlier in this file) still end at translateY(-10px)/brightness(0.82),
     which is correct for desktop but NOT what mobile settles into — so the
     instant cold-load ended, the hero thumbnail visibly dropped by 10px
     (and idle ones brightened) as the real mobile rules took over. Same
     keyframe names, redefined here so the browser uses this version
     whenever this media query matches, ending exactly where mobile
     actually rests. */
  @keyframes thumb-develop-in-active {
    from { opacity: 0; transform: translateY(var(--nav-distance)); }
    to { opacity: 1; transform: none; filter: none; }
  }

  @keyframes thumb-develop-in-idle {
    from { opacity: 0; transform: translateY(var(--nav-distance)); }
    to { opacity: 1; transform: none; filter: none; }
  }

  .thumb-item {
    display: block;
    width: 100%;
    flex-shrink: 0;
  }

  /* Guard the whole nested flex chain (rows-wrap > browse-rows > gallery-row >
     gallery-row-strip) against being compressed below content height — this is
     what caused captions to overlap the next thumbnail on the Work page. */
  .stage-filmstrip,
  .gallery-rows-wrap,
  .browse-rows,
  .gallery-row,
  .filmstrip,
  .gallery-row-strip {
    flex-shrink: 0;
  }

  .thumb-caption {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--space-2);
    padding-top: var(--space-1);
  }

  .thumb-caption .project-title {
    font-family: var(--font-display);
    font-size: 17px;
  }

  .thumb-caption .project-meta {
    font-size: 10px;
    letter-spacing: var(--meta-tracked);
    color: var(--gray);
    text-transform: uppercase;
    white-space: nowrap;
  }

  /* The shared hover-driven caption doesn't apply once every item has its own */
  .filmstrip-caption {
    display: none;
  }

  /* Serif display face, centered, one size down from Work's 26px category
     titles — a quieter echo of that same editorial treatment. */
  .home-kicker {
    align-self: center;
    font-family: var(--font-display);
    font-size: 16px;
    letter-spacing: 0;
    text-transform: none;
    color: var(--ink);
  }

  /* Home only (":not(.gallery-row-strip)" excludes Work's category rows, which
     share the .filmstrip class): lead project full-width, the rest as a 2-up
     grid, and only 7 items total shown. */
  .filmstrip:not(.gallery-row-strip) {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-3);
  }

  .filmstrip:not(.gallery-row-strip) .thumb-item:first-child {
    grid-column: 1 / -1;
  }

  .filmstrip:not(.gallery-row-strip) .thumb-item:not(:first-child) .thumb-caption {
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
  }

  .filmstrip:not(.gallery-row-strip) .thumb-item:not(:first-child) .project-title {
    font-size: 13px;
  }

  .filmstrip:not(.gallery-row-strip) .thumb-item:not(:first-child) .project-meta {
    font-size: 9px;
  }

  /* The switcher (filmstrip or category rows) disappears in detail mode on
     mobile rather than shrinking and docking — a "← All Work" link is enough. */
  .gallery-stage.is-detail .stage-filmstrip,
  .gallery-stage.is-detail .gallery-rows-wrap {
    display: none;
  }

  /* Regular case, not uppercase — "Selected Work" (Home's detail view) and
     "All Work" (Work's) read as actual link labels naming their
     destination, not a tag-style micro-label. */
  .gallery-stage.is-detail .mobile-back {
    display: block;
    font-size: 12px;
    letter-spacing: var(--meta-tracked);
    color: var(--gray);
    margin-bottom: clamp(6px, 1.4dvh, 16px);
  }

  /* Project detail stays a single non-scrolling screen too, same as About —
     sized off dvh so it holds on short phones as well as tall ones. html
     (not body) gets the hard overflow:hidden since that's what actually
     controls document scroll; the clamps below are what make the content
     genuinely fit rather than just clipping it. */
  html:has(.gallery-stage.is-detail) {
    height: 100dvh;
    overflow: hidden;
  }

  /* Browse mode needs justify-content:flex-start (its content can exceed the
     viewport and scroll) but that same rule left detail mode top-aligned
     with a dead gap at the bottom once its content was sized to actually
     fit — center it back for detail specifically. */
  .gallery-stage.is-detail {
    justify-content: center;
  }

  /* Prefixed with .gallery-stage.is-detail for extra specificity — the
     un-prefixed base rules for these same selectors are declared further
     down in the stylesheet (outside any media query), so they'd otherwise
     win the cascade over this media query regardless of viewport width
     (the same issue hit on About's copy earlier). */
  .gallery-stage.is-detail .detail-panel {
    gap: clamp(6px, 1.6dvh, 16px);
  }

  .gallery-stage.is-detail .detail-copy {
    gap: clamp(2px, 0.5dvh, 8px);
  }

  .gallery-stage.is-detail .detail-title {
    font-size: clamp(20px, 5dvh, 34px);
    line-height: 1.05;
  }

  /* These are nowrap (see the base rule) so a long line stays intact
     instead of breaking mid-word — but dvh alone only tracks viewport
     height, so a tall-but-narrow phone screen (plenty of dvh, not much
     width) still let long lines like "Co-Directed & Animated by Brannen
     Haderle for Stevie Wolf" run past the edge of the screen. Taking
     whichever of dvh or vw is more restrictive keeps it shrinking on
     narrow screens regardless of how tall they are. */
  .gallery-stage.is-detail .detail-credit-line {
    font-size: clamp(9px, min(1.5dvh, 3vw), 13px);
  }

  .gallery-stage.is-detail .detail-credits {
    margin-top: clamp(4px, 1dvh, 16px);
  }

  .gallery-stage.is-detail .detail-credits .kicker {
    font-size: 10px;
    margin-bottom: clamp(2px, 0.5dvh, 8px);
  }

  .gallery-stage.is-detail .credit-lines {
    font-size: clamp(9.5px, 1.4dvh, 13px);
    line-height: 1.4;
  }

  .gallery-stage.is-detail .watch-link {
    margin-top: clamp(6px, 1.6dvh, 24px);
    font-size: clamp(9.5px, 1.4dvh, 12px);
  }

  .gallery-stage.is-detail .stills-grid {
    margin-top: clamp(6px, 1.6dvh, 16px);
    gap: clamp(4px, 0.8dvh, 8px);
    max-width: min(380px, 62dvh);
  }
}

/* ---------- Work page: category-sorted rows — the switcher in both browse and detail modes ---------- */

.gallery-rows-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
}

.browse-rows {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-4);
  width: 100%;
}

/* Docked at the bottom once a project is open, .browse-rows holds a single
   flat #filmstrip (see renderDockedStrip() in main.js) instead of the three
   categorized rows — center it same as Home's docked switcher, instead of
   the browse-mode categorized rows' left alignment. */
.gallery-stage.is-detail .browse-rows {
  align-items: center;
}

.gallery-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-2);
  width: 100%;
}

/* Doubles as an accordion header on mobile — on desktop it's just the label,
   non-interactive-looking, since every row stays expanded there regardless. */
.gallery-row-header {
  display: flex;
  align-items: baseline;
  gap: var(--space-1);
  cursor: default;
}

.gallery-row-count,
.gallery-row-toggle {
  display: none;
}

/* Nullified outside mobile so the strip renders exactly as before — the
   collapse/expand grid trick below is mobile-only. */
.gallery-row-collapse {
  display: contents;
}

.gallery-row-strip {
  padding: var(--space-1) var(--space-1) var(--space-2);
}

@media (max-width: 700px) {
  /* Category rows become simple vertical lists too, same as the homepage strip */
  .gallery-row-strip {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    overflow: visible;
    width: 100%;
    padding: 0;
  }

  /* Deliberately not padding: a 0fr-collapsed grid item can never shrink
     below its own padding (padding is an unconditional floor on a box's
     size, unlike content), so any padding here leaked through as a visible
     sliver even while "collapsed". Margins on the children instead — content
     is what actually gets clipped to zero by the collapse's overflow:hidden. */
  .gallery-row-strip {
    padding: 0;
  }

  .gallery-row-strip .thumb-item:first-child {
    margin-top: var(--space-1);
  }

  /* The cushion before the next category's hairline — without it the last
     thumbnail's caption sat flush against that line. */
  .gallery-row-strip .thumb-item:last-child {
    margin-bottom: var(--space-3);
  }

  .gallery-row-strip .thumb {
    width: 100%;
    opacity: 1;
  }

  /* Rows sit flush against each other — hairlines do the separating, not gap */
  .browse-rows {
    gap: 0;
  }

  /* Each category becomes a tappable, editorial index entry — large serif
     title, a tabular count, a hairline plus/minus in place of a stock chevron. */
  .gallery-row-header {
    width: 100%;
    justify-content: space-between;
    align-items: center;
    cursor: pointer;
    padding: var(--space-2) 0;
    border-top: 1px solid var(--hair);
  }

  .gallery-row-header .kicker {
    font-family: var(--font-display);
    font-size: 26px;
    letter-spacing: 0;
    text-transform: none;
    color: var(--ink);
  }

  .gallery-row-count {
    display: inline-block;
    font-size: 11px;
    letter-spacing: var(--meta-tracked);
    color: var(--gray-dim);
    font-variant-numeric: tabular-nums;
  }

  .gallery-row-label {
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
  }

  .gallery-row-toggle {
    display: block;
    position: relative;
    width: 14px;
    height: 14px;
    flex-shrink: 0;
  }

  .gallery-row-toggle::before,
  .gallery-row-toggle::after {
    content: "";
    position: absolute;
    background: var(--ink);
    transition: transform 0.35s var(--ease), opacity 0.35s var(--ease);
  }

  .gallery-row-toggle::before {
    top: 50%;
    left: 0;
    width: 100%;
    height: 1px;
    transform: translateY(-50%);
  }

  .gallery-row-toggle::after {
    left: 50%;
    top: 0;
    width: 1px;
    height: 100%;
    transform: translateX(-50%);
  }

  .gallery-row:not(.is-collapsed) .gallery-row-toggle::after {
    transform: translateX(-50%) rotate(90deg);
    opacity: 0;
  }

  /* Standard "grid-rows 0fr/1fr" accordion trick — animates height without
     knowing the content's size, unlike a display:none/block toggle. */
  .gallery-row-collapse {
    display: grid;
    grid-template-rows: 0fr;
    width: 100%;
    transition: grid-template-rows 0.5s var(--ease);
  }

  .gallery-row:not(.is-collapsed) .gallery-row-collapse {
    grid-template-rows: 1fr;
  }

  .gallery-row-collapse > .gallery-row-strip {
    overflow: hidden;
    min-height: 0;
  }

  /* No dead space while collapsed — the gap only opens up once expanded */
  .gallery-row {
    gap: 0;
    transition: gap 0.5s var(--ease);
  }

  .gallery-row:not(.is-collapsed) {
    gap: var(--space-2);
  }
}

/* ---------- Detail panel (title/credits + stills, above the filmstrip) ---------- */

.stage-content {
  width: 100%;
}

.detail-panel {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-4);
  align-items: center;
  width: 100%;
  max-width: 920px;
  margin: 0 auto;
  /* Same --nav-duration/--nav-ease/--nav-distance recipe as the page
     transition and every other reveal on the site (see the @view-transition
     comment near the top of this file) — one consistent fade + small
     upward drift, not a spot-specific motion. */
  opacity: 0;
  transform: translateY(var(--nav-distance));
}

.detail-panel.panel-enter-active {
  opacity: 1;
  transform: translateY(0);
  transition: opacity var(--nav-duration) var(--nav-ease), transform var(--nav-duration) var(--nav-ease);
}

.detail-panel.panel-exit {
  opacity: 0;
  transform: translateY(calc(-1 * var(--nav-distance)));
  transition: opacity var(--nav-duration) var(--nav-ease), transform var(--nav-duration) var(--nav-ease);
}

.detail-copy {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-1);
}

.detail-title {
  font-family: var(--font-display);
  font-size: clamp(34px, 4.2vw, 58px);
  font-weight: 400;
  letter-spacing: -0.01em;
  line-height: 1.05;
  margin: 0;
}

/* max-width used to live on .detail-copy itself, computed in `ch` against
   its own small body font-size — a sensible reading measure for the credit
   text, but it also squeezed the much larger title into that same narrow
   width, wrapping titles like "Broken Hearts" that had plenty of room in
   the actual grid column. Moved here to the specific text elements it was
   actually meant for, so the title can use the column's full width. */
/* Regular case (not uppercase like the site's other micro-labels) —
   these lines now carry real sentence-like content (credits, genre,
   festival honors), not short tag-style labels, so they read as body
   copy rather than a caption. */
.detail-credit-line {
  font-size: 13px;
  letter-spacing: var(--meta-tracked);
  color: var(--gray);
  margin: 0;
  /* These are meant to read as a single line each (e.g. "Written by
     Brannen Haderle & Braden Stuchen") — max-width used to force a wrap at
     34ch, which cut a short credit line in half for no reason. nowrap
     keeps every line intact regardless of length. */
  white-space: nowrap;
}

.detail-credits {
  margin-top: var(--space-2);
  max-width: 34ch;
}

.detail-credits .kicker {
  display: block;
  margin-bottom: var(--space-1);
}

.credit-lines {
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: 13px;
  color: var(--body);
  line-height: 1.8;
}

.watch-link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  margin-top: var(--space-3);
  font-size: 12px;
  letter-spacing: var(--meta-tracked);
  text-transform: uppercase;
  border-bottom: 1px solid var(--hair);
  width: fit-content;
  padding-bottom: 4px;
  transition: opacity 0.3s var(--ease), border-color 0.3s var(--ease);
}

.watch-link:hover {
  opacity: 0.7;
  border-color: currentColor;
}

.stills-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-1);
  max-width: 380px;
}

.stills-grid .frame {
  aspect-ratio: 16 / 9;
}

.still-thumb {
  display: block;
  width: 100%;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
}

.still-thumb .frame::before {
  transition: transform 0.5s var(--ease);
}

.still-thumb:hover .frame::before,
.still-thumb:focus-visible .frame::before,
.still-thumb:hover .frame-img,
.still-thumb:focus-visible .frame-img {
  transform: scale(1.06);
}

.still-thumb:focus-visible {
  outline: 1px solid var(--hair);
  outline-offset: 3px;
}

/* The bio portrait reuses .still-thumb (it opens the same lightbox as a
   stills-grid image), but it's a single static portrait, not a clickable
   grid of stills to preview — the hover zoom read as an odd, unintentional
   twitch on a photo that isn't inviting exploration the way a stills grid
   is. Scoped to .about-portrait only; the zoom stays everywhere else. */
.about-portrait .still-thumb:hover .frame::before,
.about-portrait .still-thumb:focus-visible .frame::before,
.about-portrait .still-thumb:hover .frame-img,
.about-portrait .still-thumb:focus-visible .frame-img {
  transform: none;
}

@media (max-width: 900px) {
  .detail-panel {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-3);
  }

  .detail-copy { max-width: none; }
}

/* ---------- Lightbox (stills slideshow) ---------- */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 10, 10, 0.97);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s var(--ease);
}

.lightbox.is-open {
  opacity: 1;
  pointer-events: auto;
}

.lightbox-frame {
  /* --lb-ratio is set per-item in JS (defaults to 16/9 for video stills) —
     without it every image, portrait photos included, was force-fit into a
     16:9 box and cropped by object-fit:cover. Bounded by viewport width AND
     height (converted through the actual ratio) so aspect-ratio is never
     fighting a clamped height on short viewports. */
  width: min(80vw, 1100px, calc(80vh * var(--lb-ratio, 1.7778)));
  height: auto;
  aspect-ratio: var(--lb-ratio, 1.7778);
}

.lightbox-close,
.lightbox-prev,
.lightbox-next {
  position: absolute;
  /* Without this, DOM order decides paint order among same z-index:auto
     positioned siblings — since .lightbox-frame sits between prev and next
     in the markup, prev was painting underneath the image (invisible) while
     next painted above it (visible). z-index pins all three above the frame
     regardless of source order. */
  z-index: 1;
  /* The lightbox backdrop stays dark regardless of site theme (for image
     contrast), so its controls need a fixed light color, not a theme token. */
  color: #f3efe8;
  font-size: 22px;
  line-height: 1;
  padding: var(--space-2);
  transition: opacity 0.3s var(--ease);
  opacity: 0.7;
}

.lightbox-close:hover,
.lightbox-prev:hover,
.lightbox-next:hover {
  opacity: 1;
}

.lightbox-close {
  top: var(--space-3);
  right: var(--space-3);
  font-size: 28px;
}

.lightbox-prev {
  left: var(--space-3);
  top: 50%;
  transform: translateY(-50%);
}

.lightbox-next {
  right: var(--space-3);
  top: 50%;
  transform: translateY(-50%);
}

.lightbox-counter {
  position: absolute;
  bottom: var(--space-3);
  left: 50%;
  transform: translateX(-50%);
  font-size: var(--meta);
  letter-spacing: var(--meta-tracked);
  color: var(--gray);
  text-transform: uppercase;
}

/* "Watch Here" popup — same dark full-screen overlay treatment as the
   stills lightbox above, but holding a single embedded video player
   instead of a still/prev/next slideshow. See getVideoModal() in main.js. */
.video-modal {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 10, 10, 0.97);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s var(--ease);
}

.video-modal.is-open {
  opacity: 1;
  pointer-events: auto;
}

.video-modal-frame {
  /* --vm-ratio is set per-video in JS (defaults to 16/9) — most embeds are
     16:9, but a couple were cut wider (The Showdown, Stories Told) and
     would otherwise get letterboxed/cropped into the wrong shape. Bounded
     by viewport width AND height (converted through the actual ratio,
     same approach as the stills lightbox's --lb-ratio) so an ultra-wide
     video never forces the popup taller than the screen. */
  position: relative;
  width: min(90vw, 1200px, calc(85vh * var(--vm-ratio, 1.7778)));
  aspect-ratio: var(--vm-ratio, 1.7778);
}

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

.video-modal-close {
  position: absolute;
  top: var(--space-3);
  right: var(--space-3);
  z-index: 1;
  /* Fixed light color, not a theme token — same reasoning as the lightbox
     controls: this backdrop stays dark regardless of site theme. */
  color: #f3efe8;
  font-size: 28px;
  line-height: 1;
  padding: var(--space-2);
  opacity: 0.7;
  transition: opacity 0.3s var(--ease);
}

.video-modal-close:hover {
  opacity: 1;
}

@media (max-width: 700px) {
  /* Same height-bound-safe formula as desktop, just with more width headroom
     — a flat 86vw would let a portrait ratio image (like the About photo)
     grow taller than the viewport since nothing capped it by height here. */
  .lightbox-frame { width: min(86vw, calc(80dvh * var(--lb-ratio, 1.7778))); }
  .lightbox-prev, .lightbox-next { padding: var(--space-1); }
}

/* ---------- About (single viewport, no scroll — matches Home/Work) ---------- */

.about-shell {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
}

.about-grid {
  display: grid;
  grid-template-columns: minmax(220px, 320px) 1fr;
  gap: var(--space-6);
  align-items: center;
  width: 100%;
  padding: calc(var(--space-5) + 24px) 0 var(--space-4);
}

@media (max-width: 900px) {
  /* Without a cap here, the portrait stretches to the full single-column
     width (the grid's own minmax(220px,320px) sizing only applied in the
     two-column layout above) — on a narrower desktop window (700–900px,
     e.g. a laptop browser resized to half-screen) that meant a huge image
     shoving the bio text and contact info off the bottom of the viewport.
     Below 700px the more aggressive mobile-specific sizing further down
     takes over instead. */
  .about-grid { grid-template-columns: 1fr; gap: clamp(16px, 4vh, 40px); }

  /* vh-relative (not just a flat cap) so this also keeps shrinking on a
     short-but-wide window (e.g. 900×650) instead of only capping width. */
  .about-portrait {
    width: 100%;
    max-width: min(320px, 42vh);
    margin: 0 auto;
  }
}

@media (max-width: 700px) {
  /* Unlike Home/Work, About stays a single non-scrolling viewport on mobile
     too — so this overrides the general body.gallery-page scroll rule above
     (same specificity, wins by appearing later) and sizes the portrait/copy
     down to fit rather than letting the page grow past the fold. */
  body.about-page {
    display: flex;
    min-height: 100vh;
    min-height: 100dvh;
  }

  .about-shell {
    flex: 1 1 auto;
    align-items: center;
  }

  .about-grid {
    gap: clamp(20px, 4.5dvh, 32px);
    padding: calc(var(--space-4) + 16px) 0 var(--space-2);
  }

  /* Sized off viewport height (not a fixed px) so this keeps fitting a single
     screen on shorter phones (e.g. iPhone SE, ~568px tall) as well as taller
     ones — a fixed 150px overflowed the shortest common phone heights. dvh
     (not vh) so this tracks the same live viewport as body.about-page's
     min-height, instead of the address-bar-collapsed vh figure. */
  .about-portrait {
    width: clamp(84px, 17dvh, 150px);
    margin: 0;
  }

  /* Prefixed with body.about-page for extra specificity — the un-prefixed
     base rules for these same selectors happen to be declared further down
     in the stylesheet (outside any media query), so they'd otherwise win the
     cascade over this media query regardless of viewport width. */
  body.about-page .about-copy h1 {
    font-size: 22px;
    margin-bottom: var(--space-2);
  }

  body.about-page .about-copy p {
    font-size: clamp(10.5px, 1.9dvh, 12px);
    line-height: 1.65;
    color: var(--body);
    margin-bottom: 0.9em;
  }

  body.about-page .about-contact {
    margin-top: var(--space-3);
    padding-top: clamp(24px, 5.5dvh, 34px);
  }

  body.about-page .about-contact .kicker {
    font-size: 10px;
    margin-bottom: var(--space-1);
  }

  /* Matches the bio paragraph's size exactly, not just a similar mobile value */
  body.about-page .about-contact-links a {
    font-size: clamp(10.5px, 1.9dvh, 12px);
  }
}

.about-portrait .frame {
  aspect-ratio: 4 / 5;
}

.about-copy {
  max-width: 480px;
}

.about-copy h1 {
  font-family: var(--font-display);
  font-size: clamp(28px, 3.2vw, 40px);
  font-weight: 400;
  letter-spacing: -0.01em;
  margin: 0 0 var(--space-3);
}

.about-copy p {
  font-size: 15px;
  line-height: 1.7;
  color: var(--body);
  margin: 0 0 1.2em;
}

.about-contact {
  margin-top: var(--space-4);
  padding-top: var(--space-3);
  border-top: 1px solid var(--hair);
}

.about-contact .kicker {
  display: block;
  margin-bottom: var(--space-2);
}

.about-contact-links {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.about-contact-links a {
  font-size: 16px;
  transition: color 0.3s var(--ease);
}

.about-contact-links a:hover { color: var(--paper); }

/* ---------- Footer ---------- */

/* Reserves the real footer's height on its placeholder before main.js swaps
   the actual <footer> in. Without this, the placeholder sits at 0 height on
   first paint (a real gap on a real network, not just on an instant local
   server) — once the footer is injected and grows to its real height, that
   changes how much space body.about-page's flex column gives .about-shell,
   which visibly re-centers/shifts the whole vertically-centered page. Home
   and Work aren't height-locked the same way, but this keeps things
   consistent (and avoids the same small pop) across all three pages. */
[data-footer] {
  display: block;
  min-height: 95.5px;
}

@media (max-width: 700px) {
  [data-footer] {
    min-height: 78.5px;
  }
}

.site-footer {
  border-top: 1px solid var(--hair);
  padding: var(--space-4);
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 11px;
  letter-spacing: var(--meta-tracked);
  color: var(--gray);
  text-transform: uppercase;
}

@media (max-width: 700px) {
  /* Same layout as desktop — copyright left, email right — just sized down. */
  .site-footer {
    padding: var(--space-3) var(--space-2) var(--space-4);
    font-size: 10px;
  }

  /* Project detail is already a fixed, non-scrolling single screen (see
     html:has(.gallery-stage.is-detail) above) — flow layout mostly landed
     the footer at the true bottom anyway, but pinning it explicitly removes
     any dependency on a given project's content rendering to exactly the
     right height, which is what let it end up sitting a little high for
     some of them. background needed since it's now painting over
     whatever's still in normal flow beneath it. */
  body:has(.gallery-stage.is-detail) .site-footer {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    background: var(--black);
  }

  /* Reserves the room the now out-of-flow footer used to occupy, so the
     centered detail content above it doesn't extend underneath it. */
  body:has(.gallery-stage.is-detail) .filmstrip-section {
    padding-bottom: 90px;
  }
}

.site-footer a:hover { color: var(--paper); }

/* ---------- Motion preference ---------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}
