/* eli samuel — sequence homepage
   Single-viewport, non-scrolling page modelled directly on
   darrengwynn.com's own homepage: one photo at a time, contained (never
   cropped) inside generous surrounding quiet, click anywhere to advance,
   no arrows, no dots, no autoplay, no captions. The header/nav is the
   same component as the exhibition-wall homepage (freeform.html) — see
   style.css's own masthead section for the fuller comments — just
   simplified: no canvas underneath, so no tile/glass-backdrop overlap
   check is needed, and wordmark + nav can live in one plain <header>
   instead of two independently-positioned fixed elements. */

:root{
  --ink:#0a0a0a;
  --paper:#fafaf8;
  --muted:#7c7c78;
  --prose:#2c2c2a;
  --line:#cfcfcb;

  --font:'Inter',-apple-system,'Helvetica Neue',Arial,sans-serif;
  --fs-iface:10px; --ls-iface:2px; --lh-iface:16px;
  --fs-identity:16px;
  --fs-meta:12px; --lh-meta:1.6;
  --fs-body:14px; --lh-body:1.75;
}

*{ box-sizing:border-box; }
html,body{
  margin:0; padding:0; height:100%;
  background:var(--paper);
  color:var(--ink);
  font-family:var(--font);
  overflow:hidden; /* one viewport, no page scroll — same as the reference */
  -webkit-font-smoothing:antialiased;
  /* Every page here is a single fixed viewport by design (see
     overflow:hidden above) — nothing on any of them is ever meant to
     natively pan, scroll, or pinch-zoom. showcase.js's own swipe
     listener is deliberately bound to `document`, not #stage, so its
     gesture can start anywhere including right at the bottom edge —
     touch-action needs the same document-wide scope to actually back
     that up, or the browser's compositor can still claim a gesture
     that started outside #stage as its own before JS ever sees it. */
  touch-action:none;
  /* Custom on-brand cursor site-wide — a small solid dot standing in
     for the plain system arrow. Cursor is an inherited property, so
     this only ever reaches elements that don't set their own (button{}
     already has cursor:pointer, .stage-zone has cursor:none for its
     own chevron) — those keep working exactly as before. */
  cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="2.5" fill="black"/></svg>') 4 4, auto;
}
/* Clicking a real nav link (sequence.js/showcase.js) closes the menu
   first, then fades this to 0 before actually navigating, instead of the
   browser just tearing down the page mid-open — requested directly:
   "closes like it opened... gives way to the next set of images" rather
   than slamming shut. */
body.page-leaving{ opacity:0; transition:opacity .35s ease; }
/* The other half of the same transition — every page's <body> now
   starts invisible via this class already present in its own static
   HTML (not added by JS after the fact), so there's genuinely nothing
   to flash before the fade-in even starts; sequence.js/showcase.js
   remove the class a frame after load to trigger it. Without starting
   invisible in the raw markup itself, there'd be an instant of full-
   opacity content before any script could react — exactly the "choppy"
   handoff this was built to avoid on the way IN, matching page-leaving
   above on the way out. */
body.page-entering{ opacity:0; }
body{ transition:opacity .4s ease; }
/* Same dot as the site-wide cursor above (cx/cy/r all identical — the
   dot itself doesn't change size or shift position), just with a thin
   halo ring added around it, so hovering a link reads as the dot
   "focusing" rather than swapping to an unrelated shape. */
a{ color:inherit; text-decoration:none; cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer; }
/* Same halo-ring cursor as links above (identical SVG) — buttons like
   Previous/Next Project were still showing the native OS pointing-hand
   since this rule set cursor:pointer directly, a separate declaration
   from the `a{}` one above even though both used to resolve to the same
   native cursor. .stage-zone below overrides this back to none for its
   own chevron, same as before. */
button{ font:inherit; background:none; border:0; padding:0; color:inherit; cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer; }

/* Site-wide tap/click affordance reset. Without this, every custom
   "button" on the site (.stage-zone's tap halves, .video-play,
   #navToggle, .back-link/.details-link, #prevProject/#nextProject on
   the showcase pages, ...) showed two separate browser defaults that
   were never actually designed for here: a dark tap-highlight flash on
   mobile the instant a finger lands, and a persistent blue focus
   outline left behind after the tap/click registers — reported
   separately as "flashes black on mobile" and "a big blue box that
   comes up when you click." Both are the browser's own affordances for
   plain unstyled controls, not something this site ever drew on
   purpose; .gate already had its own local fix for exactly this
   (below), just never generalized past that one element. -webkit-tap-
   highlight-color kills the flash; :focus-visible (not :focus) keeps a
   real indicator for actual keyboard tabbing while dropping it for the
   tap/mouse-click case that was never supposed to show one. */
a, button{ -webkit-tap-highlight-color:transparent; }
a:focus, button:focus{ outline:none; }
a:focus-visible, button:focus-visible{ outline:2px solid var(--ink); outline-offset:2px; }

.page{
  height:100vh; height:100dvh;
  display:flex; flex-direction:column;
}

/* ---------- header ---------- */
#masthead{
  flex:0 0 auto;
  display:flex; align-items:flex-start; justify-content:space-between;
  /* 10px here + #navToggle's own 12px right padding below = 22px from
     the viewport edge to the "WORK" text — matching #wordmark's 22px on
     the left exactly (wordmark has no internal padding of its own). A
     real, confirmed mismatch: this used to be 44px, landing the visible
     text 56px from the edge, more than double the wordmark's inset. */
  padding:22px 10px 22px 22px;
  position:relative; /* containing block for #mainnav's dropdown */
  z-index:50;
}
#wordmark{
  display:block;
  font-size:var(--fs-iface); font-weight:700; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
  text-transform:uppercase;
  color:var(--ink);
}
/* index.html only — the wordmark link sits inside a real <h1> there (site
   name as the homepage's one heading, standard practice). display:contents
   removes the wrapper's own box entirely (default h1 margin included), so
   #wordmark lays out exactly as if the <h1> weren't there — this exists
   purely so the heading is real, present HTML, not a CSS trick to hide
   or fake anything; nothing about it is visually different from before. */
#wordmark-h1{ display:contents; }

