/* ============================================================================
   Anand Jain — portfolio
   Screenplay-format layout. Left column = the script. Right column = the reel.

   TYPE SIZING NOTE (this is the important bit):
   The reference site (vikpat.com) sets its ROOT font-size to a fluid ~0.81vw,
   so on a 1280px screen the base type is only ~10.4px and everything is tight.
   We copy that here. Because every size and gap below is written in `rem`
   (relative to the root), shrinking the root shrinks the whole design in
   proportion. On phones the root switches back to a readable 16px.
   ========================================================================= */

/* ---- Fonts, self-hosted -------------------------------------------------
   These used to come from Google Fonts, which put a third-party origin
   (DNS + TLS + a render-blocking stylesheet fetch) on the critical path of
   every cold visit — Lighthouse measured it at ~900ms on a cold phone.
   The same woff2 files now live in fonts/ and are declared here; the two
   that carry the first paint are preloaded from index.html. display:swap
   keeps text visible in fallback while they arrive. Latin subset — covers
   everything the site sets, incl. curly quotes and em-dashes; the ↗ arrow
   always came from the system font. */
@font-face {
  font-family: "Archivo";
  src: url("fonts/archivo.woff2") format("woff2");
  font-weight: 300 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Archivo";
  src: url("fonts/archivo-italic.woff2") format("woff2");
  font-weight: 300 700; font-style: italic; font-display: swap;
}
@font-face {
  font-family: "Courier Prime";
  src: url("fonts/courier-prime.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Courier Prime";
  src: url("fonts/courier-prime-bold.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Courier Prime";
  src: url("fonts/courier-prime-italic.woff2") format("woff2");
  font-weight: 400; font-style: italic; font-display: swap;
}

:root {
  /* Colour — deliberately monochrome. The work supplies the colour. */
  --paper:      #FBFAF7;   /* warm off-white, like script paper */
  --ink:        #14130F;   /* near-black */
  --ink-soft:   #5A5852;   /* secondary text */
  --ink-faint:  #6B6862;   /* metadata — 5.3:1 on paper, 4.6:1 on highlight (AA) */
  --rule:       #DFDCD4;   /* hairlines */
  --highlight:  #EDEAE1;   /* selected row */

  /* Type */
  --mono: "Courier Prime", "Courier New", Courier, monospace;
  --sans: "Archivo", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;

  /* Spacing scale — in rem, so it scales with the root font-size. */
  --s-1: 0.5rem;
  --s-2: 0.875rem;
  --s-3: 1.25rem;
  --s-4: 2rem;
  --s-5: 3.25rem;
  --s-6: 5rem;

  --measure: 66ch;   /* readable line length */

  /* The fixed top bar's full block height: a 44px touch row plus its own
     padding. Every pane that scrolls under the bar clears itself by this,
     so no content can ever start beneath the controls. */
  --topbar-h: calc(44px + var(--s-2) * 2);

  /* Motion tokens. One family of curves so everything moves with the same
     accent: --ease-out for entrances, --ease-in for exits. */
  --dur: 200ms;
  --dur-1: 150ms;    /* hover, press, small state changes   */
  --dur-2: 240ms;    /* content entrances                   */
  --dur-3: 400ms;    /* the largest moves (pane slide)      */
  --ease-out: cubic-bezier(0.2, 0.7, 0.3, 1);
  --ease-in:  cubic-bezier(0.5, 0, 0.8, 0.4);
}

/* ---- Dark palette ----
   Applied when the OS is in dark mode (unless the visitor has forced light
   with the toggle), and whenever the visitor explicitly chooses dark.
   `:root[data-theme=...]` beats the media query, so the toggle always wins. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper:     #131211;
    --ink:       #F2F0EA;
    --ink-soft:  #A9A59C;
    --ink-faint: #8C8880;   /* 5.3:1 on paper, 4.7:1 on highlight (AA) */
    --rule:      #2E2C28;
    --highlight: #201E1B;
  }
}
:root[data-theme="dark"] {
  --paper:     #131211;
  --ink:       #F2F0EA;
  --ink-soft:  #A9A59C;
  --ink-faint: #8C8880;     /* 5.3:1 on paper, 4.7:1 on highlight (AA) */
  --rule:      #2E2C28;
  --highlight: #201E1B;
}

*, *::before, *::after { box-sizing: border-box; }

/* The `hidden` attribute must always win, even on elements we give a
   `display` value (otherwise `display:flex` etc. silently un-hides them). */
[hidden] { display: none !important; }

/* Interactive elements: no 300ms tap delay, no grey iOS tap flash — the
   :active states below provide the press feedback instead. */
a, button {
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}

/* Selecting text keeps the script's ink-on-paper logic, inverted. */
::selection { background: var(--ink); color: var(--paper); }

/* The one moment colours are allowed to transition: the theme toggle adds
   .theme-anim to <html> for 400ms, then removes it. */
html.theme-anim body,
html.theme-anim .slate__link,
html.theme-anim .slug,
html.theme-anim .player,
html.theme-anim .theme-toggle,
html.theme-anim .colophon a {
  transition: background-color var(--dur-3) ease, color var(--dur-3) ease,
              border-color var(--dur-3) ease;
}

/* --- The root font-size trick, copied from the reference ----------------
   Phones: a comfortable 16px.
   Tablet and up: fluid ~0.84vw (≈10.5px at 1280px), matching vikpat.com,
   with a floor and ceiling so it never gets unreadable or oversized.
   NOTE: media-query `rem` always uses 16px, so the breakpoints below are
   unaffected by this override.
   ---------------------------------------------------------------------- */
html {
  /* Phones and small tablets. Everything else on the page is in rem, so
     this one number sets the whole scale below 48rem — see the fluid
     clamp() further down for wider screens. 14px rather than the browser
     default 16: at 16 the index read oversized against the desktop
     proportions, and four group names would not fit the reel's menu row. */
  font-size: 14px;
  -webkit-text-size-adjust: 100%;
}
@media (min-width: 48rem) {
  html { font-size: clamp(10.5px, 0.84vw, 12.5px); }
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 1.02rem;
  line-height: 1.6;
  font-synthesis: none;
  -webkit-font-smoothing: antialiased;
}

a { color: inherit; }

/* While the lightbox is open, nothing behind it should scroll — the
   document (mobile) and the reel's own internal scroller (desktop). */
html.lightbox-open,
html.lightbox-open body,
html.lightbox-open .reel {
  overflow: hidden;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--ink);
  color: var(--paper);
  padding: var(--s-2);
  z-index: 100;
}
.skip-link:focus { left: 0; }

/* Focus is visible everywhere — never removed. */
a:focus-visible,
button:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
  border-radius: 1px;
}

/* ============================================================================
   TOP BAR
   One fixed row holding the signature and the two controls. It is invisible
   at rest — at the top of the page the hero owns the screen and the bar is
   just two floating pills. Once the hero signature has scrolled up past it,
   `is-condensed` turns the bar into a real surface (paper + blur + hairline)
   and the signature reappears inside it. That surface is the fix for the
   controls colliding with content: text now passes *under* a defined edge
   instead of behind two floating circles.

   Desktop: the bar spans the index column only (the same 54rem grid track),
   so it never reaches across the divider into the reel.
   ========================================================================= */

.topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 50;
  /* Three explicit slots: control, signature, control. The columns are
     assigned by hand rather than auto-placed, so that where masks aren't
     supported and the middle button is display:none, the two controls stay
     pinned to their own edges instead of sliding inward. */
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2);
  min-height: var(--topbar-h);
  /* Transparent at rest, so it must not swallow clicks meant for the page.
     Once condensed it is an opaque surface and takes its own hits back. */
  pointer-events: none;
}
.topbar::after {
  /* The surface itself, as a pseudo-element: fading a background on the bar
     would fade the buttons with it. This paints behind them instead. */
  content: "";
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--paper) 82%, transparent);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  opacity: 0;
}
.topbar::before {
  /* The hairline, separate from the surface so it can draw itself in from
     the centre outward as the signature docks — the same gesture as the
     title page's underline at boot. */
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--rule);
  transform: scaleX(0);
}
/* Above the surface: ::after is the bar's last child, so without this it
   paints over the buttons and eats every click once the bar is condensed. */
.topbar > * { position: relative; z-index: 1; pointer-events: auto; }

/* The controls keep the places they hold on the home screen — one at each
   end of the index column — and the signature sits centred between them. */
.topbar .theme-toggle { grid-column: 1; justify-self: start; }
.topbar__home         { grid-column: 2; justify-self: center; }
.topbar .audio-toggle { grid-column: 3; justify-self: end; }

/* The signature, once it has been handed over from the hero. Hidden unless
   masks are supported (see the @supports block by .titlepage__mark) — the
   hero's own text wordmark carries the name in that case. */
.topbar__home {
  display: none;
  align-items: center;
  min-height: 44px;         /* full touch target; the mark inside is smaller */
  padding: 0 var(--s-1);
  background: none;
  border: 0;
  cursor: pointer;
  border-radius: 4px;
}
.topbar__home:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

html.is-condensed .topbar { pointer-events: auto; }
html.is-condensed .topbar::after { opacity: 1; }
html.is-condensed:not(.has-handoff) .topbar::before { transform: scaleX(1); }

/* Scrub path (html.has-handoff, set by the flight controller in app.js):
   the surface and hairline stop being binary states and become functions of
   --hp, the scroll-driven handoff progress the controller writes every
   frame. The frost develops through the second half of the flight; the
   hairline draws outward from under the signature just before it docks.
   These sit after the is-condensed rules on purpose — same specificity,
   later wins, so while the flight is armed the scrub is the authority. */
html.has-handoff .topbar::after {
  /* --sp, not --hp: the surface is on a pixel-anchored schedule of its own
     (see measure() in app.js). It must be solid before page content can
     slide beneath it, which is a scroll distance, not a share of the
     signature's journey. */
  opacity: clamp(0, var(--sp, 0), 1);
}
html.has-handoff .topbar::before {
  transform: scaleX(clamp(0, calc((var(--hp, 0) - 0.35) / 0.6), 1));
}

@media (min-width: 62rem) {
  /* Exactly the index column: minmax(0, 54rem) resolves to 54rem at every
     width where this query applies, so the bar ends on the divider. */
  .topbar { right: auto; width: 54rem; }
}

@media (prefers-reduced-motion: no-preference) {
  /* Fallback path only: under the scrub, transitions would lag the direct
     per-frame writes and smear the surface behind the scroll. */
  html:not(.has-handoff) .topbar::after { transition: opacity var(--dur-2) var(--ease-out); }
  html:not(.has-handoff) .topbar::before { transition: transform var(--dur-2) var(--ease-out); }
}

/* Light / dark toggle. */
.theme-toggle {
  position: relative;       /* the stacked icons below are absolute to this */
  flex: none;
  width: 40px;
  height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: color-mix(in srgb, var(--paper) 78%, transparent);
  border: 1px solid var(--rule);
  border-radius: 50%;
  color: var(--ink-soft);
  cursor: pointer;
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  transition: color var(--dur) ease, border-color var(--dur) ease;
}
.theme-toggle:hover,
.theme-toggle:focus-visible { color: var(--ink); border-color: var(--ink-faint); }
.theme-toggle:active { transform: scale(0.92); transition-duration: 60ms; }

/* Sun and moon are stacked; flipping the theme cross-morphs them — the
   outgoing icon rotates away as the incoming one rotates into place.
   Resting states are plain opacity/transform, so under reduced motion the
   swap is simply instant. */
.theme-toggle svg { position: absolute; width: 17px; height: 17px; }
.theme-toggle .icon-sun  { opacity: 0; transform: rotate(-90deg) scale(0.55); }
.theme-toggle .icon-moon { opacity: 1; }
.theme-toggle.is-dark .icon-sun  { opacity: 1; transform: none; }
.theme-toggle.is-dark .icon-moon { opacity: 0; transform: rotate(90deg) scale(0.55); }
@media (prefers-reduced-motion: no-preference) {
  .theme-toggle { transition: color var(--dur-1) ease, border-color var(--dur-1) ease,
                              transform var(--dur-1) var(--ease-out); }
  .theme-toggle svg { transition: opacity var(--dur-2) var(--ease-out),
                                  transform var(--dur-3) var(--ease-out); }
}

/* Music toggle — the theme toggle's twin, beside it in the bar. Everything
   visual is inherited from .theme-toggle; only the icon swap differs. */
.audio-toggle {
  position: relative;       /* the stacked icons below are absolute to this */
  flex: none;
  width: 40px;
  height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: color-mix(in srgb, var(--paper) 78%, transparent);
  border: 1px solid var(--rule);
  border-radius: 50%;
  color: var(--ink-soft);
  cursor: pointer;
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  transition: color var(--dur) ease, border-color var(--dur) ease;
}
.audio-toggle:hover,
.audio-toggle:focus-visible { color: var(--ink); border-color: var(--ink-faint); }
.audio-toggle:active { transform: scale(0.92); transition-duration: 60ms; }
/* Playing: the icon takes full ink, so the state is legible at a glance
   and not by colour alone — the waves themselves change. */
.audio-toggle.is-on { color: var(--ink); border-color: var(--ink-faint); }

.audio-toggle svg { position: absolute; width: 17px; height: 17px; }
.audio-toggle .icon-sound-on  { opacity: 0; transform: scale(0.6); }
.audio-toggle .icon-sound-off { opacity: 1; }
.audio-toggle.is-on .icon-sound-on  { opacity: 1; transform: none; }
.audio-toggle.is-on .icon-sound-off { opacity: 0; transform: scale(0.6); }
@media (prefers-reduced-motion: no-preference) {
  .audio-toggle { transition: color var(--dur-1) ease, border-color var(--dur-1) ease,
                              transform var(--dur-1) var(--ease-out); }
  .audio-toggle svg { transition: opacity var(--dur-2) var(--ease-out),
                                  transform var(--dur-2) var(--ease-out); }
  /* Between the press and the first note there is a 4.9MB download; the
     button breathes so the wait reads as loading, not as a dead control. */
  .audio-toggle.is-loading { animation: slate-pulse 1.2s ease-in-out infinite; }
}

