/* ==========================================================================
   Fonts
   ========================================================================== */
/* font-display: block (not swap) -- swap renders text in the fallback
   system sans-serif IMMEDIATELY and then swaps to the real font once it's
   parsed, which is exactly the "text glitches, looks a lot bigger for a
   moment" flash on every page navigation (fallback fonts have very
   different metrics from this pixel typeface). block instead holds text
   invisible for a very brief moment (spec max ~3s, but this is a small
   already-cached-after-first-load font file, so in practice it's much
   faster than that) and only ever renders in the real font -- no
   wrong-font flash, just a much shorter and less jarring invisible beat. */
@font-face {
  font-family: "Terminal Grotesque";
  src: url("../fonts/terminal-grotesque.ttf") format("truetype");
  font-weight: normal;
  font-style: normal;
  font-display: block;
}
/* Good Sans Medium -- used only for the small all-caps captions under
   project content items. font-display: swap (not block) is fine here:
   these are short secondary labels, so a brief fallback render costs
   nothing, unlike the headline face where it caused a visible size flash. */
@font-face {
  font-family: "Good Sans";
  src: url("../fonts/good-sans-medium.ttf") format("truetype");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
/* Open variant used on link/button hover.
 *
 * The metric overrides below are the real fix for the "text nudges off the
 * baseline on hover" problem, replacing the hand-computed translateY that
 * kept being slightly wrong. Root cause: the two files disagree about which
 * metric table is authoritative --
 *
 *     Terminal Grotesque      hhea 0.8125/0.1875   typo 0.625/0.1875
 *     Terminal Grotesque Open hhea 0.625 /0.188    typo 0.625/0.188
 *                             (win  0.813 /0.187)
 *
 * -- so the baseline offset the browser actually produces depends on which
 * table it decides to read, and no single magic number can be right for all
 * of them. These overrides take that choice away: they force the Open face
 * to report exactly the same ascent/descent/line-gap as Terminal Grotesque,
 * so both faces put the baseline in an identical place inside the line box.
 *
 *   size-adjust      97.65625%  = 625/640, the cap-height match that used to
 *                               be done with font-size: calc(1em * ratio)
 *   ascent-override  83.2%      x 0.9765625 = 0.8125  (matches base hhea)
 *   descent-override 19.2%      x 0.9765625 = 0.1875  (matches base hhea)
 *
 * Net effect: swapping to this face on hover changes NO metric at all, so
 * the text cannot shift vertically or horizontally -- no compensating
 * transform, no font-size calc anywhere. */
@font-face {
  font-family: "Terminal Grotesque Open";
  src: url("../fonts/terminal-grotesque-open.otf") format("opentype");
  font-weight: normal;
  font-style: normal;
  font-display: block;
  size-adjust: 97.65625%;
  ascent-override: 83.2%;
  descent-override: 19.2%;
  line-gap-override: 0%;
}

/* ==========================================================================
   Design tokens
   ========================================================================== */
:root {
  --bg: #f3f7f7;      /* bright */
  --fg: #141519;      /* dark */
  --bg-rgb: 243, 247, 247;
  --fg-rgb: 20, 21, 25;
  --glow-rgb: 161, 11, 43; /* #a10b2b */

  --font-body: "Terminal Grotesque", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
  --font-open: "Terminal Grotesque Open", var(--font-body);
  --font-caption: "Good Sans", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
  /* (--open-ratio used to live here: the 625/640 cap-height match applied
     as a font-size calc on every hover rule. It's now expressed once, as
     size-adjust inside the Open face's @font-face, so no rule has to
     compensate and the token is gone.) */

  /* Only two font sizes on the whole site: base, and a 2x title size used on
     the project detail hero. The gallery caption gets its own, smaller
     ratio of base (see --fs-gallery-title below). */
  --fs-base: clamp(1.5rem, 1.5vw, 2rem);
  --fs-title: calc(var(--fs-base) * 2);
  --fs-gallery-title: calc(var(--fs-base) * 1.5);

  /* tight spacing scale */
  --space-1: 0.3rem;
  --space-2: 0.6rem;
  --space-3: 1rem;
  --space-4: 1.5rem;
  --space-5: 2.25rem;
  --space-6: 3.5rem;
  --space-7: 5rem;

  /* THE side gutter for the whole site. Every horizontal inset -- nav,
     footer, gallery frame, project rows/columns, prev-next arrows, info
     page -- resolves to this one value, so the left edge of everything
     lines up on every page at every width, and the phone step-down happens
     in exactly one place (below) instead of being repeated per component.
     It used to be hard-coded as --space-4 in a dozen rules of which only
     some had a phone override, which is why the info page and footer sat
     at 24px on phone while the galleries and project rows sat at 16px. */
  --gutter: var(--space-4);

  --nav-h: 84px;
  --ease: cubic-bezier(0.16, 1, 0.3, 1);

  --glow-max: 20px;
}
@media (max-width: 1024px) { :root { --glow-max: 14px; } }
@media (max-width: 640px)  { :root { --glow-max: 10px; } }
/* Single phone step-down for the site gutter. 780px (not 640) because that
   is where the galleries and project content already collapse to one
   column, so the whole layout narrows together. */
@media (max-width: 780px)  { :root { --gutter: var(--space-3); } }
@media (max-width: 640px) {
  :root {
    /* Smaller than the 1.5x used elsewhere so the whole project title fits
       on one line instead of wrapping awkwardly mid-word. */
    --fs-title: calc(var(--fs-base) * 1.2);
    --fs-gallery-title: calc(var(--fs-base) * 1.2);
  }
}

/* ==========================================================================
   Page transition
   ==========================================================================
   Two half-width rectangles sweep up from the bottom corners to cover the
   screen, then keep going up and off the top to reveal the next page.

   Honest note on "fully CSS": the motion is pure CSS, but a page navigation
   destroys the document, so CSS alone cannot carry an animation across it.
   JS does exactly two things -- hold the click for the length of the cover
   animation, and set a sessionStorage flag the next page reads. Everything
   visual is here. (The View Transitions API would do this natively but is
   not supported widely enough to rely on yet.)

   The colour comes from --transition-color, set per-link on the way out
   (each link knows its destination's background) and defaulting to the
   page's own --bg on the way in. */
.page-transition {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  pointer-events: none;
  visibility: hidden;
}
/* Each half travels DIAGONALLY: in from its own bottom corner, out towards
   the top middle. The left panel starts down-and-left of frame and moves
   up-and-right; the right panel mirrors it. Both run for the same duration
   with no delay, so they move at identical speed and meet together.
   (The two panels cross each other on the way out -- invisible, since
   they're the same colour, so it reads as one mass lifting to the top.) */
.page-transition__half {
  width: 50%;
  height: 100%;
  background: var(--transition-color, var(--bg));
  will-change: transform;
}
.page-transition__half--l { transform: translate(-100%, 100%); }
.page-transition__half--r { transform: translate(100%, 100%); }

/* LEAVING: in from the bottom corners to cover. 1.3s, quad-out. */
.is-leaving .page-transition { visibility: visible; }
.is-leaving .page-transition__half--l {
  animation: cover-from-left 1.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.is-leaving .page-transition__half--r {
  animation: cover-from-right 1.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}

/* ARRIVING: hold 0.3s at full cover, then RETRACE the incoming path --
   back down the same diagonal to the bottom corners the panels came from.
   The new page is therefore uncovered from the top and middle first,
   working down and outward. 1s, quad-in (accelerating away). */
.is-entering .page-transition { visibility: visible; }
.is-entering .page-transition__half--l {
  transform: translate(0, 0);
  animation: reveal-to-bottom-left 1s cubic-bezier(0.55, 0.085, 0.68, 0.53) 0.3s forwards;
}
.is-entering .page-transition__half--r {
  transform: translate(0, 0);
  animation: reveal-to-bottom-right 1s cubic-bezier(0.55, 0.085, 0.68, 0.53) 0.3s forwards;
}

@keyframes cover-from-left  { from { transform: translate(-100%, 100%); } to { transform: translate(0, 0); } }
@keyframes cover-from-right { from { transform: translate(100%, 100%); }  to { transform: translate(0, 0); } }
/* Exactly the inverse of the cover keyframes above. */
@keyframes reveal-to-bottom-left  { from { transform: translate(0, 0); } to { transform: translate(-100%, 100%); } }
@keyframes reveal-to-bottom-right { from { transform: translate(0, 0); } to { transform: translate(100%, 100%); } }

/* Respect reduced-motion: no sweep, and the JS navigates immediately. */
@media (prefers-reduced-motion: reduce) {
  .page-transition { display: none; }
}

/* ==========================================================================
   Reset
   ========================================================================== */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Reserve the scrollbar track on every page (even the fixed, non-
     scrolling landing page) so the layout viewport width -- and therefore
     the vw component of --fs-base's clamp() -- can't differ between the
     landing page and the scrollable pages depending on whether a
     scrollbar happens to be present. */
  scrollbar-gutter: stable;
}
/* Safari fallback (scrollbar-gutter isn't supported there): reserve the
   track unconditionally, so a hover-triggered font swap that shifts a
   line's wrap point -- and therefore total document height -- can never
   cross the scrollbar-visibility threshold and nudge the whole column
   sideways (the list-view hover shift bug). Scoped to browsers that don't
   support scrollbar-gutter -- stacking a forced scrollbar on top of an
   already-reserved stable gutter double-counted the reserved width in
   supporting browsers, which was throwing max-width:1440px/margin:auto
   centered content (gallery, gallery-list, the view-toggle) off from the
   edge-anchored nav by that same amount. */
@supports not (scrollbar-gutter: stable) {
  html { overflow-y: scroll; }
}

/* Custom scrollbar: a 15px hard-edged rectangle in the page's type colour,
   length left to the browser (it reflects page length, as a scrollbar
   should). Applied at EVERY width, phones included -- the earlier
   min-width: 641px gate was why the bar reverted to the platform default on
   phone. The trade-off is that styling a mobile scrollbar makes it
   permanently visible rather than a transient overlay; that's the intended
   look here.
   Two syntaxes because they're mutually exclusive by engine: the
   ::-webkit-scrollbar pseudo-elements drive Chrome/Edge/Safari, while
   scrollbar-width/scrollbar-color is the standard property Firefox uses.
   Firefox offers no px width, only thin/auto, so it gets `auto` (nearest to
   15px) plus the matching colours. */
html {
  scrollbar-width: auto;
  /* var(--fg) so the bar always matches the type colour of the page it's
     on -- project pages each set their own palette, so a fixed colour
     would clash on most of them. */
  scrollbar-color: var(--fg) transparent;
}
::-webkit-scrollbar {
  width: 15px;
  height: 15px;
}
::-webkit-scrollbar-track {
  background: transparent;
  /* no border/margin so the track occupies the full width right up to the
     window edge -- any of these would inset the thumb from the edge */
  border: 0;
  margin: 0;
}
::-webkit-scrollbar-thumb {
  background: var(--fg);
  /* Hard rectangle. border-radius is zeroed on the thumb AND the piece
     pseudo-elements because WebKit rounds some of these by default, which
     is what still read as soft corners. */
  border-radius: 0;
  /* A border on the thumb is drawn INSIDE its box and shows the track
     through, which is what makes a styled scrollbar look inset rather
     than flush. Explicitly zeroed so the bar fills the full width and
     sits hard against the right edge of the window. */
  border: 0;
  background-clip: border-box;
}
::-webkit-scrollbar-thumb:hover,
::-webkit-scrollbar-thumb:active { background: var(--fg); border-radius: 0; }
::-webkit-scrollbar-track-piece { background: transparent; border-radius: 0; }
::-webkit-scrollbar-corner { background: transparent; }

body {
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-body);
  font-size: var(--fs-base);
  /* Fixed (not unitless) line-height: a bare number recomputes per-element
     against that element's OWN font-size, which is exactly what breaks
     multi-line hover text (second line jumps) and any hoverable link,
     since hovering locally shrinks the font-size via --open-ratio. Using
     the resolved length here means the inherited line-height stays put
     regardless of what an individual hovered descendant's font-size does. */
  line-height: var(--fs-base);
  font-kerning: none;
  letter-spacing: -0.02em;
  font-synthesis: none; /* no fake/synthesized bold or italic anywhere */
  -webkit-font-smoothing: antialiased;
}
@media (max-width: 640px) {
  /* Reading text (body copy -- bio paragraphs, project descriptions,
     credits, anything using the plain inherited size) shrinks to a more
     comfortable 1.2rem on phone. Nav and footer stay at the full
     --fs-base scale (the "1.5rem" size) -- they're short, single-line UI
     labels, not reading text, so they don't need to shrink, and shrinking
     them tended to make the whole page feel like a different, smaller
     piece of chrome than the rest of the site. */
  body { font-size: 1.2rem; line-height: 1.2rem; }
  .site-nav, .site-footer { font-size: var(--fs-base); line-height: var(--fs-base); }
}
img, video { max-width: 100%; display: block; }
/* vertical-align:top pins every inline-block link to the TOP of its line
   box instead of the browser's default baseline-to-strut alignment. The
   baseline model shifts depending on context (flex-centered nav vs. plain
   block text) which is exactly why the hover correction below kept
   behaving differently in different places; top-alignment is the same
   everywhere, so one correction value now actually applies everywhere. */