/* Gets the same frosted background as #mainnav below it, while open, so
   "Work" reads as sitting inside the same panel as its own dropdown
   instead of floating separately above it — that separation was the
   real, confirmed source of two complaints at once: the box looking
   misaligned with the word above it, and a visible gap between them
   (see #mainnav's own top offset below for the actual cause of the gap,
   and why calc() rather than a hardcoded pixel value). */
#navToggle{
  display:flex; align-items:center;
  appearance:none; -webkit-appearance:none; background:none; border:none;
  /* Above #mainnav (z-index:1, below) in the stacking order — the
     swung-open bottom line is 20px long but the icon's own box is only
     5px tall, so it extends well past #navToggle's own bottom edge and
     into #mainnav's own box once that opens right underneath. Without
     this, #mainnav's later DOM position wins ties on equal (auto)
     z-index and paints over that stretch of the line, visibly cutting
     it off — a real, confirmed bug reported as the nav box
     "interfering with the lines." */
  position:relative; z-index:2;
  /* right:12px matches #mainnav's own right padding below (10px 12px) —
     both inset from the same masthead edge, so this is what makes "Work"
     line up with "Projects/Books/Info" once the nav opens underneath it.
     A real, confirmed 4px misalignment when this was 8px on both sides. */
  padding:4px 12px 4px 8px;
  border-radius:2px;
  color:var(--ink);
  cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
  transition:background .25s ease;
}
#masthead.nav-open #navToggle{
  background:rgba(255,255,255,.55);
  backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
  border-radius:2px 2px 0 0; /* square where it meets #mainnav below, so the seam disappears */
}
/* Design decision: the toggle is now a plain two-line icon on every
   breakpoint, not "WORK"/"PROJECTS" text on desktop and a single bare
   line on mobile — one consistent, self-explanatory affordance instead
   of two different ones that happened to sit in the same corner. Text
   stays in the DOM (sequence.js still updates it live) purely so the
   Work/Projects-switching logic below has something to read; it's
   never shown. */
.nav-current-label{ display:none; }
.nav-toggle-line{
  position:relative;
  display:block; width:20px; height:5px;
}
.nav-toggle-line::before,
.nav-toggle-line::after{
  content:''; position:absolute; left:0; width:20px; height:1px; background:var(--ink);
}
.nav-toggle-line::before{ top:0; }
/* Top line stays put; the bottom line hinges on its own right edge —
   transform-origin:right center, exactly on the line itself, not
   floating 4px above it. A prior version moved the pivot off the line
   entirely (up to the top line's own corner) to try to close the visual
   gap between the two once rotated, but rotating a line around a point
   that isn't actually on it makes that "anchored" end sweep through its
   own small arc instead of staying put — a real, confirmed bug reported
   as the icon drifting sideways instead of swinging cleanly in place.
   Pinning the origin to the line's own edge keeps that end completely
   fixed through the whole rotation, so it reads as a door hinged at one
   point and swinging its free end down, not sliding. Negative degrees,
   not positive — positive swings the line UP off the top of the icon
   instead of down toward #mainnav below it (rotate() is clockwise on
   screen, and this line's own local geometry runs the opposite way from
   what "positive = down" would assume). */
.nav-toggle-line::after{
  bottom:0;
  transform-origin:right center;
  /* Closing only — reverts to this rule's own transform:rotate(0) once
     .nav-open comes off and the animation below stops applying, so it
     needs a transition of its own to ease back up rather than snap. */
  transform:rotate(0deg);
  transition:transform .4s cubic-bezier(.4,0,.2,1);
}
/* Falls open rather than just rotating flatly to -90deg — swings past
   vertical, springs back, and settles, like it was hinged and gravity
   pulled it down with a bit of sway at the bottom rather than someone
   moving it in a straight line. A single eased transition (the previous
   version) can only ever move toward its target, never past it, so it
   read as sliding into place instead of falling — requested directly:
   "kinda like it's unlocked and it's fallen down, and gravity is moving
   it a little". transform-origin above is untouched by any of this — the
   anchor point this hinges from never moves, only the angle animates. */
#masthead.nav-open .nav-toggle-line::after{
  animation:navLineFall .7s cubic-bezier(.4,0,.2,1) forwards;
}
@keyframes navLineFall{
  0%   { transform:rotate(0deg); }
  55%  { transform:rotate(-100deg); }
  70%  { transform:rotate(-83deg); }
  85%  { transform:rotate(-93deg); }
  100% { transform:rotate(-90deg); }
}

/* Flush against the toggle, not floating below it with a gap —
   alignNavToCurrent() in sequence.js force-opens Work's own submenu
   every time the panel opens fresh, so it's visible immediately instead
   of requiring an extra tap to reveal it. Positioned so its top-right
   corner lands exactly on the swing icon's own anchor point (the top
   line's right edge, the same point .nav-toggle-line::after hinges
   from above) — measured directly off the live page (12px left, 8px up
   from the old top:22px/right:10px), so the panel reads as hanging from
   that same fixed point rather than sitting independently below it, per
   explicit request. */
#mainnav{
  /* top:100% would be #masthead's FULL height, padding-bottom included —
     landing well below #navToggle's own bottom edge. Subtracting 30px
     (22px for that padding + 8px to reach the anchor point above it)
     lands the corner on the anchor instead, and keeps working correctly
     even if #navToggle's own padding above ever changes, unlike a
     hardcoded top value would. z-index:1, below #navToggle's own
     z-index:2 above — see #navToggle's own comment for why. */
  position:absolute; top:calc(100% - 30px); right:22px; margin-top:0; z-index:1;
  display:flex; flex-direction:column; align-items:flex-end; gap:4px;
  padding:10px 12px;
  border-radius:0 0 2px 2px; /* square top corners — meets #navToggle's own box above with no visible seam */
  background:rgba(255,255,255,.55);
  backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
  opacity:0; visibility:hidden; pointer-events:none;
  transform:translateY(-4px);
  transition:opacity .25s ease, transform .25s ease, visibility 0s linear .25s;
}
#masthead.nav-open #mainnav{
  opacity:1; visibility:visible; pointer-events:auto;
  transform:translateY(0);
  /* Held back briefly so it appears once the icon above has mostly
     finished falling open, not at the same instant — the two animating
     in together read as one shape growing/stretching downward rather
     than a hinge swinging followed by a panel dropping in underneath
     it, a real, confirmed complaint ("the line is extending"). */
  transition:opacity .25s ease .2s, transform .25s ease .2s;
}
#mainnav a{
  display:block;
  font-size:var(--fs-iface); font-weight:400; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
  text-transform:uppercase;
  color:var(--muted);
  transition:color .2s;
}
#mainnav a:hover{ color:var(--ink); }