/* ============================================================================
   LAYOUT
   Mobile first: one column. Desktop: two columns split by a hairline.
   ========================================================================= */

.stage { min-height: 100dvh; }

.script {
  padding: calc(var(--topbar-h) + var(--s-2)) var(--s-3) var(--s-6);
}
.script__inner {
  max-width: 40rem;
  margin: 0 auto;          /* mobile: centred index */
}

.reel {
  display: none;             /* mobile: hidden until a title is opened */
  --reel-pad: var(--s-3);    /* the pane's own gutter — .reelnav spans it */
}

/* ---- Mobile / tablet (< 62rem): tap a title → full-screen detail ---- */
@media (max-width: 61.999rem) {
  body.is-detail-open .script { display: none; }
  body.is-detail-open .reel {
    display: block;
    /* Exactly the height of the fixed top bar, so the menu bar below starts
       where it will stick and does not jolt on the first pixel of scroll. */
    padding: var(--topbar-h) var(--reel-pad) var(--s-6);
  }
  /* Opening slides the detail in from the right — it explains where the
     content came from. Going back is instant: exits should be faster. */
  @media (prefers-reduced-motion: no-preference) {
    body.is-detail-open .reel {
      animation: pane-in var(--dur-3) var(--ease-out) both;
    }
  }
}
@keyframes pane-in {
  from { opacity: 0.4; transform: translateX(28px); }
  to   { opacity: 1;   transform: none; }
}

/* The detail arrives FROM somewhere. Desktop: the content steps in from
   the index side — meta, then title, then the film, a beat apart — so a
   swap reads as handed over from the left pane, not cut in. Mobile: the
   whole pane already slides in from the right; the children hold still
   inside it, so each platform has exactly one motion, pointed the right
   way. Transform and opacity only — everything lands where it always was. */
   The menu bar is deliberately left out of it: it is sticky, and a sticky
   element mid-animation is a sticky element in the wrong place. */
@media (prefers-reduced-motion: no-preference) and (min-width: 62rem) {
  .detail.is-entering .panel:first-child > * {
    animation: hand-in var(--dur-2) var(--ease-out) both;
  }
  .detail.is-entering .panel:first-child > :nth-child(2) { animation-delay: 40ms; }
  .detail.is-entering .panel:first-child > :nth-child(3) { animation-delay: 80ms; }
  .detail.is-entering .panel:first-child > :nth-child(4) { animation-delay: 110ms; }
  .detail.is-entering .panel:first-child > :nth-child(n+5) { animation-delay: 140ms; }
}
@keyframes hand-in {
  from { opacity: 0; transform: translateX(-14px); }
  to   { opacity: 1; transform: none; }
}

/* ---- Desktop (>= 62rem): TWO STATIC PANES ----
   Left  = the index. Left-aligned, fixed in place, never changes.
   Right = the selected film. Sticky; only this side updates on click. */
@media (min-width: 62rem) {
  .stage {
    display: grid;
    /* The index caps its content at 48rem (+2rem padding each side = 52rem),
       so a 50/50 split left a growing band of dead space beside it on wide
       screens. Cap the left column just past the content (2rem to breathe)
       and let the reel take all the surplus: the divider sits closer to the
       left and the film/gallery pane — where the work is judged — gets the
       room. In rem, so it tracks the fluid type instead of fighting it. */
    grid-template-columns: minmax(0, 54rem) minmax(0, 1fr);
  }
  .script {
    border-right: 1px solid var(--rule);
    padding: calc(var(--topbar-h) + var(--s-3)) var(--s-4) var(--s-6);
  }
  .script__inner {
    max-width: 48rem;
    margin: 0;             /* left-aligned & static — never auto, never shifts */
  }
  .reel {
    display: block;        /* always present on desktop */
    position: sticky;
    top: 0;
    align-self: start;
    height: 100dvh;
    overflow-y: auto;
    --reel-pad: var(--s-4);
    /* No top padding: the menu bar is the first thing in the pane and sticks
       flush to the top of it. The panels carry their own breathing room. */
    padding: 0 var(--reel-pad) var(--s-6);
    scrollbar-width: thin;
    scrollbar-color: var(--rule) transparent;
  }
}

/* ============================================================================
   THE SCRIPT — real screenplay conventions
   ========================================================================= */

/* Title page: centred, mono, spaced out. */
.titlepage {
  text-align: center;
  margin: var(--s-3) 0 var(--s-6);
}
.titlepage__name {
  font-family: var(--mono);
  font-size: 1.25rem;
  font-weight: 700;
  letter-spacing: 0.22em;
  margin: 0 0 var(--s-2);
  text-transform: uppercase;
}
.titlepage__by {
  font-family: var(--mono);
  font-size: 0.92rem;
  letter-spacing: 0.12em;
  color: var(--ink-soft);
  margin: 0;
  text-transform: uppercase;
}

/* --- The signature ------------------------------------------------------
   The logo ships as an alpha-only silhouette and is painted with a CSS
   mask, so the ink is var(--ink): near-black on paper, off-white in dark
   mode, from one file. aspect-ratio matches the asset exactly (4:1), so
   the box is reserved before the image arrives — no layout shift.

   Two elements, only ever one visible: the mask, and the plain text
   wordmark that screen readers announce and that shows verbatim if the
   browser has no mask support. The @supports block below does the swap,
   so the text is the default and the logo is the enhancement. */
.titlepage__mark { display: none; }