a { color: inherit; text-decoration: none; display: inline-block; vertical-align: top; }
ul { list-style: none; margin: 0; padding: 0; }
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; padding: 0; margin: 0; }

::selection { background: #a10b2b; color: #fff; }
::-moz-selection { background: #a10b2b; color: #fff; }

/* Global hover / active behavior for every link and button on the site.
   Hover swaps to the Open face and turns everything red (active kept red
   too, as a touch-device fallback where :hover may never fire). Size and
   baseline matching between the two faces is handled entirely by the
   @font-face metric overrides at the top of this file -- see the long note
   there -- so nothing here (or anywhere else) needs to compensate. */
a, button { transition: color 0.15s var(--ease); }
/* Just the face swap and the recolor. Size matching and baseline alignment
   are handled by the @font-face metric overrides at the top of this file,
   so there is deliberately no font-size calc and no translateY here. */
a:hover, button:hover {
  color: #a10b2b;
}

/* .gallery__item is itself an <a>, so the global a:hover above was applying
   the font swap AND the baseline translateY to the whole item -- thumbnail
   included -- and then .gallery__item:hover .gallery__caption applied a
   SECOND translateY on top of it. That double shift (and the image moving
   at all) is the "content jumps down on hover" bug. The item is a layout
   container, not text: it gets no transform and no font change, and the
   caption alone does the swap (see its own rule further down). */


/* ==========================================================================
   Nav -- solid color, one font size, same weight throughout
   ========================================================================== */
.site-nav {
  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(--gutter);
  color: var(--fg);
}
/* Sound-effects toggle: centered on the header bar itself (absolute, not a
   flex child) so it sits at the true middle of the viewport regardless of
   how wide the name on the left and the links on the right happen to be --
   same reasoning as the landing page's footer icon group. */
.sound-toggle {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: inline-flex;
  align-items: center;
  color: var(--fg);
  opacity: 0.35;
}
.sound-toggle svg { height: 1em; width: auto; display: block; }
.sound-toggle.is-on { opacity: 1; }
.sound-toggle .icon-on { display: none; }
.sound-toggle.is-on .icon-off { display: none; }
.sound-toggle.is-on .icon-on { display: block; }

/* Cover-image mask for the header (project pages in NAV_MASK_OVER_HERO).
   An identical copy of the nav painted in the background colour, laid
   exactly over the real one and clipped from the bottom by JS to the part
   of the header still sitting on the cover image. Because it's a clip and
   not a class swap, a letter crossing the image's bottom edge is split --
   background colour above the edge, type colour below -- which is what
   makes it read as the image masking the type.
   height is set inline by initNavMask; 0 by default so the real nav shows
   through if JS never runs. */
.site-nav-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 101;          /* directly above .site-nav (100) */
  overflow: hidden;
  height: 0;
  pointer-events: none;  /* clicks belong to the real nav underneath */
}
.site-nav-mask .site-nav {
  position: static;      /* fills the clipping wrapper instead of the viewport */
  color: var(--bg);
}
/* The copy must never react to the cursor -- the real nav underneath owns
   hover. Without this the two would disagree visually mid-hover. */
.site-nav-mask a,
.site-nav-mask button { pointer-events: none; }

/* Landing page: the reel's sound toggle lives in the header, matching the
   sfx speaker on every other page. Same absolute centering as .sound-toggle,
   but a separate class on purpose -- initSoundFx() binds to `.sound-toggle`,
   and this button controls the reel's audio, not the hover sound effects. */
.hero__sound-toggle--nav {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}
.hero__sound-toggle--nav:hover { color: #a10b2b; }

.site-nav__name { white-space: nowrap; }
.site-nav__links { display: flex; gap: var(--space-4); }
.site-nav__links a { white-space: nowrap; }
.site-nav__links a[aria-current="page"] { text-decoration: underline; }

@media (max-width: 480px) {
  .site-nav { padding-top: var(--space-2); padding-bottom: var(--space-2); }
  .site-nav__links { gap: var(--space-3); }
}

/* ==========================================================================
   Home page -- fixed, no scroll, full-bleed video edge to edge.
   Nav and footer float on top of the video as overlays (not flex siblings)
   so the video itself fills 100% of the viewport with zero gap anywhere.
   ========================================================================== */
body.home {
  height: 100vh;
  height: 100svh;
  overflow: hidden;
}
body.home .site-footer {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 90;
  color: var(--fg);
}

.hero {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: var(--fg);
}
.hero__frame { position: relative; width: 100%; height: 100%; overflow: hidden; }
.hero__frame video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* Intro overlay: sits above the main reel until clicked, then fades out
   over 0.4s. Both intro clips are stacked and cross-faded by an is-active
   class rather than being shown/hidden with display -- a display swap would
   drop the newly-shown video's decode and flash. */
.hero__intro {
  position: absolute;
  inset: 0;
  z-index: 2;
  cursor: pointer;
  opacity: 1;
  transition: opacity 0.4s var(--ease);
}
.hero__intro.is-hidden {
  opacity: 0;
  pointer-events: none;
}
.hero__intro-clip {
  opacity: 0;
  transition: opacity 0.15s linear;
}
.hero__intro-clip.is-active { opacity: 1; }

.hero__scrim {
  position: absolute;
  inset: 0;
  /* Above the intro too (z-index 2 + later in DOM), so the intro and the
     reel carry the same tint and the handoff doesn't change the image. */
  z-index: 3;
  background: rgba(var(--fg-rgb), 0.12);
  pointer-events: none;
}

/* Sound + play/pause toggles: plain inline elements sitting in the footer
   row, sized to the running text -- no button chrome, no glow, same
   hover/active treatment as every other link on the site. */
.hero__sound-toggle,
.hero__play-toggle {
  display: inline-flex;
  align-items: center;
  color: var(--fg);
}
.hero__sound-toggle svg,
.hero__play-toggle svg { height: 1em; width: auto; display: block; }
.hero__sound-toggle .icon-on { display: none; }
.hero__sound-toggle.is-unmuted .icon-off { display: none; }
.hero__sound-toggle.is-unmuted .icon-on { display: block; }
.hero__play-toggle .icon-pause { display: none; }
.hero__play-toggle.is-playing .icon-play { display: none; }
.hero__play-toggle.is-playing .icon-pause { display: block; }

/* Ambient idle animation, landing page only: every link/button's fill
   gradually cycles bright -> black -> bright, 3s each way (6s loop).
   Paused whenever the element is hovered/active so the normal font-swap
   and red-on-press behavior still take over cleanly. */
@keyframes ambient-pulse {
  0%, 100% { color: #f3f7f7; }
  50%      { color: #000; }
}
body.home .site-nav a:not(:hover):not(:active),
body.home .site-footer a:not(:hover):not(:active),
body.home .hero__sound-toggle:not(:hover):not(:active),
body.home .hero__play-toggle:not(:hover):not(:active) {
  animation: ambient-pulse 6s ease-in-out infinite;
}

/* ==========================================================================
   Reveal-on-scroll
   ========================================================================== */
.reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 0.7s var(--ease), transform 0.7s var(--ease);
}
.reveal.is-visible { opacity: 1; transform: translateY(0); }

/* ==========================================================================
   Projects gallery -- true independent-column masonry, big random gaps
   ========================================================================== */
/* Grid/list view toggle -- top-left, inside the same centered 1440px/gutter
   frame as the gallery content below it (margin: 0 auto on all three, so
   the toggle's left edge always lines up with the first thumbnail/name
   below it, wherever that centered column lands on a given screen). */
/* ONE shared frame owns the 1440px cap, the centering and the side gutter
   for everything on this page: the view toggle, the masonry columns and
   the list view are all children of it with ZERO horizontal padding of
   their own. Previously each of the three carried its own identical
   max-width/margin/padding trio -- identical on paper, but that meant
   three independent chances to diverge, and the toggle kept ending up on
   a different left edge than the columns. Sharing a single padded box
   makes a mismatch structurally impossible rather than merely intended. */
.gallery-frame {
  max-width: 1440px;
  margin: 0 auto;
  padding: 0 var(--gutter);
  width: 100%;
  /* Sticky footer: this frame is the growing flex child of body now (see
     body.page-projects), so a short list view still pins the footer down. */
  flex: 1 0 auto;
}
.gallery__view-toggle {
  padding: calc(var(--nav-h) + var(--space-2)) 0 0;
  display: flex;
  gap: var(--space-3);
}
.view-btn {
  display: inline-flex;
  align-items: center;
  justify-content: flex-start;
  color: var(--fg);
  opacity: 0.35;
}
/* shape-rendering: crispEdges turns off anti-aliasing on these pixel-art
   icons -- at their small rendered size the soft/blurred edge an
   anti-aliased diagonal-free rectangle path produces reads as a sliver of
   empty space on the left even though the path itself starts at x:0. */
.view-btn svg { height: 1.7em; width: auto; display: block; shape-rendering: crispEdges; }
.view-btn[aria-pressed="true"] { opacity: 1; }

body[data-view="list"] .gallery { display: none; }
body:not([data-view="list"]) .gallery-list { display: none; }

/* Sticky footer: whichever view is active grows to fill the remaining
   viewport height, so a short list view doesn't leave the footer floating
   mid-page -- it stays pinned to the bottom instead. */
body.page-projects {
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
}
.gallery-list {
  padding: var(--space-5) 0 var(--space-6);
}
/* list-style:none on the parent ul (base reset) removes the marker, but
   some browsers still reserve the marker's own inset box unless list-style
   is also set directly on the li -- a real, if inconsistent, source of a
   few extra px of unwanted left indent on exactly this kind of plain
   link-per-li list. Belt and braces. */
/* Two-line titles (see title_html in build.py) use line-height equal to
   their own font-size for the hover-jump fix -- setting the gap BETWEEN
   entries to that same value (var(--fs-gallery-title)) means the
   whitespace separating two different projects visually matches the
   whitespace separating a project's own two title lines, instead of the
   disconnected fixed spacing token used before. */
.gallery-list li { list-style: none; margin-bottom: var(--fs-gallery-title); }
/* white-space: nowrap for the same reason as .gallery__caption -- each
   <br>-separated title line must not ALSO be free to auto-wrap on its
   own, or the hover font swap's small width change could push a line
   over that wrap threshold and grow/shrink the whole entry. */
.gallery-list a { font-family: var(--font-open); font-size: var(--fs-gallery-title); letter-spacing: -0.02em; line-height: var(--fs-gallery-title); white-space: nowrap; }
@media (max-width: 640px) {
  .gallery-list a { font-family: var(--font-open); font-size: var(--fs-gallery-title); }
}

.gallery {
  display: flex;
  gap: var(--space-4);
  padding: var(--space-4) 0 var(--space-6);
  align-items: flex-start;
}
.gallery__col {
  display: flex;
  flex-direction: column;
  flex: 1 1 0;
  min-width: 0;
}
.gallery__col--b { margin-top: var(--gap, 30rem); }

.gallery__item { display: block; margin-bottom: var(--gap, 20rem); }
@media (max-width: 780px) {
  .gallery__item { margin-bottom: calc(var(--fs-gallery-title) * 5) !important; }
}

/* .gallery__parallax is now just a grouping wrapper for thumb + caption --
   the scroll parallax it was named for is gone, so its will-change is
   removed too (it was promoting every gallery item to its own compositing
   layer to accelerate an animation that no longer exists). */

.gallery__thumb {
  position: relative;
  overflow: visible;
  background: rgba(var(--fg-rgb), 0.06);
  box-shadow: 0 0 0 rgba(var(--glow-rgb), 0);
  transition: box-shadow 0.45s var(--ease);
}
/* img included, not just video: most thumbnails are GIFs now, and without
   height:auto they were being drawn into the box described by their width/
   height ATTRIBUTES rather than their own aspect ratio -- which is what was
   stretching them vertically. (The attributes are still worth emitting, from
   the real file's dimensions, so the browser can reserve correct space
   before the media loads and avoid a layout jump.) */
.gallery__thumb video,
.gallery__thumb img {
  width: 100%;
  height: auto;
  display: block;
}
.gallery__item:hover .gallery__thumb {
  box-shadow: 0 0 var(--glow-max) rgba(var(--glow-rgb), 0.55);
}

/* Terminal Grotesque Open is now a deliberate typographic choice for
   project titles rather than a hover state. Its @font-face metric
   overrides keep it at the same cap-height and baseline as the body face,
   so it sits in the layout exactly like the regular cut. */
.gallery__caption {
  font-family: var(--font-open);
  margin-top: var(--space-2);
  font-size: var(--fs-gallery-title);
  letter-spacing: -0.02em;
  /* Fixed line-height (see body rule) so a wrapped second line doesn't
     shift on hover -- it's pinned to the un-hovered --fs-gallery-title
     length, not recomputed against the smaller hover font-size. */
  line-height: var(--fs-gallery-title);
  color: var(--fg);
  /* Each title line (deliberately broken with <br> in build.py) must never
     auto-wrap on top of that -- the hover font swap changes rendered text
     width slightly (different face, different --open-ratio size), which
     for a line sitting close to the column's width could push it from one
     wrapped line to two right as you hover, growing the whole caption and
     shoving every item below it down. Pinning to one line per <br> segment
     removes that risk entirely, in both directions. */
  white-space: nowrap;
}
/* font-family is set explicitly here: the item itself opts out of the
   global hover font swap (so the thumbnail can't move), so the caption
   can't inherit the Open face from it and has to ask for it directly.
   No size or position change -- the metric overrides handle that. */
.gallery__item:hover .gallery__caption { color: #a10b2b; }

@media (max-width: 780px) {
  /* Side gutter now lives on the shared frame only -- the children keep
     vertical padding just as on desktop, so everything stays flush left
     with each other at every width. */
  .gallery { flex-direction: column; padding: var(--space-3) 0 var(--space-5); gap: 0; }
  .gallery__col { display: contents; }
  .gallery__col--b { margin-top: 0; }
  .gallery__view-toggle { gap: var(--space-2); }
  .gallery-list { padding: var(--space-5) 0 var(--space-5); }
}

/* ==========================================================================
   Project detail page
   ========================================================================== */

/* Column system shared by the hero text, content rows, and credits so
   everything lines up to the same 1440px-max, gutter-padded grid. */
.col-grid {
  max-width: 1440px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-4);
  padding: 0 var(--gutter);
}
.col-grid > .col1 { grid-column: 1; }
@media (max-width: 640px) {
  .col-grid { grid-template-columns: 1fr; }
}

/* Hero: a plain, normal-flow full-bleed image/video (no crop box, no
   overscan, no transform -- it just scrolls up like the rest of the page).
   On desktop the title/description overlay the bottom of that image
   (position: absolute within this relative wrapper, which is sized by the
   image alone since the overlaid text is out of flow); on phone they sit
   below it in normal flow instead, so the image stays free of type on a
   small screen. No scroll-linked motion on either -- the hero text offset
   has been removed, so there is no will-change/transform here at all. */
.project-hero {
  position: relative;
  width: 100%;
}
.project-hero__media { width: 100%; }
.project-hero__media img,
.project-hero__media video { width: 100%; height: auto; display: block; }
.project-hero__text {
  padding-top: var(--space-4);
  padding-bottom: var(--space-4);
}
@media (min-width: 641px) {
  /* Anchored to the TOP of the cover, starting at its vertical midpoint and
     flowing downward. Previously this was pinned to bottom: 0, which meant a
     tall description grew UPWARD -- so narrowing the window pushed it off the
     top of the image and straight through the header, where it became
     unreadable. Growing downward instead can only ever overflow the bottom
     edge, which initHeroTextOverflow handles by pushing the content stack
     down by exactly that amount. */
  .project-hero__text {
    position: absolute;
    left: 0;
    right: 0;
    top: 50%;
    bottom: auto;
  }
  /* Per-project exception (build.py: HERO_TEXT_AS_BG): draw the overlaid
     hero type in the page's BACKGROUND colour rather than its type colour,
     for covers where that reads better against the image. Desktop only --
     on phone the text sits below the image on the background itself, where
     this would make it invisible. */
  /* Cover-masked hero text (build.py: HERO_TEXT_AS_BG). The real text keeps
     the normal type colour; the background-coloured copy is stacked on top
     inside a wrapper clipped to the cover's box, so the colour changes
     exactly at the image's bottom edge -- including mid-glyph.
     This replaces a flat `color: var(--bg)`, which looked right over the
     image but turned the text invisible the moment a squeezed description
     spilled below it onto the page background (same colour).
     inset: 0 works out to precisely the cover, because .project-hero's
     height IS the cover's height: the text is absolutely positioned and so
     adds nothing to it. No JS needed. */
  .project-hero__text-mask {
    position: absolute;
    inset: 0;
    overflow: hidden;
    pointer-events: none;
    z-index: 1;
  }
  .project-hero__text-mask .project-hero__text { color: var(--bg); }
}
@media (max-width: 640px) {
  /* Phone: the text is in normal flow below the image, so there's nothing
     to mask -- and the clipped copy would otherwise render over the cover. */
  .project-hero__text-mask { display: none; }
}
@media (max-width: 640px) {
  /* Phone: text is in normal flow under the image. The 10rem above the
     title is the same gap used between every other pair of blocks further
     down the page, and padding-bottom is zeroed so the space between the
     description and the first content item comes from ONE source only --
     .project-stack's 10rem padding-top. Previously both contributed and
     the two stacked, which is why that gap read much larger than 10rem. */
  .project-hero__text {
    padding-top: 5rem;
    padding-bottom: 0;
  }
  /* Sound effects are pointless on touch (there's no hover to trigger
     them), so the toggle is hidden and initSoundFx skips binding entirely
     below this width -- see the matching guard in main.js. */
  .sound-toggle { display: none; }
}
.project-intro__title {
  font-family: var(--font-open);
  font-size: var(--fs-title);
  letter-spacing: -0.02em;
  line-height: 1;
  margin: 0 0 var(--space-2);
}
.project-intro__desc { line-height: 1; }

/* Each project page carries its own palette, written by build.py as inline
   --bg/--fg (+ the matching -rgb pairs) on <body> from the colour line at
   the end of that project's RTF. Because those are the same tokens the rest
   of the stylesheet already draws with, every rule -- body copy, nav,
   footer, credits, the faint rgba placeholder fills -- recolors itself with
   no per-project CSS. The values here are only the fallback for a project
   whose RTF has no colour line. */
/* Deliberately does NOT declare --bg/--fg. The per-project palette is
   emitted as a :root block in each page's <head>, and :root IS <html> --
   an element ABOVE <body>. Custom properties inherit downward, so any
   value re-declared here would override the inherited one for <body> and
   everything inside it, which is exactly what made every project page fall
   back to the same default dark/bright pair regardless of its own colours.
   The base `body` rule already paints background/color from these tokens,
   so this rule only needs to exist for the page-level class hook. */
body.project-page {
  background: var(--bg);
  color: var(--fg);
}

/* ==========================================================================
   Mini player -- only on content clips that actually carry audio
   ========================================================================== */
.mini-player { position: relative; line-height: 0; }
.mini-player video { width: 100%; height: auto; display: block; }
/* Controls sit over the bottom-left of the clip, always visible. */
.mini-player__controls {
  position: absolute;
  left: 0;
  bottom: 0;
  display: flex;
  gap: var(--space-3);
  padding: var(--space-3);
}
/* Countdown, bottom-right, mirroring the controls' inset. Tabular figures
   stop the width jittering as the digits tick over, which would otherwise
   shift the whole block. */
.mini-player__time {
  position: absolute;
  right: 0;
  bottom: 0;
  padding: var(--space-3);
  font-family: var(--font-caption);
  font-size: 0.5em;
  line-height: 1.4;
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
  color: var(--bg);
  filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.65));
  pointer-events: none;
}
.mini-player__btn {
  display: inline-flex;
  align-items: center;
  color: var(--bg);
  /* The clip underneath can be any colour, so the icons get a soft dark
     halo rather than relying on contrast with unknown footage. */
  filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.65));
}
.mini-player__btn svg { height: 1.1em; width: auto; display: block; }
/* Icon-only: no font swap, no baseline nudge, just the accent recolor. */
.mini-player__btn:hover { color: #a10b2b; }
.mini-player__btn .icon-pause,
.mini-player__btn .icon-sound { display: none; }
.mini-player.is-playing .mini-player__btn .icon-play { display: none; }
.mini-player.is-playing .mini-player__btn .icon-pause { display: block; }
.mini-player.is-unmuted .mini-player__btn .icon-muted { display: none; }
.mini-player.is-unmuted .mini-player__btn .icon-sound { display: block; }

/* --hero-overflow is set by initHeroTextOverflow (main.js): how far the
   hero description spills past the bottom of the cover image, or 0px. The
   hero text is absolutely positioned so it contributes nothing to layout
   height on its own -- adding the overflow here is what pushes the first
   content items (e.g. _p1-c2 / c3) down so the description never lands on
   top of them. */
.project-stack {
  padding-top: calc(var(--gap-top, var(--space-5)) + var(--hero-overflow, 0px));
  padding-bottom: var(--space-5);
  position: relative;
}

/* Full-width break (landscape media): its own row, margins preserved. */
.project-row {
  max-width: 1440px;
  margin: 0 auto var(--gap, 15rem);
  padding: 0 var(--gutter);
}
.project-row:last-child { margin-bottom: 0; }

/* Square/portrait runs: two INDEPENDENT columns, same idea as the Projects
   page masonry -- each column stacks its own items with its own random
   gap, not aligned row-to-row with the other column. */
.project-columns {
  display: flex;
  gap: var(--space-4);
  max-width: 1440px;
  margin: 0 auto var(--gap, 15rem);
  padding: 0 var(--gutter);
  align-items: flex-start;
}
.project-columns:last-child { margin-bottom: 0; }
.project-col { display: flex; flex-direction: column; flex: 1 1 0; min-width: 0; }
.project-col--b { margin-top: var(--gap, 15rem); }
.project-col .project-media { margin-bottom: var(--gap, 15rem); }
.project-col .project-media:last-child { margin-bottom: 0; }

/* No cropping, no letterboxing: media renders at its own natural ratio,
   scaled only to the width of whatever column it sits in. */
.project-media { line-height: 0; }
.project-media video, .project-media img {
  width: 100%; height: auto; display: block;
}
/* Caption under a content item, from the (description) in its filename.
   Good Sans Medium, all caps, flush left. Kerning/tracking are reset here
   because the global -0.02em is tuned for Terminal Grotesque and reads far
   too tight on this face at caption size. */
.project-media__credit {
  font-family: var(--font-caption);
  font-size: 0.5em;
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: 0;
  font-kerning: normal;
  text-align: left;
  margin-top: var(--space-2);
}

@media (max-width: 780px) {
  /* Collapse to a single flex flow so items reappear in their original
     reading order (via the inline `order` set in build.py) instead of
     "all of column A, then all of column B". */
  .project-columns { flex-direction: column; gap: 0; }
  .project-col { display: contents; }
  .project-col--b { margin-top: 0; }

  /* Every gap between content pinned to exactly 10rem on phone, overriding
     both the random inline --gap (10/15/20rem) and the desktop rules that
     deliberately zero the LAST item's margin.
     Those :last-child zeroes were the source of the leftover
     inconsistency: with .project-col { display: contents } the columns
     flatten into one flow, so "last child of column A" is usually NOT the
     visually last item -- it just dropped a gap somewhere mid-page. And at
     the very bottom, zeroing the final margin left only .project-stack's
     padding-bottom (3.5rem) plus .project-credits' padding-top before the
     credits, which is why that gap read short.
     So: every item keeps its 10rem bottom margin unconditionally, and the
     stack/credits padding that used to bridge the end is set to 0 -- the
     last item's own margin is the 10rem gap before the credits. */
  .project-stack { padding-top: 10rem !important; padding-bottom: 0 !important; }
  .project-row,
  .project-row:last-child { margin-bottom: 10rem !important; }
  /* Columns contribute no gap of their own -- the medias inside them do,
     so this stays 0 to avoid stacking a second 10rem on top. */
  .project-columns,
  .project-columns:last-child { margin-bottom: 0 !important; }
  .project-col .project-media,
  .project-col .project-media:last-child { margin-bottom: 10rem !important; }
  .project-credits { padding-top: 0 !important; }
}

.project-credits { padding-top: var(--space-5); padding-bottom: var(--space-5); position: relative; }
/* Credit lines sit directly on top of each other, exactly as typed in the
   RTF -- the block only breaks before ROLE. (They previously each carried a
   --space-4 bottom margin, which spread a tight four-line credit block over
   half a screen.) */
.project-credits__line { margin: 0; }
.project-credits__role-heading {
  margin-top: var(--space-4); margin-bottom: 0;
  /* <h2> for document structure -- neutralise the UA heading defaults so
     it keeps rendering exactly as the old <div> did. */
  font-size: inherit; font-weight: inherit;
}
.project-credits__roles { list-style: none; padding: 0; margin: 0; }
.project-credits__roles li { position: relative; padding-left: 0.7em; }
.project-credits__roles li::before { content: "•"; position: absolute; left: 0; }

.project-nav-footer {
  max-width: 1440px;
  margin: 0 auto;
  padding: var(--space-5) var(--gutter) var(--space-4);
  display: flex;
  justify-content: space-between;
  align-items: center;
  position: relative;
}
@media (max-width: 640px) {
  .project-nav-footer__name { display: none; }
}

/* ==========================================================================
   Info page -- split exactly in half; col 1 shares the site gutter,
   col 2 has no left margin so its text sits right at the midpoint.
   ========================================================================== */
.info {
  padding-top: calc(var(--nav-h) + var(--space-4));
  padding-bottom: var(--space-3);
  width: 100%;
}
.info__photo {
  width: 140px;
  aspect-ratio: 3 / 4;
  background: rgba(var(--fg-rgb), 0.1);
  margin: 0 var(--gutter) var(--space-4);
  object-fit: cover;
}
.info__bio {
  max-width: 60ch;
  padding: 0 var(--gutter);
  margin: 0 0 var(--space-3);
}
.info__bio:last-of-type { margin-bottom: var(--space-5); }
.info__contact { padding: 0 var(--gutter); margin-bottom: var(--space-5); }
.info__contact p { margin: 0 0 0.15em; }
.info__contact a { text-decoration: underline; }

.info__row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: 0;
  align-items: baseline;
  padding: var(--space-4) 0;
}
.info__row h2 {
  font-size: 1em;
  font-weight: normal;
  text-transform: uppercase;
  margin: 0;
  padding-left: var(--gutter);
  padding-right: var(--space-3);
}
.info__row .info__content {
  display: flex;
  flex-direction: column;
  padding-left: 0;
  padding-right: var(--gutter);
}
.info__row .info__content ul { display: flex; flex-direction: column; }
.info__row .info__content a { text-decoration: underline; }