/* Personal/Travel are flat top-level links now (no more Projects
   dropdown), so #mainnav's children render in plain DOM order — no
   order overrides needed. #mainnav a{color:var(--muted)} above is an
   ID selector, which beats a class-based aria-current selector even
   with an attribute added, so the #mainnav prefix is required here too
   or this silently never applies. */
#mainnav a[aria-current="page"]{ opacity:1; color:var(--ink); }

/* A plain house icon standing in for "Home" (the wordmark itself no
   longer does double duty as a home link, since it's now the menu
   toggle everywhere — desktop and mobile alike), first in the cascade
   so it lands right above Selected Work. Hidden here by default; the
   unconditional rule further down reveals and sizes it. */
#mainnav a.nav-home{ display:none; }

/* Discovery hint, index.html only — see sequence.js for the trigger
   (reaching the slideshow's last slide) and dismiss logic. Hidden here
   by default; the unconditional rule further down reveals and positions
   it, reusing the exact spot #mainnav's own first item lands in, so the
   flash points at where the real menu is about to appear. */
#moreHint{ display:none; }

/* Same wordmark-as-menu treatment on mobile now too — "exactly the same
   as desktop," requested directly, including #moreHint further down.
   No longer gated behind a min-width breakpoint at all; #navToggle
   (sequence.js/showcase.js still wire its click handler up, harmlessly
   inert now that it's hidden everywhere) stays in the DOM only because
   ripping out its markup wasn't asked for. Fold the icon-based menu
   into the wordmark itself — "Eli Samuel" becomes the toggle, and what
   drops down is just the words themselves, left-aligned underneath it,
   not a floating boxed panel over on the right. */
#navToggle{ display:none; }
  #mainnav{
    left:22px; right:auto; top:100%; margin-top:0;
    align-items:flex-start; gap:4px; padding:0;
    background:none; backdrop-filter:none; -webkit-backdrop-filter:none;
    opacity:1; visibility:visible; pointer-events:none;
    transform:none; transition:none;
  }
  #masthead.nav-open #mainnav{ pointer-events:auto; transition:none; }
  /* Each word now animates on its own, not the panel as a whole — a
     downward cascade on open (item 1 first, each following item a beat
     later) via increasing transition-delay per nth-child, requested
     directly ("cascade down the words underneath"). */
  #mainnav a{
    opacity:0; transform:translateY(-8px);
    transition:opacity .3s ease, transform .3s ease;
  }
  #mainnav a:nth-child(1){ transition-delay:0s; }
  #mainnav a:nth-child(2){ transition-delay:.06s; }
  #mainnav a:nth-child(3){ transition-delay:.12s; }
  #mainnav a:nth-child(4){ transition-delay:.18s; }
  #mainnav a:nth-child(5){ transition-delay:.24s; }
  #masthead.nav-open #mainnav a{ opacity:1; transform:translateY(0); }
  /* The home icon (nth-child(1), see the mobile-hiding rule further up
     this file for why it only exists here) — same cascade item as every
     #mainnav a above, just swapped for a small centered icon instead of
     text. 16px square keeps its own visual weight roughly level with a
     line of the nav's own 10px/16px-line-height type next to it. */
  #mainnav a.nav-home{
    display:flex; align-items:center; justify-content:center;
    width:16px; height:16px; margin-bottom:2px;
  }
  #mainnav a.nav-home svg{ width:16px; height:16px; }
  /* The current-page link (e.g. "Books", while sitting on books.html)
     has its own opacity:1 rule further up this file so it still reads
     as the bold/emphasized one once the menu is open — but that same
     rule's attribute selector out-specifies the closed a{opacity:0}
     rule above, which left it sitting there permanently visible under
     the wordmark even with the menu shut. A real, confirmed bug,
     reported directly: it needs to disappear along with every other
     link when closed, and only reappear (still bold) once the menu is
     actually opened again. */
  #mainnav a[aria-current="page"]{ opacity:0; transform:translateY(-8px); }
  #masthead.nav-open #mainnav a[aria-current="page"]{ opacity:1; transform:translateY(0); }

  #moreHint{
    display:flex; align-items:center; justify-content:center;
    width:16px; height:16px;
    position:absolute; left:22px; top:100%; margin-top:0;
    color:var(--ink);
    opacity:0; transform:translateY(-4px);
    /* Fade + settle, same easing/feel as the typographic-settle reveal
       this replaced (chosen out of a live side-by-side of four motion
       candidates) — just without the letter-spacing tightening, since
       there's no longer any type to track in. Reads as a considered
       reveal rather than an alert blinking for attention. */
    pointer-events:none;
    transition:opacity .9s cubic-bezier(.4,0,.2,1), transform 1.1s cubic-bezier(.16,1,.3,1);
  }
  #moreHint svg{ width:16px; height:16px; }
  #moreHint.visible{
    opacity:1; transform:translateY(0);
    /* Clickable for the duration it's actually shown — the icon opens
       the nav too, not just "Eli Samuel" above it. Same halo-ring cursor
       every other clickable thing on the site uses. */
    pointer-events:auto;
    cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
  }
  /* Closing reverses the wave instead of mirroring it — the LAST word
     retracts first and the first word (right under "Eli Samuel")
     retracts last, so it reads as receding back up into the wordmark
     rather than the open cascade playing backwards. Only applied via
     .nav-closing, which sequence.js/showcase.js add for the duration of
     the close transition and then remove — .nav-open alone (e.g. a
     fresh page load that starts closed) never triggers this. */
  #masthead.nav-closing #mainnav a:nth-child(1){ transition-delay:.24s; }
  #masthead.nav-closing #mainnav a:nth-child(2){ transition-delay:.18s; }
  #masthead.nav-closing #mainnav a:nth-child(3){ transition-delay:.12s; }
  #masthead.nav-closing #mainnav a:nth-child(4){ transition-delay:.06s; }
  #masthead.nav-closing #mainnav a:nth-child(5){ transition-delay:0s; }