@supports ((-webkit-mask-image: url("logo/aj-logo.webp")) or (mask-image: url("logo/aj-logo.webp"))) {
  .titlepage__mark {
    display: block;
    position: relative;          /* anchors the boot wipe below */
    width: min(16rem, 72vw);
    aspect-ratio: 4 / 1;
    margin: 0 auto;
    background: var(--ink);
    -webkit-mask: url("logo/aj-logo.webp") center / contain no-repeat;
    mask: url("logo/aj-logo.webp") center / contain no-repeat;
  }
  /* The bar's copy of the signature. Same mask file as the hero — one
     request, one asset — at nav scale. It is present in the layout from the
     start (so revealing it can never shift the bar), just not yet visible. */
  .topbar__home { display: inline-flex; }
  .topbar__mark {
    display: block;
    /* px, deliberately — and the one place on this site where that is right.
       The bar is a fixed 44px touch row that does not ride the fluid root
       size, so a rem-sized mark inside it would render 56px on desktop and
       83px on a phone. 76px keeps the signature the same weight in the bar
       everywhere: 19px tall in a 44px row. */
    width: 76px;
    aspect-ratio: 4 / 1;
    background: var(--ink);
    -webkit-mask: url("logo/aj-logo.webp") center / contain no-repeat;
    mask: url("logo/aj-logo.webp") center / contain no-repeat;
    opacity: 0;
  }

  /* The travelling signature — the element the flight controller (app.js)
     steers between the hero and the bar. A third use of the same mask file
     (still one request; the browser reuses it), created at arm time and
     shown only while 0 < progress < 1. It lives at body level so no
     ancestor transform or overflow can trap it, and is transform-only per
     frame. The shadow underneath is what sells the lift: ink casting onto
     the paper it just left, gone again the moment it is pressed into the
     bar. Plain black on purpose — in dark mode a shadow on near-black
     paper simply doesn't read, which is physically honest. */
  .handoff {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 51;              /* over the bar's surface; under skip-link and lightbox */
    pointer-events: none;
    will-change: transform;
  }
  .handoff__mark {
    display: block;
    width: 100%;
    height: 100%;
    background: var(--ink);
    -webkit-mask: url("logo/aj-logo.webp") center / contain no-repeat;
    mask: url("logo/aj-logo.webp") center / contain no-repeat;
  }
  .handoff__shadow {
    position: absolute;
    left: 6%;
    right: 6%;
    bottom: -16%;
    height: 30%;
    border-radius: 50%;
    background: radial-gradient(50% 50% at 50% 50%, rgb(0 0 0 / 0.3), rgb(0 0 0 / 0) 72%);
    opacity: 0;
  }

  /* Still announced, never seen. */
  .titlepage__wordmark {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

/* --- The signature's handoff to the bar ---------------------------------
   Two motion paths share these elements; only ever one is live.

   SCRUB (html.has-handoff — the default): the flight controller in app.js
   flies a single travelling copy of the signature from the hero to the bar,
   its pose a pure function of scroll position. The hero and bar marks are
   then driven by inline styles (visible only at the endpoints), and the
   rules below are gated off so no transition fights the per-frame writes.

   FALLBACK (:not(.has-handoff) — reduced motion, no mask support, no rAF):
   the original class-driven exchange. The hero dissolves as it passes the
   bar and the bar's copy settles in, on `is-condensed` from the
   IntersectionObserver. Reduced motion gets no transition and no offset:
   the bar mark is simply there once you have scrolled past the hero. */
@media (prefers-reduced-motion: no-preference) {
  html:not(.has-handoff) .topbar__mark {
    /* Arrives from below and settles up — the same direction the hero mark
       was travelling as it reached the bar, so the two read as one move. */
    transform: translateY(6px) scale(0.94);
    transition: opacity 260ms var(--ease-out), transform 380ms var(--ease-out);
  }
  html.is-condensed:not(.has-handoff) .topbar__mark { transform: none; }

  /* The hero mark clears out just ahead of the arrival — quicker than the
     bar mark settles, so the eye is handed forward rather than shown both. */
  html:not(.has-handoff) .titlepage__mark { transition: opacity 180ms var(--ease-out); }
  html.is-condensed:not(.has-handoff) .titlepage__mark { opacity: 0; }

  /* Scrub path only: the moment the flight docks, the bar mark takes over
     in the identical pose and gives one small press — pushed in slightly,
     a touch past, still. The settling of a stamp, not a bounce. */
  html.has-handoff .topbar__mark.is-docking {
    animation: dock-settle 360ms var(--ease-out);
  }
}
@keyframes dock-settle {
  0%   { transform: none; }
  35%  { transform: scale(0.97); }
  72%  { transform: scale(1.012); }
  100% { transform: none; }
}

html.is-condensed:not(.has-handoff) .topbar__mark { opacity: 1; }

/* A short centred rule closes the title card — the one em-dash of the page. */
.titlepage::after {
  content: "";
  display: block;
  width: 4rem;
  height: 1px;
  background: var(--rule);
  margin: var(--s-3) auto 0;
}

/* ============================================================================
   ENTRANCE — played once per visit, then disarmed by app.js.
   Pure opacity/transform; wrapped so reduced-motion never sees any of it.
   ========================================================================= */
@media (prefers-reduced-motion: no-preference) {
  html.is-boot .script__inner > * {
    animation: rise-in var(--dur-3) var(--ease-out) both;
  }
  /* The script settles in top to bottom, one beat apart — but it waits
     for the title card to have its moment first. The name starts typing
     at 60ms and is mostly set by ~450ms; the body begins at 300ms so the
     card clearly leads and the page follows, rather than everything
     fading up at once around it. */
  html.is-boot .script__inner > :nth-child(2) { animation-delay: 300ms; }
  html.is-boot .script__inner > :nth-child(3) { animation-delay: 360ms; }
  html.is-boot .script__inner > :nth-child(4) { animation-delay: 420ms; }
  html.is-boot .script__inner > :nth-child(5) { animation-delay: 470ms; }
  html.is-boot .script__inner > :nth-child(6) { animation-delay: 510ms; }
  html.is-boot .script__inner > :nth-child(7) { animation-delay: 550ms; }
  html.is-boot .script__inner > :nth-child(n+8) { animation-delay: 590ms; }
  /* The reel arrives with the same hand-in grammar as every later swap:
     from the index side, after the script is clearly under way. */
  html.is-boot .reel { animation: hand-in 600ms var(--ease-out) 470ms both; }

  /* The title card doesn't rise as a block — it signs itself. A band of
     paper sits over the signature and slides off to the right, so the ink
     appears left to right at writing pace; the byline follows, then the
     rule draws closed. The whole card is done inside ~1s and app.js
     disarms is-boot at 1150ms, so nothing is ever cut off mid-flight.

     The cover is a transform on a paper-coloured block — no layout, no
     opacity on the mark itself — which also means it hides the signature
     during the only window where the mask file could still be in flight.
     It exists only while is-boot is set, so it can never strand a band of
     paper over the logo. */
  html.is-boot .script__inner > :nth-child(1) { animation: none; }
  html.is-boot .titlepage__mark::after {
    content: "";
    position: absolute;
    inset: 0;
    background: var(--paper);
    animation: sign-in 620ms var(--ease-out) 80ms both;
  }
  html.is-boot .titlepage__by { animation: fade-in 500ms var(--ease-out) 430ms both; }
  html.is-boot .titlepage::after { animation: draw-in 500ms var(--ease-out) 540ms both; }
}
@keyframes rise-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes sign-in {
  from { transform: translateX(0); }
  to   { transform: translateX(101%); }
}
@keyframes draw-in {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.block { margin-bottom: var(--s-5); }
.block--end { margin-bottom: var(--s-4); }

/* Action lines — the prose of a screenplay. Every paragraph, including the
   opening line, reads at the same body size and rhythm. */
.action p {
  margin: 0 0 var(--s-2);
  max-width: var(--measure);
}

.quote {
  font-family: var(--mono);
  font-style: normal;          /* upright — the aside is set plainly, not in italic */
  font-size: 1rem;
  line-height: 1.65;
  color: var(--ink);
  margin: var(--s-4) 0;
  padding-left: var(--s-2);
  border-left: 2px solid var(--rule);
  max-width: var(--measure);
}

/* Attribution under the pull quote — the speaker's name, set plain (not in
   the quote's italic hand) and quiet, so the line reads as a credit. */
.quote__by {
  display: block;
  font-style: normal;
  font-size: 0.85rem;
  letter-spacing: 0.04em;
  color: var(--ink-faint);
  margin-top: var(--s-1);
}
.quote__by::before { content: "— "; }

.note {
  font-size: 0.95rem;
  color: var(--ink-faint);
  max-width: var(--measure);
  margin: var(--s-3) 0 0;
}

/* Slug line — the scene heading. Bold mono.
   On mobile it's a full-width header above the list. */
.slug {
  font-family: var(--mono);
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  margin: 0 0 var(--s-2);
  padding-bottom: var(--s-1);
  border-bottom: 1px solid var(--rule);
  color: var(--ink);
}

/* Mobile: the slug's underline draws itself in as the section scrolls into
   view — the border carries the line when motion is off or the observer
   never fires (the failsafe mirrors the gallery reveals'). Desktop removes
   the border entirely, so none of this applies there. */
@media (max-width: 61.999rem) and (prefers-reduced-motion: no-preference) {
  .slug { position: relative; border-bottom-color: transparent; }
  .slug::after {
    content: "";
    position: absolute;
    left: 0; right: 0; bottom: -1px;
    height: 1px;
    background: var(--rule);
    transform: scaleX(0);
    transform-origin: left;
    animation: slug-failsafe 1ms linear 2.5s forwards;
  }
  .slug.in-view::after {
    transform: none;
    animation: none;
    transition: transform 650ms var(--ease-out);
  }
}
@keyframes slug-failsafe { to { transform: scaleX(1); } }

/* A band is one group inside a section: its name in the left rail, its
   titles in the column beside it. A section that isn't grouped renders a
   single band with an empty rail — so titles start at the same tab stop
   whether or not the section has groups above them. */
.band + .band { margin-top: var(--s-3); }

.band__label {
  font-family: var(--mono);
  font-size: 0.95rem;
  font-weight: 400;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 var(--s-1);
}
.band__label:empty { display: none; }

/* Desktop: a work section becomes a screenplay hierarchy — the section
   heading owns its own line at the left margin, then each group's name
   stays in the rail with the titles indented beside it into a consistent
   tab-stop column, genre right-aligned. */
@media (min-width: 62rem) {
  .block--work .slug {
    border-bottom: 0;       /* hierarchy comes from the indent, not a rule */
    margin-bottom: var(--s-2);
  }
  .band {
    display: grid;
    grid-template-columns: minmax(6rem, 12rem) minmax(0, 1fr);
    column-gap: var(--s-3);
    align-items: start;
  }
  .band__label {
    margin: 0;
    padding: 0.4rem 0 0;    /* line the group name up with its first title */
  }
  /* An empty rail must still hold the column open, so an ungrouped section's
     titles line up with a grouped one's. :empty would collapse it. */
  .band__label:empty { display: block; }
  .band .slate { min-width: 0; }
  .band + .band { margin-top: var(--s-2); }
}

/* The BOOKMARKS view in the right pane. */
.bm__intro {
  color: var(--ink-soft);
  margin: 0 0 var(--s-4);
  max-width: var(--measure);
}
.bookmarks {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}
.bm__label {
  font-family: var(--mono);
  font-size: 0.9rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin: 0 0 var(--s-2);
  padding-bottom: var(--s-1);
  border-bottom: 1px solid var(--rule);
  color: var(--ink);
}
.bm__label a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-underline-offset: 3px;
}
.bm__label a:hover,
.bm__label a:focus-visible { text-decoration-color: var(--ink); }
.bm__list { list-style: none; margin: 0; padding: 0; }
.bm__list li {
  padding: 0.3rem 0;
  color: var(--ink-soft);
  max-width: var(--measure);
}
.bm__list li a {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-underline-offset: 3px;
}
.bm__list li a:hover,
.bm__list li a:focus-visible { text-decoration-color: var(--ink); }

/* Underline colour eases in rather than snapping, on every text link. */
.bm__label a, .bm__list li a, .colophon a, .detail__links a {
  transition: text-decoration-color var(--dur-1) ease;
}

/* The closing line, near the bottom of the page. */
.close {
  font-size: 1.02rem;
  color: var(--ink);
  text-align: left;
  max-width: var(--measure);
  margin: var(--s-2) 0 var(--s-5);
}
.close:empty { display: none; }

/* The list of works under each slug line. */
.slate { list-style: none; margin: 0; padding: 0; }

.slate__row { border-bottom: 1px solid transparent; }

.slate__link {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: 0 var(--s-2);
  align-items: baseline;
  justify-content: space-between;
  /* The left gutter is constant — it is the caret's reserved column. Every
     state change on a row is paint or transform only; the text never moves
     because of a hover or a selection. */
  padding: 0.4rem var(--s-1) 0.4rem 1.2rem;
  text-decoration: none;
  color: var(--ink);
  cursor: pointer;
  transition: background var(--dur-1) var(--ease-out);
}
/* Enforce the 44px minimum tap target only where a finger is used. */
@media (pointer: coarse) {
  .slate__link, .contact a { min-height: 44px; }
}
.slate__link:hover { background: var(--highlight); }
.slate__link:focus-visible { background: var(--highlight); }
/* Press: the row darkens a step beyond hover, so a tap visibly lands. */
.slate__link:active { background: var(--rule); transition-duration: 40ms; }

.slate__row.is-active .slate__link { background: var(--highlight); }

/* Not colour alone — the active row is also marked with a caret. It lives
   in the gutter and slides into place; nothing else moves. */
.slate__link::before {
  content: "▸";
  position: absolute;
  left: var(--s-1);
  top: 0.4rem;
  line-height: inherit;
  color: var(--ink-soft);
  opacity: 0;
  transform: translateX(-0.35em);
}
.slate__row.is-active .slate__link::before { opacity: 1; transform: none; }

.slate__title {
  font-size: 1.02rem;
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-underline-offset: 3px;
  transition: text-decoration-color var(--dur-1) ease;
}
.slate__link:hover .slate__title,
.slate__link:focus-visible .slate__title { text-decoration-color: var(--ink); }

/* On hover the title takes one small step toward the caret — transform
   only, so the row's layout is untouched. */
@media (prefers-reduced-motion: no-preference) {
  .slate__link::before {
    transition: opacity var(--dur-1) var(--ease-out),
                transform var(--dur-1) var(--ease-out);
  }
  .slate__title { transition: transform var(--dur-1) var(--ease-out),
                              text-decoration-color var(--dur-1) ease; }
  .slate__link:hover .slate__title,
  .slate__link:focus-visible .slate__title { transform: translateX(0.3rem); }
}

.slate__meta {
  font-size: 0.95rem;
  color: var(--ink-faint);
  font-family: var(--mono);
  white-space: nowrap;
}
/* Phones: the genre always sits on its own line under the title. At
   narrow widths flex-wrap made this depend on title length — short
   titles kept the genre beside them, long ones pushed it below, and the
   two patterns alternated down the list. One committed rule instead. */
@media (max-width: 47.999rem) {
  .slate__meta { flex-basis: 100%; }
}

/* Contact footer (colophon) — a basic row at the very bottom:
   EMAIL on the left, social links on the right. */
.colophon {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s-2) var(--s-3);
  margin-top: var(--s-5);
}
.colophon a {
  font-family: var(--mono);
  font-size: 0.9rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: var(--rule);
  text-underline-offset: 3px;
  padding: 0.35rem 0;
}
.colophon a:hover,
.colophon a:focus-visible { text-decoration-color: var(--ink); }
/* inline-block so the press cue below (a transform) can apply. */
.colophon a { display: inline-block; }
.colophon a:active,
.detail__links a:active { transform: translateY(1px); }
.colophon__social {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-4);
}
@media (pointer: coarse) {
  .colophon a { min-height: 44px; display: inline-flex; align-items: center; }
}

