/* ============================================================
   PAGE TRANSITION · lift, zoom, curtain
   Ported from the Exo Ape timing captured in the console traces:
     exit  — content translateY(-50vh) + scale(1.3), curtain 0 → 1
     enter — curtain 1 → 0, hero content staggered in
   Both on an exponential-out curve, which is what the sampled
   decay (~0.84 ratio per 50ms) actually fits.

   The curtain is a pseudo-element on <html> rather than a real node, so it
   exists from the very first paint — a JS-created element would arrive a
   frame late and flash the new page before covering it.
   ============================================================ */

:root {
  --pt-exit: 950ms;
  --pt-enter: 900ms;
  --pt-ease: cubic-bezier(0.16, 1, 0.3, 1);   /* expo-out */
  /* Fixed value, not a token: --ink differs per page (#1a1712 on the homepage,
     #202a22 on case studies) and the curtain must be identical everywhere. */
  --pt-curtain: #202a22;      /* dark green */
  --pt-chrome: 420ms;         /* nav + status blocks fade */
  --pt-lift: -50vh;
  --pt-zoom: 1.3;
  --pt-stagger: 90ms;

  /* Defocus. --pt-blur-out is magnified by --pt-zoom, so it lands near 36px. */
  --pt-blur-out: 28px;
  --pt-dim: 0.85;
  --pt-blur-in: 14px;

  /* Destination name on the curtain.
     --pt-label-delay + --pt-label-in must finish INSIDE --pt-exit (950ms), or
     the real navigation interrupts the animation mid-way and the label jumps
     when it is re-rendered on the next document. 180 + 700 = 880ms. */
  --pt-label-delay: 180ms;
  --pt-label-in: 700ms;
  --pt-label-out: 320ms;
  --pt-label-ease: cubic-bezier(0.19, 1, 0.22, 1);   /* expo-out, very eased */
  --pt-label-scale: 1.35;
  --pt-label-blur: 18px;
  /* Fixed, for the same reason as --pt-curtain: index.html does not load
     styles.css at all, so --bg and --display are undefined there and the
     label would silently fall back to a default sans on the homepage only. */
  --pt-label-color: #f2ecdf;
  --pt-label-font: 'Playfair Display', Georgia, serif;
}

/* Metric-matched fallbacks for the curtain label.
   ==============================================
   Google Fonts is requested with display=swap, so text paints in a fallback
   immediately rather than waiting for the webfont. The label now paints in
   the very first frame of the arriving page — before the @font-face rules
   register — so it catches that swap, and it is the only thing on screen when
   it happens. At 64px italic 500 Georgia sets these names 2.1%–7.2% wider than
   Playfair, which on a centred label is a visible nudge at both ends.

   size-adjust scales the fallback to the same width, so the swap costs no
   reflow and only the letterforms differ, for a few frames.

   One value cannot serve every name — the ratio depends on the glyph mix. But
   on arrival a page only ever shows its OWN name; the exit shows other names,
   and by then the webfont has long since loaded and the fallback never
   renders. So each page gets a fallback tuned to the one string it can flash.

   Values were solved in-browser by binary search rather than derived as a
   width ratio: size-adjust scales the glyphs, but letter-spacing is computed
   from the unchanged font-size, so a plain ratio overshoots by up to 3.7px.
   No line-box override needed — both metrics give an identical 102.4px height. */
@font-face { font-family: 'PTL-home';      src: local('Georgia'), local('Times New Roman'), local('Times');
             font-style: italic; font-weight: 500; size-adjust: 92.96%; }  /* Home.        174.6px */
@font-face { font-family: 'PTL-bifur';     src: local('Georgia'), local('Times New Roman'), local('Times');
             font-style: italic; font-weight: 500; size-adjust: 97.83%; }  /* BIFUR Zine.  342.3px */
@font-face { font-family: 'PTL-ryte';      src: local('Georgia'), local('Times New Roman'), local('Times');
             font-style: italic; font-weight: 500; size-adjust: 96.03%; }  /* Ryte.        137.7px */
@font-face { font-family: 'PTL-spungebot'; src: local('Georgia'), local('Times New Roman'), local('Times');
             font-style: italic; font-weight: 500; size-adjust: 95.93%; }  /* Spungebot.   300.9px */
@font-face { font-family: 'PTL-winevibes'; src: local('Georgia'), local('Times New Roman'), local('Times');
             font-style: italic; font-weight: 500; size-adjust: 96.00%; }  /* WineVibes.   303.7px */