@media (max-width:720px){
  /* top:100% (above) lands on #masthead's own bottom padding edge — a
     22px gap under the wordmark's actual text, on top of the cascade's
     own 8px translateY entrance. Reads fine as breathing room on
     desktop's wider layout, but on a narrow phone screen it just looks
     like an unexplained blank line between "Eli Samuel" and the menu —
     reported directly. Pulling it up close to a tight, deliberate 8px
     gap instead (masthead height 60px = 22 top pad + 16 line + 22
     bottom pad; wordmark's own bottom sits at 38px, so 46px here is
     exactly 8px under it) — #moreHint shares the exact same anchor
     point, so it gets the identical adjustment. */
  #mainnav{ top:calc(100% - 14px); }
  #moreHint{ top:calc(100% - 14px); }
}

/* ---------- slideshow ---------- */
#stage{
  flex:1 1 auto;
  position:relative;
  display:flex; align-items:center; justify-content:center;
  min-height:0;
  padding:20px;
  /* Without this, the swipe-to-navigate JS (sequence.js/showcase.js)
     is fighting the browser's own touch handling rather than replacing
     it — a non-passive touchmove calling preventDefault() is the usual
     fix, and it IS in place, but iOS Safari's compositor decides
     whether to hand a gesture to JS at all before that listener ever
     runs, based on touch-action alone. Left at its default ("auto"),
     the compositor can still claim a swipe as its own scroll/bounce
     gesture pre-JS, which reads as the swipe just not working — a
     real, confirmed bug, and the reason the touchmove preventDefault
     fix alone didn't fully resolve it. touch-action:none tells the
     compositor up front that nothing here does native panning or
     zooming (true on every page this rule reaches — #stage is always
     a single fixed viewport, nothing to scroll), so it hands every
     gesture straight to the JS listeners instead. */
  touch-action:none;
}
#slideshow{
  position:relative;
  width:min(82vw, 1200px);
  height:min(78vh, 940px);
  /* Smooths the box resize when sequence.js toggles .with-caption on
     entering/leaving one of the gated slides above, so it eases along
     with the photo's own crossfade instead of jumping instantly. */
  transition:height .7s ease;
}
.slide{
  position:absolute; inset:0;
  opacity:0;
  transform:scale(1.015);
  filter:brightness(.325) saturate(.55);
  /* "Developing, 50% more" — chosen directly out of a live side-by-side
     built from two real gallery photos, after trying a plain crossfade,
     a "focus pull" blur version, the original milder "developing"
     version, and a scale-only version, so the choice was made on the
     actual motion, not a description of it. The incoming photo starts
     dim and noticeably desaturated, then lifts to full exposure and
     colour as it settles to size — like a print rising in a developing
     tray, pushed a stop further than the first pass at this idea
     (brightness .55→.325, saturation .7→.55, scale 1.01→1.015 — "50%
     more" on each). "Standard" cubic-bezier(.4,0,.2,1) deceleration for
     opacity; a slower, rounder cubic-bezier(.22,.61,.36,1) for the
     filter/transform so the exposure lift keeps gently finishing after
     the fade itself reads as done. */
  transition:opacity .75s cubic-bezier(.4,0,.2,1), filter 1.1s cubic-bezier(.22,.61,.36,1), transform 1.1s cubic-bezier(.22,.61,.36,1);
  pointer-events:none;
  /* Belt-and-suspenders alongside the decode-before-reveal wait in
     sequence.js: forces its own compositing layer, which is the other
     half of the fix for mobile Safari's own black-flash-during-opacity-
     transition bug on absolutely-positioned crossfades like this one. */
  -webkit-backface-visibility:hidden; backface-visibility:hidden;
  will-change:opacity, transform, filter;
}
.slide.active{ opacity:1; transform:scale(1); filter:brightness(1) saturate(1); pointer-events:auto; }
/* Both fill the box exactly at 100%/100% — no flexbox, no intrinsic-size
   shrink-to-fit involved (a flex-based center-and-shrink approach here
   hit the classic "flex item won't shrink below its content size"
   problem twice over, once for <picture> and once for <img>). The
   letterboxing (never cropping) is handled purely by object-fit on the
   img, not by layout sizing. */
.slide picture,
.slide img{
  display:block;
  width:100%; height:100%;
}
.slide img{
  object-fit:contain;
  object-position:center;
  user-select:none;
  -webkit-user-drag:none;
}

/* Adult-content gate. A small number of slides show a film negative
   first instead of the real photo, with the same caption treatment
   every other page on the site uses below its photos (.slide-media /
   .slide-caption / .slide-caption-text, unmodified), not a distinct
   button or overlay badge. #slideshow.with-caption (below) is toggled
   onto the shared slideshow box by sequence.js whenever the active
   slide is one of these, shrinking it exactly the way it already
   shrinks on verano.html/modibodi.html, so there is real, visible space
   between the photo and the caption line instead of the caption sitting
   flush against the bottom edge.

   Both the negative and the real photo live inside .gate the whole
   time, stacked via .gate-negative/.gate-real below, and the click just
   toggles which one shows plus swaps the caption text (see the
   .gate click handler in sequence.js), it never becomes
   non-interactive, so the same tap covers the photo back up again.
   z-index:6 is the same reason as .video-wrap further down: it needs
   to sit above .stage-zone's own z-index:5, or the tap would just
   advance the slideshow instead of reaching .gate. And that has to
   live on .slide itself, not just .gate inside it. .slide's own
   will-change:opacity above forces it into its own stacking context,
   which traps a z-index set only on .gate: it would win locally
   against a sibling, but could never climb high enough to beat
   .stage-zone's z-index:5 at the #stage level. A real, confirmed bug
   the first time this was built. */
.slide.gated{ z-index:6; }
.gate{
  position:absolute; inset:0;
  cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
}
/* .chevron-hover (sequence.js's wireChevronHover, same mechanism
   .stage-zone already used) — hides the halo-ring pointer above in
   favor of the hover-cursor chevron, but only while the mouse is
   actually over the real photo inside .gate, not the whole letterboxed
   box the halo-ring cursor covers by default. */
.gate .slide-media.chevron-hover{ cursor:none; }
/* .gate is a plain div with role="button"/tabindex="0" (see the "own
   Enter/Space handling" comment on its keydown listener in sequence.js),
   not a real <button> — so with no focus style of its own anywhere in
   this file, clicking it left the browser's bare default blue outline
   sitting right on top of the photo, full-bleed since .gate fills the
   whole slide. A real, confirmed bug, reported as "a blue box over the
   film negative image." :focus-visible keeps a real indicator for actual
   keyboard tabbing (this is a real interactive control, not decorative)
   while :focus:not(:focus-visible) drops it for the mouse-click case
   that was actually broken. */