/* ============================================================================
   THE REEL (right pane)
   ========================================================================= */

.reel__idle {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Returning to the empty state (Escape, or clearing the hash) fades the
   hint up instead of snapping it in — the theatre going quiet. */
@media (prefers-reduced-motion: no-preference) {
  .reel__idle:not([hidden]) { animation: fade-in 500ms var(--ease-out); }
}
.reel__idlehint {
  font-family: var(--mono);
  font-size: 0.92rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* --- The reel's menu bar ------------------------------------------------
   Which section is open, and the sibling groups to move between. It holds
   the top of the pane while the group scrolls underneath, so the menu — and
   on a phone the way back to the index — is always one tap away however
   deep into a group the reader has travelled. The negative margin pulls its
   surface out over the pane's padding so content passes under an edge-to-
   edge bar rather than a floating strip. --------------------------------- */
.reelnav {
  position: sticky;
  top: var(--topbar-h);      /* mobile: the window scrolls under the fixed top bar */
  z-index: 20;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin: 0 calc(var(--reel-pad) * -1) var(--s-4);
  padding: 0 var(--reel-pad);
  /* Opaque, and deliberately NOT a backdrop-filter veil like the top bar.
     Two reasons. There is nothing to see through to — the pane behind is
     flat paper, so a blur buys no depth. And the pane carries a transform
     while its entrance animation is filled, which makes it a backdrop root:
     Chromium then applies the blur but drops the element's own background,
     and a film's synopsis was left legible straight through the menu. An
     opaque bar cannot fail that way, and costs less to composite. */
  background: var(--paper);
  /* No bottom rule. The active group already carries an underline, and a
     full-width hairline directly under it read as a second, competing line
     — the bar's edge is legible from the type sitting on it. */
}

.detail__back {
  display: inline-flex;
  align-items: center;
  font-family: var(--mono);
  font-size: 0.9rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  text-decoration: none;
  padding: 0.55rem 0;
  min-height: 44px;
  white-space: nowrap;
}
.detail__back:hover,
.detail__back:focus-visible { color: var(--ink); text-decoration: underline; }

/* Carries the same 44px as the links beside it, so a section with no groups
   (the photographs) gets a bar exactly as tall as one with them — the pane's
   content starts on the same line whichever section is open. */
.reelnav__section {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-family: var(--mono);
  font-size: 0.88rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink);
  white-space: nowrap;
}

/* The group links. On a phone there can be more of them than the width
   allows, so the row scrolls sideways on its own rather than wrapping the
   bar onto a second line and eating the pane. */
.reelnav__groups {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin-left: auto;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.reelnav__groups::-webkit-scrollbar { display: none; }
.reelnav__groups:empty { display: none; }

.reelnav__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0.55rem 0;
  font-family: var(--mono);
  font-size: 0.88rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--ink-faint);
  text-decoration: underline;
  text-decoration-color: transparent;
  text-underline-offset: 5px;
  transition: color var(--dur-1) var(--ease-out),
              text-decoration-color var(--dur-1) var(--ease-out);
}
.reelnav__link:hover,
.reelnav__link:focus-visible { color: var(--ink); text-decoration-color: var(--rule); }
/* The open group is not marked by colour alone — it carries the underline. */
.reelnav__link.is-active { color: var(--ink); text-decoration-color: var(--ink); }