.pt-label[data-fallback="home"]      span { font-family: 'Playfair Display', 'PTL-home',      Georgia, serif; }
.pt-label[data-fallback="bifur"]     span { font-family: 'Playfair Display', 'PTL-bifur',     Georgia, serif; }
.pt-label[data-fallback="ryte"]      span { font-family: 'Playfair Display', 'PTL-ryte',      Georgia, serif; }
.pt-label[data-fallback="spungebot"] span { font-family: 'Playfair Display', 'PTL-spungebot', Georgia, serif; }
.pt-label[data-fallback="winevibes"] span { font-family: 'Playfair Display', 'PTL-winevibes', Georgia, serif; }

/* ── Curtain ─────────────────────────────────────────────── */
html.pt-leaving::before,
html.pt-entering::before {
  content: '';
  position: fixed;
  inset: 0;
  background: var(--pt-curtain);
  z-index: 450;            /* over content, under the nav and status blocks —
                              they stay visible, as they do on the reference */
  pointer-events: none;
}

html.pt-leaving::before  { opacity: 0; animation: pt-curtain-in var(--pt-exit) var(--pt-ease) forwards; }

/* Opaque while entering, then gone in a single frame — no fade.
   On the reference the curtain is a child of the departing page, so deleting
   that page removes the curtain instantly. Dropping .pt-entering does the
   same thing here: the pseudo-element simply stops being generated. */
html.pt-entering::before { opacity: 1; }

@keyframes pt-curtain-in { to { opacity: 1; } }

/* ── Departing content ─────────────────────────────────────
   The blur rides the SAME keyframe as the lift, so it inherits the existing
   curve exactly — the motion is untouched, it just goes out of focus as it
   pulls away. The blur is applied before the transform, so scale(1.3)
   magnifies it: 28px reads like ~36px for the cost of 28.

   brightness() is in the same filter chain, so it is free once we are
   rasterising anyway, and it makes the page recede into the dark rather than
   just smudging. */
html.pt-leaving .pt-content {
  animation: pt-lift var(--pt-exit) var(--pt-ease) forwards;
  will-change: transform, filter;
}

@keyframes pt-lift {
  from { filter: blur(0px) brightness(1); }
  to   { transform: translateY(var(--pt-lift)) scale(var(--pt-zoom));
         filter: blur(var(--pt-blur-out)) brightness(var(--pt-dim)); }
}

/* ── Arriving content ──────────────────────────────────────
   Starts out of focus behind the curtain and sharpens as the hero staggers
   in — same duration, so the two read as one movement rather than a sequence.

   Safe to filter .pt-content: it holds no position:fixed descendants, which
   a filter would otherwise re-anchor to it instead of the viewport. The nav,
   cursor, scroll line and status blocks all live outside it. */
html.pt-entering .pt-content { filter: blur(var(--pt-blur-in)); }

html.pt-reveal .pt-content {
  filter: blur(0px);
  transition: filter var(--pt-enter) var(--pt-ease);
  will-change: filter;
}

/* Scale overflows the viewport; lock scrolling so nothing jumps. */
html.pt-leaving,
html.pt-leaving body { overflow: hidden; }

/* The intro loader is for first arrivals, not for every page change.
   transition.js also sets display:none inline on arrival — this class comes
   off at the cut, and without the inline style the loader would pop back
   into view and then play its own slide-up a moment later. */
html.pt-entering .loader { display: none !important; }

/* ── Nav and status blocks ────────────────────────────────
   They sit outside .pt-content so they don't lift with the page; fading them
   separately keeps them part of the same move. */
html.pt-leaving .site-nav,
html.pt-leaving .site-nav-menu,
html.pt-leaving .status-blocks {
  opacity: 0;
  /* Higher specificity than nav-dock.css's own transition list, which would
     otherwise win and leave opacity un-transitioned. */
  transition: opacity var(--pt-chrome) var(--pt-ease);
  pointer-events: none;
}

html.pt-entering .site-nav,
html.pt-entering .site-nav-menu,
html.pt-entering .status-blocks { opacity: 0; }

html.pt-reveal .site-nav,
html.pt-reveal .site-nav-menu,
html.pt-reveal .status-blocks {
  opacity: 1;
  transition: opacity var(--pt-enter) var(--pt-ease);
}

/* ── Arriving content ──────────────────────────────────────
   Two distinct motions, matching the traced from-states exactly:

     titles  transform: translate(10%, 0%)              — no fade
     images  translate(0%, 100%) + opacity 0 + hidden   — fades in

   The attributes are only applied when arriving via a transition, so a
   direct load never hides anything. */