.gate:focus{ outline:none; }
.gate:focus-visible{ outline:2px solid var(--ink); outline-offset:-2px; }
.gate .slide-media{ position:absolute; inset:0; }
.gate-negative,
.gate-real{
  position:absolute; inset:0;
  transition:opacity .9s ease;
}
.gate-real{ opacity:0; }
.gate.revealed .gate-negative{ opacity:0; }
.gate.revealed .gate-real{ opacity:1; }

/* Desktop: the per-slide caption markup (.slide-caption inside .gate,
   still needed below for mobile, and read from directly as the source
   text) is hidden here and mirrored into #gateHint instead, a real
   sibling of #slideshow the same way #hint is (see index.html), not
   nested inside the photo's own box. That distinction is the whole
   point: .gate is exactly the same box as the photo, so anything
   positioned "bottom:28px" inside it sits 28px above the photo's OWN
   bottom edge, on top of the image. #hint's bottom:28px is relative to
   #stage instead, which has real empty space below #slideshow (#stage
   is taller than the photo box, centered around it), so it never
   touches the picture. A real, confirmed bug the first two times this
   was built: it looked identical in the CSS (same "bottom:28px") but
   measured out to two completely different boundary boxes. #gateHint
   below reuses #hint's own positioning and font treatment exactly, for
   exactly that reason. */
.gate .slide-caption{ display:none; }
/* Mobile keeps the box-shrink version untouched instead: undoes the
   desktop-only display:none above and restores .gate/.slide-media/
   .slide-caption to the same shared .slide-media/.slide-caption flow
   every other captioned page already uses (matches
   #slideshow.with-caption below, which sequence.js still applies here
   on mobile). Requested as-is, working well already. */
@media (max-width:720px){
  .gate{ display:flex; flex-direction:column; }
  .gate .slide-media{ position:relative; flex:1 1 auto; min-height:0; }
  .gate .slide-caption{
    display:block;
    position:static;
    flex:0 0 auto; padding-top:14px; min-height:1.2em;
  }
}

/* Per-photo captions — shared by every sequence page that wants one
   (commercial.html's brand names under each cover; modibodi.html/
   verano.html's per-photo captions, sourced from data.js). Plain pages
   like index.html's own arrival sequence use neither class and keep the
   default .slide sizing above untouched. #slideshow.with-caption trims
   the stage's own height so the caption line fits within the same
   fixed-height box instead of growing it. */
#slideshow.with-caption{ height:min(70vh, 820px); }
/* jamie-turner.html's 9:16 clips (see .video-wrap below): same fixed
   with-caption height, but width derived from that height via
   aspect-ratio instead of the wide default above, so the box actually
   matches the portrait footage instead of letterboxing it inside a
   landscape frame. Placed after #slideshow.with-caption on purpose —
   both match #slideshow with equal specificity, so source order is
   what makes this one win the width while leaving its height alone. */
#slideshow:has(.slide.active.portrait){ width:auto; max-width:min(82vw, 1200px); aspect-ratio:9/16; }
/* No plain slide carries a caption below the photo anymore (removed
   sitewide except the gate slides, which use their own .gate .slide-media
   rule above) — position:absolute;inset:0 fills .slide directly, the same
   fix .gate .slide-media already relies on, instead of flex:1 1 auto,
   which only produced a real height while .slide itself was display:flex
   (via the now-gone .has-caption class). Without that, .slide-media's
   height resolved to auto → the image's own intrinsic aspect-ratio height
   at its rendered width, squeezing every photo into a thin strip pinned to
   the top of the box. A real, confirmed bug the caption-removal pass
   introduced sitewide, not just on the pages that used to show captions. */
.slide-media{ position:absolute; inset:0; }
.slide-media picture,
.slide-media img{ display:block; width:100%; height:100%; }
.slide-media img{ object-fit:contain; object-position:center; user-select:none; -webkit-user-drag:none; }
.slide-caption{ flex:0 0 auto; margin:0; min-height:1.2em; padding-top:14px; text-align:center; }
.slide-caption-name{
  margin:0;
  font-size:var(--fs-iface); font-weight:700; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
  text-transform:uppercase; color:var(--ink);
}
/* Per-photo caption text — same quiet treatment as freeform.html's own
   #stageCaption, so a caption reads the same whether you're in the
   gallery-wall lightbox or one of these dedicated project pages. */
.slide-caption-text{
  margin:0;
  text-transform:capitalize;
  font-size:var(--fs-meta); font-weight:400; line-height:var(--lh-meta); color:var(--muted);
}

/* Book detail pages (books.html's own full-page destinations) — .book-
   info is a flex sibling of #stage (not nested inside it), so
   .stage-zone's full-height tap area over #stage never overlaps the Buy
   buttons here. Title, physical specs, description, and real Buy
   buttons wired to the existing Stripe checkout function (books.js). */
/* #slideshow.book-cover: shorter than the plain default (74vh) — a book
   page's own info block below it (title through Buy buttons) needs
   real room, and .page/html/body all use overflow:hidden with no
   scroll, so anything that doesn't fit here just gets clipped rather
   than pushed onto a second screen. */
#slideshow.book-cover{ height:min(46vh, 560px); }
.book-info{ flex:0 0 auto; padding:0 24px 28px; text-align:center; }
.book-title{
  margin:0 0 4px;
  font-size:var(--fs-identity); font-weight:500; color:var(--ink);
}
.book-meta{
  margin:0 0 10px;
  font-size:var(--fs-iface); letter-spacing:var(--ls-iface); text-transform:uppercase; color:var(--muted);
}
.book-description{
  margin:0 auto 8px; max-width:52ch;
  font-size:var(--fs-body); line-height:var(--lh-body); color:var(--prose);
}
.book-handmade{
  margin:0 auto 16px; max-width:52ch;
  font-size:var(--fs-meta); line-height:var(--lh-meta); color:var(--muted); font-style:italic;
}
/* Shipping destination — answered before Buy is clickable (books.js),
   since that's what actually prices the shipping line server-side.
   Styled like a plainer sibling of .buy-btn: same face/case/border
   language, no fill, so it reads as "answer this" rather than "buy
   this" ahead of the real buttons below it. */