@media (max-width: 61.999rem) {
  /* The back link owns the left slot on a phone; the group names beside it
     already say which section this is. */
  .reelnav__section { display: none; }
  /* Below 48rem the root font jumps to 16px, which is right for reading and
     far too wide for five labels in one row — four group names plus the way
     back overran a 390px phone. Tighten the bar's own type only. */
  .detail__back,
  .reelnav__link { font-size: 0.78rem; letter-spacing: 0.06em; }
  .reelnav { gap: var(--s-2); }
  .reelnav__groups { gap: var(--s-2); }
  /* Should a section ever carry more groups than the row can hold, the row
     still scrolls — and says so, by fading its right edge instead of
     chopping a label in half. */
  .reelnav__groups {
    mask-image: linear-gradient(to left, transparent, #000 1.5rem);
    -webkit-mask-image: linear-gradient(to left, transparent, #000 1.5rem);
  }
  .reelnav__groups.is-end {
    mask-image: none;
    -webkit-mask-image: none;
  }
}
@media (min-width: 62rem) {
  .reelnav { top: 0; }
  .detail__back { display: none; }   /* both columns visible — no back needed */
}

/* --- The stack ----------------------------------------------------------
   A grouped section hands its whole group to the pane and the reader
   scrolls the films in order. Each panel is given the pane to itself, less
   a sliver so the next one shows at the bottom edge and the scroll reads as
   continuing rather than ending. It also guarantees the last film in a
   group can still be brought up under the menu bar. */
.panel + .panel {
  margin-top: var(--s-5);
  padding-top: var(--s-5);
  border-top: 1px solid var(--rule);
}
.detail__stack > .panel:not(:only-child) {
  min-height: calc(100dvh - 9rem);
}

.detail__meta {
  font-family: var(--mono);
  font-size: 0.88rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 var(--s-1);
}

.detail__title {
  font-family: var(--mono);
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  margin: 0 0 var(--s-3);
  line-height: 1.4;
  text-wrap: balance;
  outline: none;           /* receives focus programmatically; the move itself is the cue */
}

.detail__back .detail__back-arrow { display: inline-block; }
@media (prefers-reduced-motion: no-preference) {
  .detail__back .detail__back-arrow { transition: transform var(--dur-1) var(--ease-out); }
  .detail__back:hover .detail__back-arrow { transform: translateX(-3px); }
}

/* --- Video player -------------------------------------------------------
   The film is NOT loaded until it is clicked. Until then this is just an
   image with a play button, which is why the page stays fast.
   ---------------------------------------------------------------------- */

.player {
  position: relative;
  aspect-ratio: 16 / 9;      /* reserves the space, so nothing jumps */
  background: var(--highlight);
  border: 1px solid var(--rule);
  margin-bottom: var(--s-4);
  overflow: hidden;
}
.player:empty { display: none; }

/* Photo gallery — a vertical stack of full-width images in the right pane.
   Each image sits in a frame whose aspect ratio is declared in content.js,
   so the space is reserved before the file arrives: no layout shift, and
   the empty frame doubles as the loading skeleton. */
.gallery {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  margin-bottom: var(--s-4);
}
.ph {
  margin: 0;
  position: relative;
  overflow: hidden;
  background: var(--highlight);
  border: 1px solid var(--rule);
}
/* Every photograph is a real button — click or Enter/Space enlarges it. */
.ph__btn {
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: zoom-in;
}
/* The frame clips to its aspect ratio, which would also clip a default
   outset focus ring; pull this one ring inward so it stays visible. The
   facade has the same problem — the player's overflow:hidden would clip
   an outset ring entirely — so its ring is pulled inward too. */
.ph__btn:focus-visible { outline-offset: -3px; }
.facade:focus-visible {
  /* White ring with a dark halo behind it, so it reads over any poster. */
  outline: 2px solid #fff;
  outline-offset: -5px;
  box-shadow: inset 0 0 0 3px rgba(8, 8, 7, 0.55);
}
.ph img {
  width: 100%;
  height: auto;
  display: block;
}
/* A frame with a declared ratio sizes the image to fill it exactly. */
.ph[style*="aspect-ratio"] img { height: 100%; object-fit: cover; }

/* Frames far offscreen skip rendering entirely (the declared ratio still
   reserves their exact height, so scrollbars and anchors are unaffected).
   On a 31-photograph gallery this is most of the initial paint cost. */
.ph[style*="aspect-ratio"] { content-visibility: auto; }

@media (prefers-reduced-motion: no-preference) {
  .ph__btn img { transition: transform var(--dur-2) var(--ease-out); }
  .ph__btn:hover img { transform: scale(1.015); }
}

/* The fade-on-load only exists where motion is welcome, and it carries its
   own failsafe: any image not marked loaded within 4s is shown regardless,
   so a starved load event can never leave a hole in the page. */
@media (prefers-reduced-motion: no-preference) {
  .ph:not(.is-loaded) img {
    opacity: 0;
    animation: img-failsafe 1ms linear 4s forwards;
  }
  .ph.is-loaded img {
    opacity: 1;
    transition: opacity 300ms ease-out;
  }
}
@keyframes img-failsafe { to { opacity: 1; } }

/* Loading shimmer: one soft pass of paper-light across the empty frame. */
@media (prefers-reduced-motion: no-preference) {
  .ph:not(.is-loaded)::before {
    content: "";
    position: absolute;
    inset: 0;
    transform: translateX(-100%);
    background: linear-gradient(100deg, transparent 20%,
                color-mix(in srgb, var(--paper) 55%, transparent) 50%,
                transparent 80%);
    animation: shimmer 1.4s ease-in-out infinite;
  }
  /* Photographs rise gently as they enter the viewport, once. The reveal
     carries the same failsafe philosophy as the fade-on-load: if the
     observer never reports (hidden tab, exotic browser), every frame is
     forced visible after 2.5s. The enhancement has a guaranteed floor. */
  .ph {
    opacity: 0;
    transform: translateY(12px);
    animation: ph-failsafe 1ms linear 2.5s forwards;
  }
  .ph.in-view {
    opacity: 1;
    transform: none;
    animation: none;
    transition: opacity var(--dur-3) var(--ease-out),
                transform var(--dur-3) var(--ease-out);
  }
}
@keyframes ph-failsafe { to { opacity: 1; transform: none; } }
@keyframes shimmer { to { transform: translateX(100%); } }

.player iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  z-index: 1;              /* sits above the loading slate below */
}

/* Between pressing play and the player painting: a slate, not a black hole. */
.player.is-loading::after {
  content: "ROLLING…";
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
}
@media (prefers-reduced-motion: no-preference) {
  .player.is-loading::after { animation: slate-pulse 1.2s ease-in-out infinite; }
}
@keyframes slate-pulse { 50% { opacity: 0.35; } }

.facade {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: var(--highlight);
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  align-items: center;
  justify-content: center;
  overflow: hidden;              /* clips the poster's hover zoom */
}

/* The poster still. On hover it eases into a slow push-in — the language
   of a film about to start — while the button and play glyph hold still. */
.facade__bg {
  position: absolute;
  inset: 0;
  background: center / cover no-repeat;
}
@media (prefers-reduced-motion: no-preference) {
  .facade__bg { transition: transform 700ms var(--ease-out); }
  .facade:hover .facade__bg,
  .facade:focus-visible .facade__bg { transform: scale(1.04); }
}

/* A poster that has to be fetched (Vimeo's) develops in when it lands
   instead of popping. Posters known up front never take this path. With
   reduced motion the reveal is instant; if the fetch fails the span is
   already imageless, so nothing is ever held back. */
.facade__bg--late { opacity: 0; }
.facade__bg--late.is-in { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
  .facade__bg--late { transition: opacity 450ms ease-out, transform 700ms var(--ease-out); }
}

/* Label shown on films that open on an external site (e.g. X). */
.facade__ext {
  position: relative;           /* paints above the absolute poster span */
  font-family: var(--mono);
  font-size: 0.8rem;
  letter-spacing: 0.05em;
  color: #fff;
  background: rgba(20, 19, 15, 0.72);
  padding: 0.3rem 0.6rem;
  border-radius: 2px;
}

.facade__play {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background: rgba(20, 19, 15, 0.72);
  transition: transform var(--dur-1) var(--ease-out),
              background var(--dur-1) var(--ease-out);
}
.facade:hover .facade__play,
.facade:focus-visible .facade__play { transform: scale(1.06); background: rgba(20, 19, 15, 0.88); }
.facade:active .facade__play { transform: scale(0.94); transition-duration: 60ms; }
.facade__play svg { width: 24px; height: 24px; fill: #fff; margin-left: 3px; }

/* One ring ripples out when the button is engaged — a lens, not a firework. */
.facade__play::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.85);
  opacity: 0;
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  .facade:hover .facade__play::after,
  .facade:focus-visible .facade__play::after {
    animation: ring 900ms var(--ease-out) 1;
  }
  .facade:active .facade__play::after {
    animation: ring 500ms var(--ease-out) 1;
  }
}
@keyframes ring {
  0%   { opacity: 0.9; transform: scale(1); }
  100% { opacity: 0;   transform: scale(1.75); }
}