[data-pt="title"] {
  transform: translateX(var(--pt-title-shift, 10%));
}

[data-pt="image"] {
  transform: translateY(var(--pt-image-shift, 100%));
  opacity: 0;
  visibility: hidden;
}

html.pt-reveal [data-pt] {
  transform: none;
  transition: transform var(--pt-enter) var(--pt-ease),
              opacity var(--pt-enter) var(--pt-ease);
  transition-delay: var(--pt-delay, 0ms);
}

html.pt-reveal [data-pt="image"] {
  opacity: 1;
  visibility: visible;
}

/* ── Destination name ──────────────────────────────────────
   Sits above the curtain (450) but below the nav, which is fading out anyway.
   Typography deliberately mirrors .loader-text — same idea, same voice. */
/* Static markup in every page, carrying that page's own name. It is in the
   document from the first byte, so on arrival it paints in the same frame as
   the curtain. Building it from JS cannot work: this file is deferred, so it
   runs only after the whole document is parsed, and the name would blink out
   and back in on every page change. */
.pt-label {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 460;
  align-items: center;
  justify-content: center;
  padding: 0 6vw;
  text-align: center;
  pointer-events: none;
}

html.pt-leaving .pt-label,
html.pt-entering .pt-label { display: flex; }

.pt-label span {
  font-family: var(--pt-label-font);
  font-style: italic;
  font-weight: 500;
  font-size: clamp(32px, 5vw, 64px);
  letter-spacing: -0.01em;
  color: var(--pt-label-color);

  opacity: 0;
  transform: scale(var(--pt-label-scale));
  filter: blur(var(--pt-label-blur));
  will-change: transform, filter, opacity;
}

/* Oversized and out of focus, settling to sharp. */
.pt-label.is-in span {
  opacity: 1;
  transform: scale(1);
  filter: blur(0px);
  transition: opacity   var(--pt-label-in) var(--pt-label-ease) var(--pt-label-delay),
              transform var(--pt-label-in) var(--pt-label-ease) var(--pt-label-delay),
              filter    var(--pt-label-in) var(--pt-label-ease) var(--pt-label-delay);
}

/* On arrival it is already settled, with no JS and no transition — the
   animation finished during the exit, before the browser navigated, so
   replaying it would read as a hiccup. Explicit blur(0px)/scale(1) rather
   than `none`, so the out transition has real values to interpolate from. */
html.pt-entering .pt-label span {
  opacity: 1;
  transform: scale(1);
  filter: blur(0px);
}

/* Listed with the pt-entering ancestor as well, or the settled rule above
   out-ranks it on specificity and the name never dissolves. */
html.pt-entering .pt-label.is-out span,
.pt-label.is-out span {
  opacity: 0;
  transform: scale(0.97);
  filter: blur(var(--pt-label-blur));
  transition: opacity   var(--pt-label-out) ease-in,
              transform var(--pt-label-out) ease-in,
              filter    var(--pt-label-out) ease-in;
}

/* ── Cost controls ────────────────────────────────────────
   Full-page blur is the most expensive thing here: it re-rasterises the whole
   layer every frame, over a playing video. Below 900px the effect is barely
   perceptible and the devices are the weakest, so drop it entirely rather
   than paying for blur(0px), which still forces the same work. */
@keyframes pt-lift-plain {
  to { transform: translateY(var(--pt-lift)) scale(var(--pt-zoom)); }
}

@media (max-width: 900px) {
  html.pt-leaving .pt-content {
    animation-name: pt-lift-plain;
    will-change: transform;
  }
  html.pt-entering .pt-content,
  html.pt-reveal .pt-content { filter: none; transition: none; will-change: auto; }
}

/* ── No reduced-motion override, on purpose ───────────────
   There was a @media (prefers-reduced-motion: reduce) block here that switched
   the whole transition off — curtain instant, no lift, no blur, no name. It has
   been removed by the site owner's explicit decision: the transition plays for
   every visitor.

   transition.js sets its `calm` flag to a hard false for the same reason. The
   two MUST agree — leaving this block in place while JS played along would give
   reduced-motion visitors a curtain with no animation behind it, which is worse
   than either choice on its own.

   Noted rather than hidden: this is motion that can cause nausea and vertigo for
   people with vestibular disorders. If it needs revisiting, soften the animation
   for those visitors (drop --pt-zoom and --pt-blur-out, keep the curtain and the
   label) instead of restoring the instant swap. The mobile block above already
   does exactly that shape of thing for weaker devices. */