.ship-select-row{ margin:0 0 12px; }
.ship-select{
  appearance:none; -webkit-appearance:none;
  border:1px solid var(--muted); background:var(--paper);
  border-radius:0;
  padding:9px 30px 9px 14px;
  font-family:inherit;
  font-size:var(--fs-iface); letter-spacing:var(--ls-iface); text-transform:uppercase;
  color:var(--ink);
  background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" fill="none" stroke="%237c7c78" stroke-width="1.2"/></svg>');
  background-repeat:no-repeat; background-position:right 12px center;
  cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
}
.book-buy{ display:flex; gap:12px; justify-content:center; flex-wrap:wrap; }
.buy-btn{
  appearance:none; -webkit-appearance:none;
  border:1px solid var(--ink); background:none;
  padding:10px 18px;
  font-size:var(--fs-iface); letter-spacing:var(--ls-iface); text-transform:uppercase;
  color:var(--ink); cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
  transition:background .2s ease, color .2s ease;
}
.buy-btn:hover{ background:var(--ink); color:var(--paper); }
.buy-btn:disabled{ opacity:.5; cursor:default; pointer-events:none; }
.buy-status{
  margin:10px 0 0;
  font-size:var(--fs-meta); color:var(--muted); min-height:1.2em;
}
@media (max-width:720px){
  .book-description, .book-handmade{ max-width:88vw; }
}

/* Video slides — poster shown immediately (the same lazy-loaded <img>
   every photo slide uses), the real .mp4 only ever fetched once a
   visitor actually presses play (see the video-wrap wiring in
   sequence.js). z-index:6 is deliberate: it sits above .stage-zone's own
   z-index:5 below, which otherwise sits on top of everything in
   #slideshow and would swallow clicks meant for the play button. Visual
   language ported from the archived freeform.html/style.css's own
   video-wrap — same quiet translucent circle, same thin progress line,
   no native <video controls> bar.
   Same trap as .slide.gated above, and just as real: .slide's own
   will-change:opacity forces its own stacking context, which traps a
   z-index set only on .video-wrap — it wins locally against sibling
   elements inside .slide, but can never climb high enough to beat
   .stage-zone's z-index:5 at the #stage level, so every tap meant for
   the play button just fell through to goNext() instead. Confirmed by
   clicking the play button in a live page and watching the slideshow
   advance rather than the video play. Fixed the same way: promote
   z-index:6 onto .slide itself whenever it holds a video. */
.slide:has(.video-wrap){ z-index:6; }
.video-wrap{
  position:relative; z-index:6;
  display:flex; width:100%; height:100%;
  cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
}
.video-wrap picture{ pointer-events:none; }
.video-wrap video{ width:100%; height:100%; object-fit:contain; cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer; }
/* Same chevron-hover mechanism as .gate above — the video element has
   its own explicit cursor rule right above, which otherwise wins over
   an inherited cursor:none from the wrap regardless of when the mouse
   is actually over the real (letterboxed-contained) video area. */
.video-wrap.chevron-hover,
.video-wrap.chevron-hover video{ cursor:none; }
.video-play{
  position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
  width:48px; height:48px; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  background:rgba(255,255,255,.5);
  backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px);
  color:var(--ink);
  opacity:.85;
  transition:opacity .2s ease, background .2s ease;
  cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
}
.video-play svg{ width:17px; height:17px; margin-left:2px; }
.video-play:hover{ opacity:1; background:rgba(255,255,255,.75); }
.video-wrap.playing .video-play{ opacity:0; pointer-events:none; }
.video-progress{
  position:absolute; left:0; right:0; bottom:0;
  height:2px; background:rgba(255,255,255,.25);
  pointer-events:none;
}
.video-progress-fill{ height:100%; width:0%; background:rgba(255,255,255,.9); }
/* Corner fullscreen button. Deliberately no background circle (unlike
   .video-play) — a plain white glyph with a soft drop-shadow reads fine
   against any footage, dark or light, without a UI chrome blob sitting
   on top of the image. Stays visible whether paused or playing (unlike
   .video-play, which hides once playing), since it's useful in both
   states; only appears at all once a video's been started (it's created
   alongside the <video> element itself, never sits on the plain poster).
   right/bottom here are just the pre-layout fallback — object-fit:contain
   can letterbox the video well inside .video-wrap's own box (a 16:9 clip
   in a taller portrait-shaped wrap, or the reverse), so anchoring to the
   wrap's own edges alone leaves it floating in that empty letterbox bar
   instead of the actual visible frame. sequence.js's positionFsBtn()
   overrides right/bottom against the video's real intrinsic size once
   metadata loads and on every resize — a real, confirmed bug the first
   version of this had, caught by measuring the rendered button against
   the video's actual content box, not just the wrap's. */
.video-fullscreen{
  position:absolute; right:10px; bottom:14px; z-index:1;
  width:26px; height:26px;
  display:flex; align-items:center; justify-content:center;
  color:#fff;
  filter:drop-shadow(0 1px 3px rgba(0,0,0,.65));
  opacity:.75;
  transition:opacity .2s ease;
  cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
}
.video-fullscreen svg{ width:14px; height:14px; }
.video-fullscreen:hover{ opacity:1; }

#hint{
  position:absolute; left:50%; bottom:28px; transform:translateX(-50%);
  font-size:var(--fs-iface); font-weight:400; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
  text-transform:uppercase;
  color:var(--muted);
  pointer-events:none;
  z-index:6;
  transition:opacity .6s ease;
}
#hint.hidden{ opacity:0; }

/* Home page only (index.html's own markup) — a brief "you can go next"
   nudge on arrival, same chevron mark as the desktop hover-cursor
   further down (.hover-cursor-chevron) so it reads as the same
   affordance, just anchored to a fixed spot at the right edge instead
   of following the mouse — this one has to work with no hover at all,
   touch screens included. Pure CSS animation: fade in, hold, fade out,
   once, timed off page load rather than any click/dismiss logic. */