.player__empty {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-faint);
  text-align: center;
  padding: var(--s-2);
}

/* --- Credits block — formatted like real film credits ------------------- */

.credits {
  display: grid;
  grid-template-columns: var(--detail-rail, minmax(7rem, auto)) 1fr;
  gap: 0.35rem var(--s-3);
  margin: 0 0 var(--s-4);
  font-size: 1rem;
}
.credits:empty { display: none; }
/* Credits are reference metadata — label and value both read quiet (grey),
   so the synopsis below is the one black, primary thing the eye lands on. */
.credits dt { color: var(--ink-faint); }
.credits dd { margin: 0; color: var(--ink-soft); }

/* The synopsis is the primary reading content of the pane: full ink (black),
   not the softened grey it used to be. */
.detail__body p {
  margin: 0 0 var(--s-2);
  max-width: var(--measure);
  color: var(--ink);
}

.detail__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-top: var(--s-3);
}
.detail__links a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0.5rem 0;
  font-family: var(--mono);
  font-size: 0.92rem;
  letter-spacing: 0.04em;
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-color: var(--rule);
}
.detail__links a:hover,
.detail__links a:focus-visible { text-decoration-color: var(--ink); }

/* Desktop: the pane reads as an editorial credits sheet. Credit labels sit
   in a left rail; everything the reader actually reads — the credit values,
   the synopsis, the links — lines up in one content column to the right of
   it. The rail is a fixed width (just past the longest label) so that column
   edge never shifts between projects. Mobile stays flush: on a narrow screen
   full width reads better than a deep indent. */
