/* ============================================
   FONTS
   ============================================ */
@font-face {
  font-family: "Balgin Black";
  src: url("fonts/Balgin-Black.woff2") format("woff2"),
       url("fonts/Balgin-Black.otf") format("opentype");
  font-weight: 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "DGMFette";
  src: url("fonts/DGMFette.woff2") format("woff2"),
       url("fonts/DGMFette.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "VVDS Fifties";
  src: url("fonts/VVDS-Fifties-Med-Bold.woff2") format("woff2"),
       url("fonts/VVDS-Fifties-Med-Bold.otf") format("opentype");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Courier Prime";
  src: url("fonts/courier_prime_regular.woff2") format("woff2"),
       url("fonts/courier_prime_regular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Courier Prime";
  src: url("fonts/courier_prime_bold.woff2") format("woff2"),
       url("fonts/courier_prime_bold.ttf") format("truetype");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* ============================================
   DESIGN TOKENS
   ============================================ */
:root {
  --paper: #ffffff;
  --ink: #101112;
  --ink-soft: #55585c;
  --line: #e4e4e0;
  --accent: #1e3fff;

  /* site background + header/footer band */
  --site-bg: #cbe8b6;
  --band-bg: #6b8d53;

  /* project card colors */
  --c-blue: #1e3fff;
  --c-yellow: #ffd400;
  --c-pink: #ff3d8a;
  --c-green: #17c97b;
  --c-black: #101112;

  --font-display: "DGMFette", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-body: "Courier Prime", "Courier New", Courier, monospace;

  --max-width: 1200px;
  --gutter: clamp(20px, 5vw, 64px);
}

/* ============================================
   RESET & BASE
   ============================================ */
* {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
  /* always reserve space for the vertical scrollbar, whether or not the
     page is currently tall enough to need it. Without this, any small
     height change (like the footer's input/link swap) can tip the page
     just over or under the viewport height, causing the scrollbar to
     appear/disappear — which changes the available viewport width and
     visibly shifts anything sized with vw units, including the crate. */
  overflow-y: scroll;
}

body {
  margin: 0;
  background-color: var(--site-bg);
  background-image: url("textures/texture-bg.png");
  background-repeat: repeat;
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

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

a {
  color: inherit;
}

h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: 400;
  margin: 0;
  line-height: 1.05;
}

p {
  margin: 0;
}

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

button {
  font-family: inherit;
}

/* visible keyboard focus everywhere */
a:focus-visible,
button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

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

/* ============================================
   SCROLL REVEAL
   The .reveal-hidden class is only ever added by JS (see script.js), so if
   JS fails or doesn't run, elements simply stay in their normal visible state.
   ============================================ */
.reveal-hidden {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 0.7s cubic-bezier(0.22, 0.8, 0.24, 1),
              transform 0.7s cubic-bezier(0.22, 0.8, 0.24, 1);
}

.reveal-hidden.is-visible {
  opacity: 1;
  transform: none;
}

/* ============================================
   LAYOUT SHELL
   ============================================ */
.wrap {
  max-width: var(--max-width);
  margin: 0 auto;
  padding: 0 var(--gutter);
}

/* ============================================
   NAV
   ============================================ */
.site-header-band,
.site-footer-band {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  background-color: var(--band-bg);
  background-image: url("textures/texture-header.png");
  background-repeat: repeat;
}

.site-header-band {
  position: sticky;
  top: 0;
  z-index: 50;
  /* opacity, not transform:translateY — a sliding reveal moves the band's
     bottom edge into the viewport before its top edge, which means
     whatever sits lower in the stack (Copywriter) becomes visible before
     whatever sits higher (Jake Gunther) during the ~0.35s transition. An
     earlier version used translateY and could be caught mid-slide in
     exactly that half-revealed state. Opacity fades the entire band,
     including everything inside it, in and out together — there's no
     way for only part of it to be visible partway through. */
  transition: opacity 0.3s ease;
}

.site-header-band.is-hidden {
  opacity: 0;
  pointer-events: none;
}

.site-nav {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  row-gap: 14px;
  /* the brand block (name + title stacked) is inherently much taller than
     the footer's single line of text, so matching padding alone (as tried
     before) can't equalize total height — this uses minimal padding on the
     header and much more on the footer to close that gap. See
     design-guidelines.md for the reasoning if this needs revisiting. */
  padding: 10px var(--gutter);
  max-width: var(--max-width);
  margin: 0 auto;
}

.site-nav__brand {
  display: flex;
  flex-direction: column;
  text-decoration: none;
  line-height: 1.15;
}

/* Shifts the brand left to align the "J" with the crate's first (cream)
   record, which sits at 2.687vw since the crate is full-bleed. The nav
   itself is constrained to max-width 1200px + centered + padded, a totally
   different coordinate system — this calc() bridges the two. Only applied
   above 1280px, since that's where --gutter is reliably maxed at 64px and
   the math holds; below that, the gutter itself shrinks (clamp) and this
   formula would no longer be accurate, so the brand just uses its normal
   position instead of an incorrect shift. */
@media (min-width: 1280px) {
  .site-nav__brand {
    margin-left: calc(-47.313vw + 536px);
  }
}

.site-nav__name {
  font-family: var(--font-display);
  font-size: clamp(34px, 6vw, 60px);
  font-weight: 400;
}

.site-nav__title {
  font-family: var(--font-body);
  font-size: clamp(18px, 3vw, 36px);
  font-weight: 400;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  margin-top: 6px;
}

.site-nav__links {
  display: flex;
  gap: clamp(16px, 3vw, 32px);
}

/* thin vertical divider between nav items — visually separates them as
   two distinct things, not just spaced-apart text. :not(:first-child)
   rather than targeting the second item specifically, so this still
   works correctly if a third nav item is ever added. */
.site-nav__links li:not(:first-child) {
  border-left: 1px solid var(--ink-soft);
  padding-left: 16px;
}

/* mirrors .site-nav__brand's left-shift calc (see that rule for the full
   derivation) so the links sit the same distance from the right edge as
   the brand does from the left — same technique, opposite direction.
   Only applied above 1280px for the same reason the brand's shift is:
   below that, --gutter itself starts shrinking (it's a clamp()), so a
   fixed-viewport-width formula stops being accurate. */
@media (min-width: 1280px) {
  .site-nav__links {
    margin-right: calc(-47.313vw + 536px);
  }
}

.site-nav__links a {
  font-family: var(--font-body);
  font-weight: 400;
  text-decoration: none;
  font-size: 27px;
  position: relative;
  display: inline-block;
  color: var(--ink);
  padding-bottom: 4px;
  transition: font-weight 0.2s ease;
}

.site-nav__links a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 100%;
  bottom: 0;
  height: 2px;
  background: currentColor;
  transition: right 0.3s ease;
}

.site-nav__links a:hover {
  font-weight: 800;
}

.site-nav__links a:hover::after {
  right: 0;
}

/* ============================================
   HOMEPAGE HERO
   ============================================ */
.hero {
  padding: clamp(60px, 12vh, 140px) 0 clamp(40px, 8vh, 80px);
}

.hero h1 {
  font-size: clamp(40px, 7vw, 84px);
  max-width: 14ch;
}

.hero p {
  margin-top: 24px;
  max-width: 46ch;
  font-size: 18px;
  color: var(--ink-soft);
}

/* ============================================
   CRATE SECTION — full-bleed section built from real, unedited photos.
   backdrop_full.png (the "point A" rest-state crate) is ALWAYS visible and
   NEVER changes — this guarantees the wood and the 4 untouched records can
   never shift, drift, or double-expose, no matter what the middle record
   does. middle_hover_tight.png is a small, tightly-cropped piece of the
   "point B" photo, covering only the middle record + its vinyl reveal —
   confined to that small area, on purpose, so nothing outside it can be
   affected. ONLY the middle slot has any hover behavior at all (see the
   crate JS in script.js) — the other 4 are dim-and-navigate hit-zones only.
   ============================================ */
.crate-outer {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  margin-top: clamp(24px, 5vh, 70px);
  margin-bottom: clamp(60px, 10vh, 110px);
}

.crate-box {
  position: relative;
  width: 100%;
  aspect-ratio: 1749 / 887;
}

/* the backdrop never changes — always visible, full opacity, no exceptions.
   This guarantees the wood and the 4 untouched records can never shift or
   drift even slightly, since they're always the exact same static pixels. */
.crate-backdrop {
  display: block;
  width: 100%;
  height: auto;
  position: relative;
  z-index: 1;
}

/* a slot's resting cover art, replacing that slot's plain record in the
   backdrop photo. Sized to the printed record's face (not the hover
   overlay's box) and clipped at the front wall via overflow, so the bottom
   of the square art tucks behind the wood like the other records. Paints
   above the backdrop (later in the DOM, same z-index) and below the hover
   overlays (z-index 2). */
.crate-record-rest {
  position: absolute;
  z-index: 1;
  overflow: hidden;
  pointer-events: none;
}

.crate-record-rest img {
  display: block;
  width: 100%;
  height: auto;
}

/* each slot's own clean record cutout, positioned to land bottom-flush
   against that slot's real sleeve position (zero gap — see
   design-guidelines.md for why this is deliberate, not a shortcut).
   Individually faded in via JS adding .is-visible to the matching one. */
/* each slot's overlay is now a small container holding two independently
   animated layers, split from the same clean photo: the sleeve (on top)
   slides up into place with a little spring/overshoot, and the vinyl
   (behind it) slides out from underneath to the right, slightly after.
   The container itself has no transition — it's the two children's own
   transform + opacity that create the entrance, so the motion reads as
   "the record moves" rather than "a box fades in". */
.crate-record-overlay {
  position: absolute;
  z-index: 2;
  pointer-events: none;
  /* clips anything that extends past this container's bounds — this is
     what makes the resting translateY(22px) offset on the sleeve/vinyl
     actually hide their bottom portion, the same way the crate's front
     wall physically hides the bottom of the other records in the
     backdrop photo itself. Without this, the offset just shifts the
     image down into empty space rather than clipping it. */
  overflow: hidden;
}

/* every vinyl overlay sits above every sleeve overlay (z-index 3 vs 2).
   A vinyl never overlaps its OWN sleeve (its track is clipped to start at
   the sleeve's edge), so this keeps each sliding disc in front of the
   neighboring record no matter which order records were hovered/left. */
.crate-record-overlay--vinyl {
  z-index: 3;
}

/* ---- record hover motion ----
   Two layers of motion, each on its own element so nothing competes:
   - .crate-record-lift  moves the WHOLE record (sleeve + vinyl) up out of
     the crate, so the vinyl always travels with its sleeve.
   - .crate-record-vinyl slides sideways inside .crate-record-vinyl-track,
     a clip box that starts exactly at the sleeve's right edge. At rest the
     vinyl sits entirely left of that edge (hidden behind the sleeve), so
     it genuinely emerges from behind it — no opacity fade on the disc.
   Only transform/opacity animate, on layers promoted up front
   (will-change) so Safari doesn't create/destroy layers mid-hover, which
   is what causes a first-frame hitch and an end-of-animation snap.
   Enter and exit use different timing (transition values on the
   destination state): on enter the record lifts, the vinyl follows ~70ms
   later; on exit the vinyl slides home first, then the record settles.
   No overshoot curves — they read as springy/mechanical at this size. */
.crate-record-lift {
  position: absolute;
  inset: 0;
  opacity: 0;
  transform: translateY(22px) rotate(-2deg);
  will-change: transform, opacity;
  /* exit */
  transition: transform 480ms cubic-bezier(0.45, 0, 0.2, 1) 90ms,
              opacity 240ms ease 280ms;
}

/* a record whose cover is already showing at rest (paired with a
   .crate-record-rest layer): visible from the start, sitting exactly over
   the resting cover, lifted by --rest-drop (the share of the square sleeve
   hidden behind the wall, set per slot inline) so it ends bottom-flush.
   Its overlay is sized to the resting face (see index.html), so it only
   ever moves straight up — no scaling. */
.crate-record-lift--at-rest {
  opacity: 1;
  transform: translateY(var(--rest-drop, 7.31%));
}

.crate-record-overlay.is-visible .crate-record-lift {
  opacity: 1;
  transform: translateY(0) rotate(0deg);
  /* enter */
  transition: transform 560ms cubic-bezier(0.22, 0.8, 0.24, 1) 0ms,
              opacity 180ms ease-out 0ms;
}

.crate-record-sleeve {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  display: block;
  z-index: 2;
}

.crate-record-vinyl-track {
  position: absolute;
  top: 0;
  height: 100%;
  z-index: 1;
  overflow: hidden;
}

.crate-record-vinyl {
  display: block;
  width: 100%;
  height: 100%;
  transform: translateX(-100%);
  will-change: transform;
  /* exit: slides straight back in, easing into the sleeve */
  transition: transform 400ms cubic-bezier(0.5, 0, 0.25, 1) 0ms;
}

.crate-record-overlay.is-visible .crate-record-vinyl {
  transform: translateX(0);
  /* enter: gentle pickup, long soft settle */
  transition: transform 720ms cubic-bezier(0.33, 0, 0.12, 1) 70ms;
}

/* Every hit-zone is positioned independently (not flexbox), so none of them
   can shift based on another being added/removed/resized. Every one is
   tight to its own record's real visible area — hovering the wood or blank
   space around a record doesn't trigger anything, only the record itself
   does — and every one has the same hover effect now. */
.crate-hit {
  position: absolute;
  display: block;
  text-decoration: none;
  z-index: 5;
}

/* ============================================
   FOOTER
   ============================================ */
.site-footer {
  padding: 52px var(--gutter);
  max-width: var(--max-width);
  margin: 0 auto;
  display: flex;
  flex-wrap: wrap;
  gap: 16px 40px;
  align-items: baseline;
  justify-content: space-between;
}

/* Site-wide footer (started as homepage-only, now on every page by direct
   request — the class name is a holdover): text and icons at opposite ends
   of the container, level with each other. */
.site-footer--home {
  /* text at the start (left) and icons at the end (right) of this
     already-symmetric, centered, equally-padded container — this gives
     the text the same distance from the screen's left edge as the icons
     have from the screen's right edge, automatically, since the
     container itself is symmetric. An earlier version grouped both
     together at the right via flex-end, which didn't have this property. */
  justify-content: space-between;
  align-items: center;
  /* overrides .site-footer's 1200px max-width, which was the actual
     reason the text and icons felt cramped together — the homepage
     footer band is already full-bleed (100vw), so this lets both sides
     spread toward the real screen edges (inset only by --gutter) instead
     of being pulled inward to a 1200px centered column. */
  max-width: none;
}

/* Matches .site-nav__links a's font/underline mechanics — same family of
   interactive text link, different element since this lives in the
   footer. Prefixed with .site-footer for extra specificity: written as
   plain .site-footer__prompt, this rule was silently losing to the older,
   more specific .site-footer a / .site-footer a:hover rules below (meant
   for the plain email/Instagram links on every other page) — which is
   why earlier attempts to change this text's size and hover color had no
   visible effect despite looking correct in the code. */
.site-footer .site-footer__prompt {
  font-family: var(--font-body);
  font-weight: 400;
  text-decoration: none;
  font-size: 45px;
  position: relative;
  display: inline-block;
  color: var(--ink);
  padding-bottom: 4px;
  transition: font-weight 0.2s ease;
}

.site-footer .site-footer__prompt::after {
  content: "";
  position: absolute;
  left: 0;
  right: 100%;
  bottom: 0;
  height: 2px;
  background: currentColor;
  transition: right 0.3s ease;
}

.site-footer .site-footer__prompt:hover {
  font-weight: 600;
  color: var(--ink);
  transform: none;
}

.site-footer .site-footer__prompt:hover::after {
  right: 0;
}

.site-footer__icons {
  display: flex;
  align-items: center;
  gap: 20px;
}

.site-footer__icons img {
  height: 52px;
  width: auto;
  display: block;
}

.site-footer__icons a {
  display: flex;
  align-items: center;
  opacity: 0.85;
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.site-footer .site-footer__icons a:hover {
  opacity: 1;
  transform: scale(1.08);
}

.site-footer a {
  text-decoration: none;
  font-size: 15px;
  display: inline-block;
  transition: color 0.2s ease, transform 0.2s ease;
}

.site-footer a:hover {
  color: var(--accent);
  transform: translateY(-2px);
}

/* ============================================
   PROJECT PAGE
   ============================================ */
.project-header {
  padding: clamp(36px, 7.5vh, 75px) 0 40px;
  border-bottom: 1px solid var(--line);
  margin-bottom: clamp(40px, 8vh, 80px);
}

.project-header__back {
  display: inline-block;
  font-size: 14px;
  text-decoration: none;
  color: var(--ink-soft);
  margin-bottom: 28px;
}

.project-header__back:hover {
  color: var(--ink);
}

.project-header h1 {
  font-family: "VVDS Fifties", var(--font-display);
  font-size: 140px; /* starting point — actual size is set per-page by JS
                        (see script.js, "fit project title to width")
                        so each title, regardless of length, fills the
                        available width on one line. This value only
                        matters as a fallback if JS fails to run. */
  line-height: 1.2; /* wider than the global h1/h2/h3/h4 value (1.05) —
                        that tighter value works fine at this page's
                        previous, much smaller H1 size, but clips
                        descenders (a "y" losing its tail) once JS scales
                        titles up to several hundred px. Specific to this
                        selector only, not changed globally. */
  white-space: nowrap;
  overflow: hidden; /* safety net matching the no-JS fallback above —
                        without this, a title that doesn't get resized
                        would overflow past the page edge instead of
                        just being visually too small */
  /* full-bleed — breaks out of .wrap's normal centered/padded width to
     span the full viewport, same technique as .project-media,
     .crate-outer, .site-header-band elsewhere on the site. Deliberate:
     the title is meant to stretch the width of the page itself, not
     stay constrained to the same column the back-link/tagline/summary
     text sit in — those stay put, only the h1 breaks out. A small
     horizontal padding keeps the text from touching the literal screen
     edge, since 100vw with no padding would put the first/last letter
     flush against it. */
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  padding: 0 24px;
  box-sizing: border-box;
}

.project-header__tagline {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: clamp(18px, 2vw, 24px);
  letter-spacing: 0.04em;
  margin-top: 12px;
}

.project-header__summary {
  margin-top: 20px;
  font-size: clamp(20px, 2.5vw, 28px);
  line-height: 1.5;
  max-width: 62ch;
  color: var(--ink-soft);
}

.project-body {
  padding-bottom: clamp(60px, 10vh, 120px);
  display: flex;
  flex-direction: column;
  gap: clamp(40px, 8vh, 72px);
}

.project-body p {
  max-width: 62ch;
  font-size: 18px;
  line-height: 1.65;
}

/* full-bleed: breaks out of .wrap's normal centered/padded width to span
   the full viewport edge-to-edge, same technique used for
   .site-header-band and .crate-outer elsewhere on the site. Only width
   is set to 100vw — height is always auto, based on the media's own real
   aspect ratio, so nothing is stretched or distorted. This means a tall
   (portrait) image or video can end up taller than the viewport itself;
   that's expected, not a bug — the lightbox (see below) is what actually
   gives a fit-to-screen view. */
.project-media {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
}

.project-media img {
  cursor: pointer;
}

/* for a set of images meant to be seen together as one execution (e.g. a
   billboard series) — a row on desktop, stacking on narrower screens,
   rather than forcing multiple related images into separate full-width
   .project-media blocks meant for distinct standalone pieces */
.project-media-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 32px;
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
}

.project-media-grid img {
  width: 100%;
  height: auto;
  display: block;
  cursor: pointer;
}

.project-video {
  width: 100%;
  height: auto;
  display: block;
  cursor: pointer;
}

/* phone/portrait device-mockup video — constrained to a small centered
   width so it's fully viewable without scrolling, unlike every other
   media type on this page which is full-bleed. This is a deliberate
   exception: full-bleed made this specific video enormously tall (a
   portrait clip's height scales up right along with its width), and
   that wasn't wanted here. Not clickable/lightbox-enabled either,
   unlike other media — it's already fully visible, so there's nothing
   for a lightbox to add (see script.js, the lightbox trigger selector
   explicitly excludes .project-video--phone).

   ⚠️ THE CORNER FIX WENT THROUGH THREE ATTEMPTS. Worth understanding all
   three before touching this again:

   1. border-radius + overflow:hidden directly on the <video> tag. This
      was the original approach, and turned out to be a known-unreliable
      technique in some browsers — video decoding can use its own
      compositing layer that doesn't always respect CSS clipping
      correctly, which is very likely why a persistent white border was
      showing despite the video's own source frames (checked thoroughly:
      every corner, multiple frames across the full 15s duration) being
      confirmed clean.

   2. Moving the same border-radius + overflow:hidden + background-color
      onto this wrapping div (.project-video-frame--phone) instead of
      the <video> element — a plain div clips far more reliably. This
      was a real improvement but, per direct user feedback, was not
      sufficient by itself.

   3. THE ACTUAL FIX: the source video file itself was re-encoded with a
      green overlay masking the four corners OUTSIDE the phone's rounded
      shape — see project-videos/jackson-hole-04.mp4 and
      project-images/jackson-hole-04-poster.jpg, both regenerated from
      this process. This was done with a PRECISE SPATIAL MASK (a
      transparent rounded-rectangle cutout over a solid-green image,
      composited over every frame via ffmpeg's overlay filter) — NOT a
      naive "replace all white pixels with green," which would have
      destroyed the phone's own legitimate white UI content (status bar,
      app background, text). The mask's corner curve doesn't perfectly
      match the real photographed phone's curve at every one of the 4
      corners (real photography has slight per-corner variation a single
      uniform radius can't perfectly capture) — this wrapping div's CSS
      border-radius + overflow:hidden (still in place, see below) is
      what catches that last bit of imprecision with a hard, exact clip.
      **Both layers are needed together now — the video-level fix
      handles the color, the CSS-level clip handles any remaining
      geometric imprecision.** Removing either one on its own is likely
      to bring back a visible seam. */
.project-video-frame--phone {
  max-width: 380px;
  margin: 0 auto;
  aspect-ratio: 856 / 1840;
  border-radius: 32px;
  overflow: hidden;
  background-color: var(--site-bg);
}

/* Spotify ad card video — same "constrained width, fully viewable
   without scrolling" treatment as the phone video above, but simpler:
   this source is just a plain rounded-rectangle ad unit with no device
   bezel/hardware buttons, so there's no corner-masking complexity to
   worry about (see the phone video's documentation above and in
   design-guidelines.md for how much more involved that one was). This
   video has real audio meant to be heard, so it's deliberately NOT
   autoplaying — see the HTML for native `controls` instead of
   muted/loop/autoplay, since browsers block audio autoplay without a
   direct user gesture (the same reason the turntable widget requires an
   explicit click). Also excluded from the lightbox, same as the phone
   video — it already has its own native play controls and doesn't need
   a second "click to view" layer on top. */
.project-video-frame--card {
  max-width: 380px;
  margin: 0 auto;
  aspect-ratio: 476 / 966;
  border-radius: 24px;
  overflow: hidden;
}

.project-video-frame--card video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  cursor: default;
}

.project-video-frame--phone .project-video--phone {
  width: 100%;
  height: 100%;
  object-fit: cover;
  cursor: default;
}

.placeholder-block {
  width: 100%;
  aspect-ratio: 16 / 10;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-size: 15px;
  color: rgba(255,255,255,0.85);
  border-radius: 4px;
}

.project-nav {
  border-top: 1px solid var(--line);
  padding: 32px 0 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.project-nav a {
  text-decoration: none;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 20px;
}

.project-nav a:hover {
  color: var(--accent);
}

/* ============================================
   ABOUT PAGE
   ============================================ */
.about-layout {
  padding: clamp(48px, 10vh, 100px) 0 clamp(60px, 10vh, 120px);
  display: grid;
  grid-template-columns: 1.06fr 1.29fr;
  gap: clamp(48px, 8vw, 100px);
  align-items: start;
}

.about-photo {
  border-radius: 4px;
  overflow: hidden;
  /* position:relative (not sticky) — this element scrolls normally with
     the page now, but still needs SOME positioning context, because
     .photo-reveal__overlay inside it uses position:absolute + inset:0 to
     size itself to match this container exactly. Without any positioned
     ancestor here, that overlay has nothing to anchor to and escapes to
     the nearest ancestor that IS positioned (or the page's root, if none
     exists) — stretching to fill THAT instead, which is what happened
     when sticky was removed without replacing it with anything. */
  position: relative;
}

.about-photo img {
  display: block;
  width: 100%;
  height: auto;
}

/* baby photo overlay — fades in on hover, right on top of the headshot in
   the same frame. Same fade technique as the word-trigger popups
   elsewhere on this page (asymmetric visibility delay: instant to show,
   delayed to hide, so it doesn't just vanish — see the word-trigger
   section of design-guidelines.md for why that specific approach is
   needed instead of a plain opacity transition). */
.photo-reveal {
  cursor: pointer;
}

.photo-reveal__overlay {
  position: absolute;
  inset: 0;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.35s ease, visibility 0s 0.35s;
}

@media (hover: hover) and (pointer: fine) {
  .photo-reveal:hover .photo-reveal__overlay,
  .photo-reveal:focus-visible .photo-reveal__overlay {
    opacity: 1;
    visibility: visible;
    transition-delay: 0s;
  }
}

.photo-reveal.is-active .photo-reveal__overlay {
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}

.about-content p {
  font-size: 20px;
  line-height: 1.65;
  max-width: 58ch;
  margin-bottom: 20px;
  color: var(--ink);
}

@media (max-width: 760px) {
  .about-layout {
    grid-template-columns: 1fr;
  }
  .about-photo {
    max-width: 280px;
  }
}

/* ============================================
   CONTACT PAGE
   ============================================ */
.contact-layout {
  padding: clamp(60px, 14vh, 140px) 0 clamp(80px, 14vh, 160px);
}

.contact-layout h1 {
  font-size: clamp(36px, 7vw, 72px);
  max-width: 16ch;
}

.contact-email {
  display: inline-block;
  margin-top: 32px;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(24px, 4vw, 36px);
  text-decoration: none;
  border-bottom: 2px solid var(--ink);
  transition: color 0.2s ease, border-color 0.2s ease;
}

.contact-email:hover {
  color: var(--accent);
  border-color: var(--accent);
}

.contact-links {
  margin-top: 48px;
  display: flex;
  gap: 28px;
  flex-wrap: wrap;
}

.contact-links a {
  text-decoration: none;
  font-size: 15px;
  color: var(--ink-soft);
}

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

/* ============================================
   FUN PAGE — deliberately breaks the grid
   ============================================ */
.fun-layout {
  padding: clamp(48px, 10vh, 100px) 0 clamp(100px, 20vh, 220px);
  position: relative;
  min-height: 70vh;
}

.fun-layout h1 {
  font-size: clamp(40px, 8vw, 96px);
  margin-bottom: 8px;
}

.fun-intro {
  color: var(--ink-soft);
  font-size: 18px;
  max-width: 46ch;
  margin-bottom: 60px;
}

.fun-scatter {
  position: relative;
  height: clamp(500px, 90vh, 900px);
}

.fun-float {
  position: absolute;
  animation: fun-drift var(--drift-duration, 7s) ease-in-out infinite;
  animation-delay: var(--drift-delay, 0s);
}

.fun-float--1 { top: 0%;  left: 2%;  --drift-duration: 7.0s; --drift-delay: 0.0s; }
.fun-float--2 { top: 8%;  left: 48%; --drift-duration: 8.5s; --drift-delay: 0.6s; }
.fun-float--3 { top: 28%; left: 10%; --drift-duration: 6.5s; --drift-delay: 1.1s; }
.fun-float--4 { top: 34%; left: 60%; --drift-duration: 9.0s; --drift-delay: 0.3s; }
.fun-float--5 { top: 55%; left: 30%; --drift-duration: 7.5s; --drift-delay: 1.4s; }
.fun-float--6 { top: 62%; left: 68%; --drift-duration: 8.0s; --drift-delay: 0.8s; }
.fun-float--7 { top: 80%; left: 6%;  --drift-duration: 6.8s; --drift-delay: 0.2s; }
.fun-float--8 { top: 84%; left: 45%; --drift-duration: 9.4s; --drift-delay: 1.7s; }

@keyframes fun-drift {
  0%, 100% { transform: translate(0, 0); }
  50% { transform: translate(6px, -14px); }
}

.fun-item {
  display: inline-block;
  font-family: var(--font-display);
  font-weight: 400;
  padding: 18px 22px;
  border-radius: 4px;
  color: var(--paper);
  transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
  cursor: default;
}

.fun-item:hover {
  transform: scale(1.08) rotate(0deg) !important;
}

.fun-item--1 { background: var(--c-pink); font-size: 22px; transform: rotate(-4deg); }
.fun-item--2 { background: var(--c-yellow); color: var(--ink); font-size: 18px; transform: rotate(3deg); }
.fun-item--3 { background: var(--c-blue); font-size: 28px; transform: rotate(2deg); }
.fun-item--4 { background: var(--c-black); font-size: 20px; transform: rotate(-2deg); }
.fun-item--5 { background: var(--c-green); color: var(--ink); font-size: 24px; transform: rotate(-3deg); }
.fun-item--6 { background: var(--c-pink); font-size: 18px; transform: rotate(4deg); }
.fun-item--7 { background: var(--c-yellow); color: var(--ink); font-size: 20px; transform: rotate(1deg); }
.fun-item--8 { background: var(--c-blue); font-size: 22px; transform: rotate(-2deg); }

@media (max-width: 700px) {
  .fun-scatter {
    height: auto;
    display: flex;
    flex-direction: column;
    gap: 16px;
  }
  .fun-float {
    position: static;
    animation: none;
  }
  .fun-item {
    transform: none !important;
    align-self: flex-start;
  }
}

/* ============================================
   TURNTABLE WIDGET — fixed bottom-right, present on every page.
   Click (or Enter/Space) toggles play. The record itself is drawn with
   CSS (grooves, label, spindle) rather than a cropped photo, since it
   needs to read correctly from every angle while continuously spinning.
   ============================================ */
.turntable {
  position: fixed;
  /* top/left are set by script.js (not bottom/right) — this is required
     for the drag-and-drop and footer-avoidance logic to both work without
     fighting each other. Default position (bottom-right, matching the
     original look) is computed in JS on load if no saved drag position
     exists. Don't add bottom/right here; it'll conflict. */
  z-index: 200;
  width: 104px;
  height: 104px;
  cursor: grab;
  transition: transform 0.2s ease;
}

.turntable.is-dragging {
  cursor: grabbing;
  transition: none;
}

.turntable:hover {
  transform: scale(1.05);
}

.turntable:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
  border-radius: 50%;
}

.turntable__base {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #2a2420, #14100e 75%);
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}

.turntable__record {
  position: absolute;
  inset: 7%;
  border-radius: 50%;
  background:
    repeating-radial-gradient(
      circle at 50% 50%,
      #1a1a1a 0%, #1a1a1a 3%,
      #0c0c0c 3%, #0c0c0c 6%
    ),
    radial-gradient(circle at 50% 50%, #262220 0%, #0a0a0a 75%);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 10px rgba(0, 0, 0, 0.5);
  animation: turntable-spin 2.6s linear infinite;
  animation-play-state: paused;
}

.turntable.is-playing .turntable__record {
  animation-play-state: running;
}

.turntable__label {
  position: absolute;
  inset: 32%;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #d9a34a, #b87f2a);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3);
}

.turntable__spindle {
  position: absolute;
  inset: 47%;
  border-radius: 50%;
  background: #0a0a0a;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
}

.turntable__tonearm {
  position: absolute;
  top: 6%;
  right: 4%;
  width: 4px;
  height: 58%;
  background: linear-gradient(180deg, #cfcfcf, #8f8f8f);
  border-radius: 3px;
  transform-origin: top center;
  transform: rotate(-32deg);
  transition: transform 0.5s cubic-bezier(0.34, 1.1, 0.4, 1);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}

.turntable.is-playing .turntable__tonearm {
  transform: rotate(6deg);
}

.turntable__tonearm-head {
  position: absolute;
  bottom: -3px;
  left: 50%;
  transform: translateX(-50%);
  width: 12px;
  height: 12px;
  border-radius: 3px;
  background: #d8d8d8;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

@keyframes turntable-spin {
  to {
    transform: rotate(360deg);
  }
}

@media (max-width: 700px) {
  .turntable {
    width: 76px;
    height: 76px;
  }
}

/* ============================================
   HOMEPAGE INTRO — plays once per browser session (see script.js).

   This uses a separate clone element, not the real nav brand — going
   back to that structure after a real-element version introduced a worse
   bug (a flash of the wrong state before JS could run, since the real
   element has to exist in its normal position from first paint). The
   clone avoids that entirely: its big/centered starting look is plain
   CSS with no JS-dependent values, so there's nothing to flash.

   THE KEY FIX vs the ORIGINAL clone version: shrinking used to be done
   with `transform: scale()`, which means the browser renders the text
   large and then visually shrinks that rendering — this does NOT
   pixel-match the same text rendered naturally at the smaller size
   (font hinting/antialiasing differ at different render scales), which
   is what caused the "not seamless" handoff. The fix is to transition
   the real `font-size` instead (to the exact pixel value measured from
   the actual nav brand via getComputedStyle) — the clone is always
   genuinely rendered at whatever size it's showing, never simulated, so
   its final frame matches the real element pixel-for-pixel.
   ============================================ */
.intro-overlay {
  position: fixed;
  inset: 0;
  z-index: 500;
  background-color: var(--site-bg);
  background-image: url("textures/texture-bg.png");
  background-repeat: repeat;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 0.5s ease;
}

.intro-overlay.is-hidden {
  opacity: 0;
  pointer-events: none;
}

.intro-overlay__brand {
  display: flex;
  flex-direction: column;
  line-height: 1.15;
}

#introName {
  font-size: clamp(50px, 10vw, 110px);
  white-space: nowrap;
}

#introTitleWrap {
  font-size: clamp(28px, 7vw, 62px);
  white-space: nowrap;
}

.intro-cursor {
  display: inline-block;
  margin-left: 2px;
  animation: intro-cursor-blink 0.9s steps(1) infinite;
}

@keyframes intro-cursor-blink {
  50% {
    opacity: 0;
  }
}

/* everything except the intro overlay is hidden while `intro-hide-rest`
   is present on <body> — removed only after the overlay has fully faded
   out (see script.js), so the brand settles into place, THEN disappears
   into the real element, THEN the rest of the page appears — one thing
   changing at a time, not simultaneous effects. */
.site-nav__links,
.crate-outer,
.site-footer-band,
.turntable {
  transition: opacity 0.6s ease;
}

body.intro-hide-rest .site-nav__links,
body.intro-hide-rest .crate-outer,
body.intro-hide-rest .site-footer-band,
body.intro-hide-rest .turntable {
  opacity: 0;
  pointer-events: none;
}

body.intro-hide-rest {
  overflow: hidden;
}

/* ============================================
   WORD-TRIGGER PHOTO POPUPS (About page bio)
   Hover reveals the image on real hover-capable devices (mouse); on
   touch, script.js toggles .is-active on tap instead, since touch
   devices don't have a hover state to key off of. Both paths show/hide
   the exact same popup element — only the trigger mechanism differs.
   ============================================ */
/* plain bold external links within the bio (photographer, vintage
   clothing dealer) — distinct from .word-trigger, which reveals an inline
   popup and doesn't navigate anywhere. These are real links, so they get
   a real underline as the standard "this is clickable" signal. */
.bio-link {
  color: inherit;
  font-weight: 600;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}

.word-trigger {
  position: relative;
  display: inline;
  cursor: pointer;
}

/* opt-in modifier, since not every trigger word should be bold — DJ uses
   this, Strava deliberately doesn't */
.word-trigger--bold {
  font-weight: 600;
}

.word-trigger__popup {
  position: absolute;
  bottom: 100%;
  left: 50%;
  --popup-x: -50%;
  transform: translateX(var(--popup-x)) translateY(-8px) scale(0.94);
  transform-origin: bottom center;
  width: 220px;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  /* visibility gets its own entry: 0s duration, 0.25s delay. Since this
     rule governs the "hiding" direction (cursor leaving), that delay
     means visibility only flips to hidden AFTER the opacity/transform
     fade-out has fully played — without it, visibility jumps to hidden
     the instant the cursor leaves, cutting the fade off before it's
     visually shown at all, which is why it looked like a sudden
     disappearance instead of a reverse of the entrance animation. */
  transition: opacity 0.25s ease, transform 0.25s ease, visibility 0s 0.25s;
  z-index: 50;
}

/* "above and to the right" variant — the popup's left edge sits just past
   the word's right edge instead of centering over it */
.word-trigger--right .word-trigger__popup {
  left: calc(100% + 10px);
  --popup-x: 0%;
  transform-origin: bottom left;
}

.word-trigger__popup img {
  display: block;
  width: 100%;
  height: auto;
}

.word-trigger__placeholder {
  display: flex;
  width: 100%;
  aspect-ratio: 4 / 3;
  border-radius: 4px;
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
  background: var(--c-blue);
  color: rgba(255, 255, 255, 0.85);
  font-family: var(--font-display);
  font-size: 13px;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 12px;
}

/* real hover devices only — see the matching (hover: none) touch handling
   in script.js, which uses .is-active instead since this rule won't apply there */
@media (hover: hover) and (pointer: fine) {
  .word-trigger:hover .word-trigger__popup,
  .word-trigger:focus-visible .word-trigger__popup {
    opacity: 1;
    visibility: visible;
    transform: translateX(var(--popup-x)) translateY(-14px) scale(1);
    transition-delay: 0s;
  }
}

.word-trigger.is-active .word-trigger__popup {
  opacity: 1;
  visibility: visible;
  transform: translateX(var(--popup-x)) translateY(-14px) scale(1);
  transition-delay: 0s;
}

/* ============================================
   PROJECT MEDIA LIGHTBOX — click any project image/video to see it fit
   entirely within the screen, no scrolling needed. Fully opaque white
   background (not semi-transparent) so the page's own content — including
   a scroll-triggered video that might still be playing behind it — is
   completely hidden, not doubled up with the lightbox's own copy. No
   close button by design: clicking anywhere outside the media closes it.
   Built generically (see script.js) so this applies automatically to any
   .project-media/.project-media-grid image or .project-video on any
   page, not just Jackson Hole.
   ============================================ */
.lightbox-overlay {
  position: fixed;
  inset: 0;
  z-index: 500;
  background: #fafaf8;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  opacity: 0;
  visibility: hidden;
  cursor: zoom-out;
  transition: opacity 0.3s ease, visibility 0s 0.3s;
}

.lightbox-overlay.is-active {
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}

.lightbox-overlay__media {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 4px;
  cursor: default;
  transform: scale(0.96);
  transition: transform 0.3s ease;
}

.lightbox-overlay.is-active .lightbox-overlay__media {
  transform: scale(1);
}