#introChevron{
  /* fixed, not absolute — #stage only fills the space below #masthead,
     so centering within it (an earlier version) landed noticeably below
     true screen-center. Fixed measures against the actual viewport, so
     this lands dead-center vertically regardless of header height.
     Horizontally: a flat 28px-from-edge (an earlier version) put it at
     the outer edge of the browser window, way past the photo itself on
     any wide screen. Centering it in the empty margin (100vw-W)/4 (the
     version right before this one) was still reported as too far
     right/not visually reading as centered — doubled to (100vw-W)/2,
     which lands it right at the photo's own edge instead of out in the
     middle of empty space past it. #slideshow is capped at
     min(82vw,1200px) and centered, so this scales with it responsively
     at any screen width rather than a fixed pixel value. */
  position:fixed; right:calc((100vw - min(82vw, 1200px)) / 2); top:50%;
  font-size:20px; font-weight:400; line-height:1;
  color:var(--ink);
  opacity:0;
  pointer-events:none;
  z-index:6;
  animation:introChevronNudge 2.6s ease .6s 1 both;
}
@keyframes introChevronNudge{
  0%{ opacity:0; transform:translateY(-50%) translateX(-4px); }
  20%{ opacity:1; transform:translateY(-50%) translateX(0); }
  70%{ opacity:1; transform:translateY(-50%) translateX(0); }
  100%{ opacity:0; transform:translateY(-50%) translateX(4px); }
}
/* Touch devices never see the desktop hover-cursor chevron either (see
   .hover-cursor's own touch media query above) — this one doesn't
   follow the mouse, but the "you can go next" job it does is exactly
   what the swipe/tap affordance already has to be discovered without
   any hint on a touch screen anyway, and there's no good fixed spot
   for it on narrow phone widths where the photo runs edge-to-edge
   (no real margin to sit in). Desktop/mouse only. */
@media (hover:none), (pointer:coarse){
  #introChevron{ display:none !important; }
}
@media (prefers-reduced-motion:reduce){
  #introChevron{ animation:introChevronFadeOnly 2.6s ease .6s 1 both; }
  @keyframes introChevronFadeOnly{
    0%{ opacity:0; }
    20%{ opacity:1; }
    70%{ opacity:1; }
    100%{ opacity:0; }
  }
}

/* Same element, same spot, same font treatment as #hint above (a real
   sibling of #slideshow inside #stage, not nested inside the photo's
   own box, see the .gate .slide-caption comment above for why that
   matters), just for the gated slides' own message instead, shown or
   hidden by sequence.js depending on whether the active slide is one
   of those. Starts at opacity:0 rather than #hint's plain starting
   visible, since this needs to fade IN each time (arriving at a gated
   slide, or toggling between its two messages), not just fade out once
   after a delay. Unlike #hint, this is itself a second tap target (see
   the click/keydown handlers on #gateHint in sequence.js) alongside the
   photo, so pointer-events only turns on with .visible below rather
   than staying off permanently like #hint's own does. */
#gateHint{
  position:absolute; left:50%; bottom:28px; transform:translate(-50%, 0);
  font-size:var(--fs-iface); font-weight:400; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
  text-transform:uppercase;
  color:var(--muted);
  pointer-events:none;
  z-index:6;
  margin:0;
  opacity:0;
}
@keyframes gateHintRise{
  from{ opacity:0; transform:translate(-50%, 14px); }
  to{ opacity:1; transform:translate(-50%, 0); }
}
#gateHint.visible{
  animation:gateHintRise .6s ease forwards;
  pointer-events:auto;
  cursor:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="1"/><circle cx="10" cy="10" r="2.5" fill="black"/></svg>') 10 10, pointer;
}
@media (max-width:720px){
  /* Mobile shows its own in-flow caption (.gate .slide-caption, see
     above) instead — this stays out of the way entirely there. */
  #gateHint{ display:none; }
}

/* Way back out — single-project pages (modibodi.html, verano.html) are
   reached by clicking into a project from a showcase page (fashion.html)
   and otherwise have no way back except the browser's own back button.
   Bottom-left specifically, not centered — per explicit request, so it
   reads as a corner nav control rather than competing with #hint's own
   centered "click to continue". Sits above .stage-zone (z-index:5) so
   the left tap zone underneath it doesn't swallow the click — in
   theory: with zero padding, the actual clickable box was only ever
   the bare glyph itself ("←" alone on mobile, per the text-hiding rule
   below), a handful of pixels wide sitting in the middle of
   .stage-zone's full-height tap half. z-index correctly puts THAT tiny
   box on top, but any tap even slightly off it landed on .stage-zone
   instead and just advanced the slide — reported as not being able to
   tap Back/Details at all on mobile. Padding here expands the real hit
   area outward to a proper touch target; the matching negative
   position offset (22px padding, position pulled in from 22px to 0)
   keeps the arrow sitting at the exact same visual spot it always was. */
.back-link{
  position:fixed; left:0; bottom:0; z-index:20;
  padding:22px;
  font-size:var(--fs-iface); font-weight:400; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
  text-transform:uppercase;
  color:var(--muted);
  transition:color .2s ease;
}
.back-link:hover{ color:var(--ink); }
@media (max-width:720px){
  .back-link{ padding:16px; }
  /* Full "Back to Projects" label restored on mobile — arrow-only read
     as broken/too small and easy to miss next to .details-link's own
     full "DETAILS" text on the opposite corner, a real, confirmed
     complaint. Matches .details-link's mobile treatment (full text,
     no arrow-only variant) for the same reason. */
}

/* Per-project details — bottom-right mirror of .back-link above, opening
   the same right-side panel language as #infoOverlay/#infoPanel just
   below (reusing its .info-* content classes directly rather than a
   parallel set) but scoped to this one project's own credits/tagline/
   intro instead of the site-wide bio. Optional: sequence.js only wires
   this up when the markup is actually present on the page, so pages
   without a #detailsLink/#detailsOverlay are entirely unaffected. */