@media (min-width: 62rem) {
  .detail {
    --detail-rail: 8.5rem;                                  /* fits the longest label on one line */
    --detail-indent: calc(var(--detail-rail) + var(--s-3)); /* rail + the credits column gap */
  }
  .detail__body,
  .detail__links { padding-left: var(--detail-indent); }
}

/* ============================================================================
   LIGHTBOX — click a photograph to enlarge it; move through the rest of
   that project's set with Prev/Next, arrow keys, or a swipe.
   Backdrop is fixed near-black regardless of site theme, deliberately — a
   dark, neutral surround is what lets a photograph read at full contrast
   whether the page itself is in light or dark mode.
   ========================================================================= */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  flex-direction: column;
  /* Fully opaque: nothing of the page may bleed through behind a photograph. */
  background: #080807;
}
/* Opening takes one short breath; closing is instant (exits are faster).
   The chrome (counter, close, arrows) waits for the backdrop to land
   before it appears — over a light page, white controls at half-fade
   read as floating over the content underneath. */
@media (prefers-reduced-motion: no-preference) {
  .lightbox:not([hidden]) { animation: fade-in var(--dur-2) var(--ease-out); }
  .lightbox:not([hidden]) .lightbox__bar,
  .lightbox:not([hidden]) .lightbox__nav {
    animation: fade-in 240ms var(--ease-out) 130ms both;
  }
}

.lightbox__bar {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--s-2) var(--s-3);
}
.lightbox__count {
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.08em;
  color: rgba(255, 255, 255, 0.68);
}

.lightbox__close,
.lightbox__nav {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.28);
  background: rgba(8, 8, 7, 0.4);
  color: #fff;
  cursor: pointer;
}
.lightbox__close { width: 44px; height: 44px; }
.lightbox__close svg { width: 16px; height: 16px; }
.lightbox__nav { width: 48px; height: 48px; }
.lightbox__nav svg { width: 20px; height: 20px; }

.lightbox__close:hover,
.lightbox__nav:hover { background: rgba(255, 255, 255, 0.14); }
.lightbox__close:active,
.lightbox__nav:active { transform: scale(0.92); }
.lightbox__nav:disabled { opacity: 0.22; cursor: default; pointer-events: none; }
@media (prefers-reduced-motion: no-preference) {
  .lightbox__close, .lightbox__nav {
    transition: background var(--dur-1) var(--ease-out), transform var(--dur-1) var(--ease-out);
  }
}

/* Centred on the PHOTOGRAPH, which is not the same as centred on the box:
   .lightbox__body starts below the bar (hence the wrapper), and on wide
   screens the stage carries --lb-inset at its foot, which lifts the picture
   by half that. Both corrections, or the arrows sit off the image — they
   were half a bar-height high on every phone before this. */
.lightbox__nav {
  position: absolute;
  top: calc(50% - var(--lb-inset, 0px) / 2);
  transform: translateY(-50%);
  z-index: 1;
}
.lightbox__nav:active { transform: translateY(-50%) scale(0.92); }
.lightbox__nav--prev { left: var(--s-2); }
.lightbox__nav--next { right: var(--s-2); }
@media (min-width: 40rem) {
  .lightbox__nav--prev { left: var(--s-4); }
  .lightbox__nav--next { right: var(--s-4); }
}

/* Everything below the bar: the stage, with the two arrows overlaid. It is
   the positioning context for .lightbox__nav, which is the whole point —
   the arrows centre on the picture rather than on the box that also
   contains the bar. min-height:0 lets it actually shrink inside the column
   flex; without it a tall photograph pushes the stage past the viewport. */
.lightbox__body {
  position: relative;
  flex: 1 1 auto;
  display: flex;
  min-height: 0;
}

.lightbox__stage {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
/* The top bar eats ~60px of the viewport; without a matching inset at
   the bottom the photograph reads bottom-heavy — optically about to
   slide off the page. Mirror the bar's height below on wide screens
   (phones keep every pixel for the image).

   --lb-inset is the single source of truth for that mirror, because TWO
   things have to agree on it: the stage pads by it, and the arrows shift
   up by half of it to stay on the photograph's centre rather than the
   stage's. Declaring it twice is how they silently drift apart. */
@media (min-width: 40rem) {
  .lightbox { --lb-inset: calc(44px + var(--s-2) * 2); }
}
.lightbox__stage { padding-bottom: var(--lb-inset, 0px); }
.lightbox__stage::-webkit-scrollbar { display: none; }
@media (prefers-reduced-motion: no-preference) {
  .lightbox__stage { scroll-behavior: smooth; }
}

.lightbox__item {
  flex: 0 0 100%;
  scroll-snap-align: center;
  scroll-snap-stop: always;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-2);
}
@media (min-width: 40rem) {
  /* Wider flanks once the Prev/Next buttons have moved out to var(--s-4)
     from the edge, so they never sit over the photograph. */
  .lightbox__item { padding: var(--s-4) var(--s-6); }
}
.lightbox__item img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  -webkit-user-select: none;
  user-select: none;
}

/* Each photograph fades up as its file arrives — the neighbours load
   lazily while you swipe, and they should develop, not pop. Same failsafe
   as the grid: anything not marked loaded in 4s is shown regardless. */
@media (prefers-reduced-motion: no-preference) {
  .lightbox__item:not(.is-loaded) {
    opacity: 0;
    animation: img-failsafe 1ms linear 4s forwards;
  }
  .lightbox__item.is-loaded {
    opacity: 1;
    transition: opacity 260ms ease-out;
  }
  /* On open, the photograph settles into place from a hair larger — a
     print being laid down. Scale only; the load fade above owns opacity.
     Items are rebuilt on every open, so this never replays mid-browse. */
  .lightbox:not([hidden]) .lightbox__item img {
    animation: lb-settle 480ms var(--ease-out) both;
  }
}
@keyframes lb-settle {
  from { transform: scale(0.965); }
  to   { transform: none; }
}

/* The global focus ring is near-black in the light theme — invisible on
   the lightbox's fixed black backdrop. Controls in here ring in white. */
.lightbox button:focus-visible {
  outline: 2px solid rgba(255, 255, 255, 0.9);
  outline-offset: 3px;
}

/* ============================================================================
   Respect the system setting for reduced motion.
   ========================================================================= */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