@media (max-width: 640px) {
  .info__row { grid-template-columns: 1fr; }
  .info__row h2 { padding-right: var(--gutter); margin-bottom: var(--space-1); }
  .info__row .info__content { padding-left: var(--gutter); }
}

/* ==========================================================================
   Footer -- copyright left, sound toggle + Instagram right, same everywhere
   ========================================================================== */
.site-footer {
  display: grid;
  /* Outer columns size to their own content (so the copyright text always
     gets the room it needs and never wraps); the middle column absorbs
     whatever space is left and centers the icon group within it. */
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3) var(--gutter);
}
.site-footer__copyright { grid-column: 1; justify-self: start; white-space: nowrap; }
.site-footer__icons {
  grid-column: 2;
  justify-self: center;
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.site-footer__right { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: var(--space-4); }
/* Landing footer holds only the play/pause button now (sound moved to the
   header), so it's centered on the viewport at EVERY width -- with a single
   icon there's no crowding argument for leaving it off-center on phone.
   NOTE: deliberately does NOT set `position` on .site-footer here. An
   earlier version set `position: relative`, which overrode the
   `position: fixed` declared for body.home further up (same specificity,
   later in the file) -- that dropped the footer back into normal flow,
   where the fixed-height landing page pushed it to the top of the screen
   on top of the header. `fixed` is already a containing block for the
   absolutely positioned icon group, so nothing extra is needed. */
body.home .site-footer__icons {
  grid-column: auto;
  justify-self: auto;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

@media (min-width: 641px) {
  /* justify-self: center only centers the icons within grid column 2 --
     and because column 1 (copyright) and column 3 (Instagram) are
     different widths, that column's own box isn't centered on the footer
     bar itself, so the icons read as off-center. Taking the icon group out
     of grid flow and centering it against the footer bar directly (footer
     is position: fixed on the home page, so this centers on the full
     viewport width) fixes that regardless of how wide the other two
     items are. */
  .site-footer { position: relative; }
  .site-footer__icons {
    grid-column: auto;
    justify-self: auto;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
  }
}
@media (max-width: 640px) {
  .site-footer__copyright { font-size: calc(var(--fs-base) / 2); }
  /* The row uses align-items: center (fine when everything's the same
     font-size), but the copyright is now half-size -- centering its
     shorter box no longer lines its text up with the full-size Instagram
     link's text. Opt just these two text items into baseline alignment so
     they share a text baseline regardless of the font-size difference;
     the icon group (no text baseline of its own) is left out and stays
     vertically centered as before. */
  .site-footer__copyright,
  .site-footer__right { align-self: baseline; }
}

/* ==========================================================================
   Visually hidden, but present for screen readers and search engines.
   Used for the page-level <h1> on pages whose design has no visible title
   (landing, projects, info) -- a page with no h1 gives a crawler and a
   screen-reader user nothing to identify it by.
   Not display:none, which would remove it from the accessibility tree too.
   ========================================================================== */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The gallery captions are <h2> now (document structure), so the UA's
   default heading margin and bold weight have to be cleared -- the caption
   rule above sets everything else and assumes a neutral box. */
h2.gallery__caption { margin-bottom: 0; font-weight: inherit; }