.details-link{
  position:fixed; right:0; bottom:0; z-index:20;
  padding:22px;
  font-size:var(--fs-iface); font-weight:400; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
  text-transform:uppercase;
  color:var(--muted);
  transition:color .2s ease;
}
.details-link:hover{ color:var(--ink); }
@media (max-width:720px){
  .details-link{ padding:16px; }
}
#detailsOverlay{
  position:fixed; inset:0; z-index:200;
  background:rgba(250,250,248,.7);
  backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
  opacity:0; visibility:hidden; pointer-events:none;
  transition:opacity .3s ease, visibility 0s linear .3s;
}
#detailsOverlay.open{
  opacity:1; visibility:visible; pointer-events:auto;
  transition:opacity .3s ease;
}
#detailsPanel{
  position:absolute; top:22px; right:22px; bottom:22px;
  width:min(420px, calc(100vw - 44px));
  overflow-y:auto;
  background:var(--paper);
  padding:28px 26px;
}
#detailsClose{
  position:absolute; top:14px; right:16px;
  font-size:var(--fs-iface); letter-spacing:var(--ls-iface); text-transform:uppercase;
  color:var(--muted);
}
#detailsClose:hover{ color:var(--ink); }

/* Left/right tap zones — full height, half width each, injected by
   sequence.js. The whole photo stays one continuous clickable surface
   (like the reference site); these just make the click direction-aware.
   No visible icon or arrow at all, by explicit request — the cursor
   itself is the only affordance (a resize-style cursor, the same trick
   used for this exact "click left/right half of an image" pattern on
   plenty of photography sites). Mobile has no cursor and no on-screen
   hint here at all — the one visible navigation control there is the
   menu toggle, top right; visitors are expected to just tap to advance. */
.stage-zone{
  position:absolute; top:0; bottom:0; width:50%;
  z-index:5;
}
.stage-zone--prev{ left:0; cursor:none; }
.stage-zone--next{ right:0; cursor:none; }
/* Replaces the plain resize-cursor affordance: a small chevron
   (sequence.js) follows the mouse over each half instead, built once
   and repositioned on mousemove rather than one element per zone.
   Touch devices never see it — nothing here fires without a real
   mousemove, which touch input never sends. */
.hover-cursor{
  position:fixed; z-index:100; pointer-events:none;
  top:0; left:0;
  opacity:0; transition:opacity .15s ease;
  color:var(--ink);
}
.hover-cursor.is-visible{ opacity:.9; }
.hover-cursor-chevron{ font-size:20px; font-weight:400; line-height:1; }
/* Belt-and-suspenders for the "touch never sends mousemove" assumption
   above — iOS Safari fires synthetic mouseover/mousemove/click on real
   <button> elements (.stage-zone) after a tap, which was leaking the
   chevron onto phones as a stray "‹"/"›" over the photo. Pinning it to
   fine-pointer/hover-capable input kills it regardless of which
   synthetic events a given touch browser decides to send. */
@media (hover:none), (pointer:coarse){
  .hover-cursor{ display:none !important; }
}
/* Overrides the site-wide button:focus-visible outline above — that
   rule is fine for a small discrete control, but .stage-zone is a
   half-viewport-wide, near-full-height invisible tap target, and some
   mobile browsers (Safari in particular) treat a plain tap on a
   <button> as :focus-visible, not just real keyboard tabbing. The
   result was a 2px outline drawn around that ENTIRE half-screen box on
   every single tap — a real, confirmed bug reported as "a box around
   where you're clicking," showing up on every project page, worse on
   mobile. No focus indicator makes sense here regardless of input
   method; the cursor swap above is this control's only affordance by
   design. */
.stage-zone:focus,
.stage-zone:focus-visible{ outline:none; }

/* ---------- info overlay ----------
   Self-contained (no dependency on freeform.js), reusing the same
   boxed-panel language as the exhibition wall's own #contactOverlay:
   a plain card, top-right, no shadow. */
#infoOverlay{
  position:fixed; inset:0; z-index:200;
  background:rgba(250,250,248,.7);
  backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
  opacity:0; visibility:hidden; pointer-events:none;
  transition:opacity .3s ease, visibility 0s linear .3s;
}
#infoOverlay.open{
  opacity:1; visibility:visible; pointer-events:auto;
  transition:opacity .3s ease;
}
#infoPanel{
  position:absolute; top:22px; right:22px; bottom:22px;
  width:min(420px, calc(100vw - 44px));
  overflow-y:auto;
  background:var(--paper);
  padding:28px 26px;
}
#infoClose{
  position:absolute; top:14px; right:16px;
  font-size:var(--fs-iface); letter-spacing:var(--ls-iface); text-transform:uppercase;
  color:var(--muted);
}
#infoClose:hover{ color:var(--ink); }
.info-name{
  margin:0 0 20px;
  font-size:16px; font-weight:500; letter-spacing:.02em; text-transform:uppercase;
  color:var(--ink);
}
/* #infoPanel's own "Eli Samuel" is the site's actual wordmark/logo, not
   a project title like every other .info-name (that's what #detailsPanel
   uses this same class for) — it needs to read as the same mark seen in
   the header, not just another panel heading. A real, confirmed
   mismatch: same class, so it was inheriting the larger/lighter
   project-title treatment above instead of #wordmark's own small bold
   tightly-tracked one. */
#infoPanel .info-name{
  font-size:var(--fs-iface); font-weight:700; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
}
.info-kicker{
  margin:0 0 8px;
  font-size:var(--fs-iface); font-weight:700; letter-spacing:var(--ls-iface); line-height:var(--lh-iface);
  text-transform:uppercase;
  color:var(--muted);
}
.info-kicker--about{ margin-top:28px; }
.info-meta{
  margin:0 0 18px;
  font-size:var(--fs-meta); line-height:var(--lh-meta);
  color:var(--ink);
}
.info-credit-row{
  display:flex; justify-content:space-between; gap:12px;
  padding:8px 0;
  border-top:1px solid var(--line);
  font-size:var(--fs-meta); line-height:var(--lh-meta);
}
.info-credit-row .lbl{ color:var(--muted); }
.info-credit-row .val a:hover{ color:var(--muted); }
.info-tagline{
  margin:0 0 12px;
  font-size:var(--fs-body); line-height:var(--lh-body);
  color:var(--ink);
}
.info-intro{
  margin:0;
  font-size:var(--fs-meta); line-height:var(--lh-meta);
  color:var(--muted);
  white-space:pre-line;
}
/* Plain stacked lines, no per-item divider/weight — matches the
   Books section's own Stockist line (.info-credit-row .val), not a
   bordered list. The earlier per-<li> border-top read as an underline
   under each name once they were this close together; removed. */
.info-clients{
  margin:0; padding:0; list-style:none;
  font-size:var(--fs-meta); line-height:var(--lh-meta);
  color:var(--ink);
}
.info-clients li{ padding:2px 0; }
