/* ============================================================
   styles.css — tokens, chrome, and the hero scene
   Preston Thoman · Palm Springs portfolio

   Two live custom properties drive the scroll-linked scene:
     --dusk   0 → 1   how far the sun has set (hero scroll)
     --wind   deg     scroll velocity, expressed as lean
   Both are written from js/main.js once per frame.
   ============================================================ */

:root {
  /* The sky is the real Coachella Valley at dusk, not a neon one: dusty
     and cool at the top, warming to a peach horizon. The names are kept
     from the old palette so every rule that uses them follows along. */
  --night:      #141a2e;   /* indigo night */
  --deep:       #212a4a;
  --violet:     #3b3d6b;   /* slate violet */
  --orchid:     #66598a;   /* lilac haze */
  --magenta:    #9c6189;   /* mauve */
  --rose:       #cf7a88;   /* dusk rose */
  --blush:      #eab09d;   /* peach horizon */

  /* The sun owns the saturated warm colours; nothing in the sky shares them. */
  --orange:     #e8552c;   /* vermillion — the one loud ink */
  --amber:      #f08a3c;   /* tangerine */
  --gold:       #f2b544;   /* marigold */
  --cream:      #f1e6d0;   /* sand: the paper everything is printed on */
  /* No white band: the name is sand-coloured, and a white sun crown
     behind it made both read as one shape. The sun starts at marigold. */
  --sun-top:    #f7c552;
  --sun-mid:    #f3a141;
  --sun-low:    #ec7633;

  /* the cool counter-notes */
  --lilac:      #a9a2cf;   /* secondary text and rules on the night ground */
  --pool:       #5ec2b5;   /* keyboard focus only */

  /* ink — a blue-black desert night rather than purple, so the warm
     accents carry and the dusk sky sits on it as its own colour */
  --ink:        #12151c;
  --ink-2:      #181c25;
  --ink-3:      #20252f;
  --paper:      #f1e6d0;
  --muted:      rgba(241, 230, 208, .64);
  --line:       rgba(169, 162, 207, .16);
  --rule:       rgba(169, 162, 207, .3);

  /* Paper grain, shared by every section ground: a static fractal-noise
     tile, painted once, never animated. */
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .95 0 0 0 0 .9 0 0 0 0 .82 0 0 0 .07 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");

  /* shape: printed corners, one size; only the sun is round */
  --r:          4px;

  --maxw: 1160px;
  --gutter: clamp(1.25rem, 4vw, 3rem);
  --ease: cubic-bezier(.16, .84, .3, 1);
  /* near-exact sine ease-in-out — the curve that makes an alternating
     animation read as continuous oscillation rather than a loop */
  --sine: cubic-bezier(.37, 0, .63, 1);

  /* live, written per frame */
  --dusk: 0;
  --wind: 0deg;

  color-scheme: dark;
}

/* ---------- reset ---------------------------------------- */
* { margin: 0; padding: 0; box-sizing: border-box; }

html { scroll-behavior: smooth; }

body {
  background: var(--ink);
  color: var(--paper);
  font-family: "Outfit", system-ui, -apple-system, "Segoe UI", sans-serif;
  font-weight: 300;
  line-height: 1.6;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; }

::selection { background: var(--orange); color: var(--ink); }

/* ---- moving between pages ------------------------------------------
   Same-origin navigations cross-fade instead of cutting to white: the
   page eases out and the next rises in, while the nav — shared by both
   pages — stays put. Browsers without cross-document view transitions
   simply navigate as before. */
@view-transition { navigation: auto; }
.nav { view-transition-name: site-nav; }
::view-transition-old(root) { animation: vtOut .32s cubic-bezier(.4, 0, 1, 1) both; }
::view-transition-new(root) { animation: vtIn .55s cubic-bezier(.16, .84, .3, 1) both; }
@keyframes vtOut { to { opacity: 0; transform: translateY(-12px); } }
@keyframes vtIn  { from { opacity: 0; transform: translateY(18px); } }
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}

/* Focus is the one place the pool teal is used: a cool ring nothing
   else on the warm page shares, so it can never be mistaken for decoration. */
:focus-visible {
  outline: 2px solid var(--pool);
  outline-offset: 3px;
  border-radius: 2px;
}

/* The gutter is reserved for the life of the page so that hiding the
   scrollbar during the entry — and giving it back afterwards — never
   shifts the layout sideways. */
html { scrollbar-gutter: stable; }

body::-webkit-scrollbar { width: 9px; }
body::-webkit-scrollbar-track { background: var(--ink); }
body::-webkit-scrollbar-thumb { background: var(--ink-3); border-left: 3px solid var(--ink); }
body::-webkit-scrollbar-thumb:hover { background: var(--orange); }

/* While the entry is still unspent the scrollbar would be describing the
   grove rather than the page, so it is taken off screen entirely. It
   returns once the runway is cut and the bar measures real content. */
html.entry-live { scrollbar-width: none; }
html.entry-live::-webkit-scrollbar,
html.entry-live body::-webkit-scrollbar { width: 0; height: 0; }

.wrap { width: min(100% - (var(--gutter) * 2), var(--maxw)); margin-inline: auto; }

/* Available to screen readers, invisible to everyone else. Not display:none,
   which would take it out of the accessibility tree along with the view. */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; white-space: nowrap;
  clip-path: inset(50%);
}

/* ============================================================
   PRELOADER
   ============================================================ */
.preloader {
  position: fixed; inset: 0; z-index: 999;
  display: grid; place-items: center;
  background: var(--ink);
  transition: opacity .8s var(--ease), visibility .8s;
}
.preloader.is-done { opacity: 0; visibility: hidden; }
.preloader__inner { width: min(78vw, 320px); display: grid; gap: 1.5rem; justify-items: center; }
.preloader__sun {
  width: 76px; height: 38px; border-radius: 76px 76px 0 0;
  /* the hero sun in miniature: a half disc printed in hard bands */
  background: linear-gradient(180deg, var(--gold) 0 40%, var(--ink) 40% 48%, var(--orange) 48% 70%,
    var(--ink) 70% 77%, var(--rose) 77% 100%);
}
.preloader__meta {
  width: 100%; display: flex; justify-content: space-between; align-items: baseline;
  font-size: .68rem; letter-spacing: .26em; text-transform: uppercase;
  color: var(--muted);
}
.preloader__pct { font-family: "Syne", sans-serif; font-weight: 700; color: var(--gold); }
.preloader__bar { width: 100%; height: 1px; background: rgba(241, 230, 208, .16); overflow: hidden; }
.preloader__bar span {
  display: block; height: 100%; transform: scaleX(0); transform-origin: left;
  background: var(--orange);
  transition: transform .45s var(--ease);
}

/* ============================================================
   CURSOR · PROGRESS
   ============================================================ */
/* Both parts are positioned from one rAF tick in main.js — never from a
   mousemove handler — so they can never be written on different frames
   and drift apart. Nothing here transitions transform, for the same
   reason: the loop owns it outright.

   Size changes are done with scale rather than width/height so that
   growing over a link costs no layout. */
.cursor, .cursor-ring {
  position: fixed; top: 0; left: 0; z-index: 900;
  pointer-events: none; border-radius: 50%;
  translate: -50% -50%;
  opacity: 0;
  transition: opacity .25s var(--ease);
  will-change: transform;
}
/* The page runs from near-black to cream, so both parts carry a dark
   hairline on either side of their bright stroke. On the dark scenes the
   gold does the work and the hairline vanishes; on the cream sections the
   hairline draws the shape. Costs nothing, and avoids needing a blend
   mode over the animating hero. */
.cursor {
  width: 7px; height: 7px;
  background: var(--gold);
  box-shadow: 0 0 0 1.5px rgba(18, 21, 28, .5), 0 0 12px 3px rgba(242, 181, 68, .5);
}
.cursor-ring {
  width: 30px; height: 30px;
  border: 1.5px solid rgba(242, 181, 68, .85);
  box-shadow: 0 0 0 1px rgba(18, 21, 28, .35), inset 0 0 0 1px rgba(18, 21, 28, .35);
  transition: opacity .25s var(--ease), border-color .3s, background-color .3s;
}
body.has-cursor .cursor, body.has-cursor .cursor-ring { opacity: 1; }
.cursor-ring.is-hot {
  background: rgba(232, 86, 44, .13);
  border-color: var(--gold);
}

/* The real pointer is only taken away once ours is on screen — both
   classes land on the same first mousemove, so there is never a frame
   with no cursor at all. Specificity (0,1,1) beats the cursor:pointer
   declarations elsewhere without needing !important. */
body.custom-cursor, body.custom-cursor * { cursor: none; }

.progress {
  position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 850;
  pointer-events: none;
}
.progress span {
  display: block; height: 100%; transform: scaleX(0); transform-origin: left;
  background: var(--orange);
}

/* ============================================================
   NAV
   ============================================================ */
.nav {
  position: fixed; top: 0; left: 0; right: 0; z-index: 800;
  display: flex; align-items: center; justify-content: space-between;
  padding: 1.35rem var(--gutter);
  border-bottom: 1px solid transparent;
  transition: background .45s var(--ease), padding .45s var(--ease),
              border-color .45s;
  /* the landing is only trees — the chrome arrives with the hero */
  opacity: var(--heroIn, 1);
}
.nav.is-dormant { visibility: hidden; background: none; }
/* The stuck bar used to be glass: blur(16px) saturate(1.4) over whatever
   was scrolling beneath it. It is fixed and full-width, so its backdrop is
   the entire page, re-read as the page moves — the single most expensive
   backdrop-filter here, and the one sitting directly behind the nav links.
   Zooming re-rasterizes that backdrop while the readback is happening,
   which is where the black bars and colour-cycling came from.

   It was also in the transition list above, so every one of those readbacks
   was being animated over 450ms as well.

   A solid bar at .92 reads as the same dark chrome, holds the links legibly
   at any zoom, and costs the compositor nothing. */
.nav.is-stuck {
  padding-block: .75rem;
  background: rgba(18, 21, 28, .94);
  border-bottom-color: var(--line);
}
.nav__logo { display: flex; align-items: center; gap: .7rem; }
.nav__logo-mark {
  display: grid; place-items: center;
  width: 36px; height: 36px; border-radius: var(--r);
  font-family: "Syne", sans-serif; font-weight: 800; font-size: .82rem;
  color: var(--ink);
  background: var(--cream);
  /* a printed sun sinking into the bottom edge of the mark */
  box-shadow: inset 0 -7px 0 var(--orange);
  transition: background .35s, box-shadow .45s var(--ease);
}
.nav__logo:hover .nav__logo-mark { background: var(--gold); box-shadow: inset 0 -36px 0 var(--orange); }
.nav__logo-text { font-family: "Syne", sans-serif; font-weight: 700; font-size: .9rem; letter-spacing: .01em; }

.nav__links { position: relative; display: flex; gap: 1.9rem; }
.nav__links a {
  font-size: .85rem; letter-spacing: .05em; color: var(--muted);
  padding-bottom: 2px; transition: color .35s;
}
.nav__links a:hover, .nav__links a.is-active { color: var(--cream); }
.nav__indicator {
  position: absolute; left: 0; bottom: -5px; height: 2px; width: 0;
  opacity: 0;
  background: var(--orange);
  transition: transform .5s var(--ease), width .5s var(--ease), opacity .35s;
}

.nav__toggle {
  display: none; width: 40px; height: 40px; border: 0; background: none;
  cursor: pointer; place-items: center; gap: 6px;
}
.nav__toggle span {
  display: block; width: 22px; height: 2px; border-radius: 2px;
  background: var(--cream); transition: transform .4s var(--ease), opacity .3s;
}
.nav.is-open .nav__toggle span:first-child { transform: translateY(4px) rotate(45deg); }
.nav.is-open .nav__toggle span:last-child { transform: translateY(-4px) rotate(-45deg); }

/* ============================================================
   HERO SCENE
   ============================================================ */

/* The runway. Its height is the scroll budget for the whole entry
   sequence: pushing through the grove, then setting the sun. */
/* Height is driven from JS so the runway can be shortened once the grove
   has been crossed — see `consume()` in main.js. The literal here is only
   a no-JS fallback and must match PIN_VH. */
.hero-pin { height: var(--pinH, 240svh); position: relative; }

.hero {
  position: sticky; top: 0;
  height: 100svh;
  display: flex; align-items: center;
  overflow: hidden;
  isolation: isolate;
}

/* ============================================================
   THE GATE — a grove that parts as you push into it
   ------------------------------------------------------------
   Five depth layers sit on top of the hero. Each one scales out
   from a vanishing point, so trees near the edges sweep off
   screen while the middle opens up: the nearest layer rushes
   past first, the farthest last. Behind them is the real hero,
   already there, waiting to be uncovered.
   ============================================================ */
.gate {
  position: absolute; inset: 0; z-index: 20;
  overflow: hidden;
  cursor: pointer;
}
.gate.is-open { visibility: hidden; pointer-events: none; }
/* set by the inline script in <head> when this session has already been
   through — the grove is gone before the first paint, not after it */
html.entry-done .gate { display: none; }

/* darkness inside the grove, burning off as you come out of it */
.gate__veil {
  position: absolute; inset: 0;
  background:
    radial-gradient(58% 48% at 50% 52%, transparent 0%, rgba(10, 12, 18, .62) 78%),
    linear-gradient(180deg, rgba(10, 12, 18, .72) 0%, rgba(10, 12, 18, .28) 42%, rgba(10, 12, 18, .8) 100%);
  opacity: var(--veil, 1);
}

.gate__layer {
  position: absolute; inset: 0;
  transform-origin: 50% 58%;
  /* Promoted so the browser can rasterize each layer once and then just
     transform the texture. Combined with freezing the sway during the
     push (.gate.is-still), the scale-up costs no repaints at all.

     No `filter` here, deliberately. A blur forces a texture covering the
     layer's whole ink overflow at the *scaled* resolution — these layers
     hold trees 250% of viewport height and scale to 6.6x, which asks for
     roughly 8400x12500px. Chrome silently drops the tiles it cannot
     allocate, and tall thin shapes go first: that was the vanishing
     trunks. Depth is carried by colour and opacity instead. */
  will-change: transform, opacity;
  backface-visibility: hidden;
}

/* the palms inside a gate layer sit lower and taller than hero palms */
.gate__layer .palm { bottom: -6%; }

/* ---- prompt ---- */
.gate__prompt {
  position: absolute; left: 50%; top: 50%;
  translate: -50% -50%;
  z-index: 2;
  display: grid; justify-items: center; gap: 1.5rem;
  text-align: center;
  opacity: var(--prompt, 1);
  pointer-events: none;
}
.gate__word {
  font-family: "Syne", sans-serif; font-weight: 800;
  font-size: clamp(1.5rem, 5vw, 3rem);
  letter-spacing: -.02em; color: var(--cream);
  text-shadow: 0 6px 40px rgba(10, 12, 18, .85);
}
.gate__push {
  pointer-events: auto;
  display: grid; justify-items: center; gap: .5rem;
  padding: 1rem 2.1rem;
  border: 1px solid rgba(242, 181, 68, .34);
  border-radius: 99px;
  background: rgba(10, 12, 18, .42);
  backdrop-filter: blur(8px);
  color: var(--cream); font: inherit; cursor: pointer;
  transition: border-color .4s var(--ease), background .4s var(--ease), transform .4s var(--ease);
}
.gate__push:hover { border-color: var(--gold); background: rgba(10, 12, 18, .6); transform: translateY(-2px); }
.gate__push-label { font-size: .95rem; letter-spacing: .08em; }
.gate__push-hint {
  font-size: .6rem; letter-spacing: .3em; text-transform: uppercase;
  color: var(--muted); text-indent: .3em;
}
.gate__meter { width: 132px; height: 2px; background: rgba(241, 230, 208, .16); border-radius: 2px; overflow: hidden; }
.gate__meter span {
  display: block; height: 100%; transform: scaleX(var(--gate, 0)); transform-origin: left;
  background: linear-gradient(90deg, var(--orange), var(--gold));
}

/* The hero copy waits for the grove to clear (--heroIn), then fades out
   again as the sun sets (--heroOut). Two independent phases multiplied
   together, so neither has to know about the other. */
.hero__content { opacity: calc(var(--heroIn, 1) * var(--heroOut, 1)); }

/* ---- sky ---- */
.sky { position: absolute; inset: 0; z-index: -10; }
/* The sky is printed, not airbrushed: one flat ink per band, bands
   narrowing toward the horizon the way a screen-printed resort poster
   does it. It runs cool to pink and stops there — no orange, amber or
   gold — so the sun, which owns those colours, never dissolves into it. */
.sky__grad {
  position: absolute; inset: 0;
  background: linear-gradient(180deg,
    var(--night)   0 17%,
    var(--deep)    17% 30%,
    var(--violet)  30% 41%,
    var(--orchid)  41% 51%,
    var(--magenta) 51% 62%,
    var(--rose)    62% 73%,
    var(--blush)   73% 100%);
}
/* night falls as --dusk climbs */
.sky__night {
  position: absolute; inset: 0;
  background: linear-gradient(180deg, #07090f 0 30%, #0c0f19 30% 50%, #131726 50% 66%,
    #1b2033 66% 80%, #242840 80% 100%);
  opacity: calc(var(--dusk) * .86);
}
/* The drifting aurora blobs were airbrush in a printed sky; retired. */
.sky__aurora { display: none; }
.sky__stars { position: absolute; inset: 0 0 42% 0; opacity: calc(.28 + var(--dusk) * .72); }
.sky__stars i {
  position: absolute; width: 2px; height: 2px; border-radius: 50%;
  background: var(--cream);
  animation: twinkle var(--t, 4s) ease-in-out infinite var(--dl, 0s);
}

/* ---- retro sun ---- */
.sun {
  position: absolute; left: 50%; bottom: 20%;
  width: clamp(240px, 32vw, 440px); aspect-ratio: 1;
  translate: -50% 0;
  display: grid; place-items: center;
  /* the scroll sets the sun */
  transform: translateY(calc(var(--dusk) * 38vh)) scale(calc(1 - var(--dusk) * .12));
}
/* The glow as two printed halo rings rather than a soft bloom. No blur:
   it would cost a large filter surface for nothing. Each edge gets half a
   percent of transition — a hard stop on a circle renders as a staircase,
   and that half percent is exactly enough for the edge to anti-alias. */
.sun__glow {
  position: absolute; inset: -30%; border-radius: 50%;
  background: radial-gradient(closest-side,
    transparent 0 66.5%,
    rgba(246, 187, 76, .2) 67% 79.5%,
    rgba(246, 187, 76, .1) 80% 92.5%,
    transparent 93%);
  opacity: calc(1 - var(--dusk) * .75);
}
.sun__disc {
  position: relative; width: 100%; height: 100%; border-radius: 50%;
  overflow: hidden;
  /* warm only — sun-white through marigold to vermillion — so it reads
     against the dusty sky */
  background: linear-gradient(180deg, var(--sun-top) 0 22%, var(--sun-mid) 22% 46%, var(--sun-low) 46% 70%,
    var(--orange) 70% 100%);
}
.sun__bands { position: absolute; inset: 0; }
.sun__bands i {
  position: absolute; left: -10%; width: 120%;
  height: calc(4px + var(--i) * 2.6px);
  top: calc(52% + var(--i) * 8.4%);
  background: var(--night);
  animation: bandSlide calc(5s + var(--i) * 1.1s) ease-in-out infinite calc(var(--i) * -.4s);
}

/* ---- clouds ---- */
.clouds { position: absolute; inset: 0 0 38% 0; overflow: hidden; opacity: calc(1 - var(--dusk) * .6); }
/* Cut-paper strata: a long capsule with a shorter one tucked under it,
   the way a mid-century poster draws a cloud bank. Flat, no blur. */
/* lit from below by the sun that has already gone behind the ridge */
.cloud {
  position: absolute; height: 10px; border-radius: 99px;
  background: var(--rose);
  animation: cloudDrift var(--dur, 70s) linear infinite var(--dl, 0s);
}
.cloud::after {
  content: ""; position: absolute; left: 22%; top: 150%;
  width: 56%; height: 100%; border-radius: 99px;
  background: inherit;
}
.cloud--1 { top: 22%; width: 320px; --dur: 82s; --dl: -12s; opacity: .5; }
.cloud--2 { top: 33%; width: 220px; --dur: 64s; --dl: -38s; opacity: .45; height: 9px; }
.cloud--3 { top: 46%; width: 420px; --dur: 96s; --dl: -60s; opacity: .5; height: 15px; }
.cloud--4 { top: 15%; width: 180px; --dur: 58s; --dl: -22s; opacity: .4; height: 7px; }

/* ---- birds ---- */
.birds { position: absolute; inset: 0 0 45% 0; overflow: hidden; opacity: calc(1 - var(--dusk)); }
.bird {
  position: absolute; top: var(--top, 30%); left: 0; width: 16px; height: 8px;
  animation: birdFly var(--dur, 26s) linear infinite var(--dl, 0s);
}
.bird i {
  position: absolute; top: 0; width: 9px; height: 8px;
  border-top: 2px solid rgba(18, 21, 28, .7);
  animation: wingFlap .42s ease-in-out infinite;
}
.bird i:first-child { left: 0; border-radius: 60% 0 0 0; transform-origin: right center; }
.bird i:last-child { right: 0; border-radius: 0 60% 0 0; transform-origin: left center; }

/* ---- ranges ---- */
.range { position: absolute; left: -2%; right: -2%; bottom: 16%; z-index: -8; }
.range svg { width: 104%; height: clamp(140px, 26vh, 280px); }
/* The San Jacintos, opaque. They used to be translucent, which let the
   sky's bands show through the rock as ghost stripes. Distance is carried
   by value instead: the far ridge is lightest, as it is in the real valley. */
.range--far { bottom: 20%; }
.range--far path { fill: #8a7aa3; }
.range--mid { bottom: 17%; z-index: -7; }
.range--mid path { fill: #5f5784; }
.range--near { bottom: 14%; z-index: -6; }
.range--near path { fill: #3b3a5c; }

/* ---- desert floor ---- */
.desert { position: absolute; left: 0; right: 0; bottom: 0; height: 26%; z-index: -5; }
.desert__floor {
  position: absolute; inset: 0;
  background: linear-gradient(180deg, #4a4061 0 18%, #3a3350 18% 40%, #2a2740 40% 68%, #1c1b2b 68% 100%);
}
/* The heat-haze stripe read as a rendering glitch across the horizon. */
.desert__haze { display: none; }
.desert__dunes { position: absolute; left: 0; bottom: 0; width: 100%; height: 60%; }
.desert__dunes path { fill: rgba(18, 21, 28, .55); }

/* ---- palm layers ---- */
.palms { position: absolute; left: 0; right: 0; bottom: 0; height: 100%; pointer-events: none; }
/* No blur on these either. Each holds animating fronds, so a filter means
   re-blurring the entire layer on every frame of every sway. Distance is
   carried by colour, opacity and scale instead. */
/* --windk-layer scales how hard a layer answers the scroll wind. The near
   trees are the tallest thing on the page, so their scalar is the one that
   bites: at 1.35 a hard flick swung a crown 61px, several times the width
   of its own sway and by far the largest movement in the scene. These are
   layer-level variables, so they reach the hero and footer groves only —
   the gate keeps whatever it had. */
.palms--far { z-index: -7; bottom: 19%; height: 46%; opacity: .42; --windk-layer: .4; }
.palms--mid { z-index: -4; bottom: 14%; height: 62%; opacity: .78; --windk-layer: .55; }
.palms--near { z-index: -2; bottom: -2%; height: 88%; --windk-layer: .7; }

.palm {
  position: absolute; bottom: 0;
  aspect-ratio: 1 / 2;                /* matches the 200x400 viewBox */
  translate: -50% 0;
  transform-origin: 50% 100%;
  /* No will-change: there are 55 of these and none of them animate —
     the sway lives on SVG groups inside, whose transforms Chrome cannot
     composite anyway. Promoting all 55 only burned layer memory. */
}
.palm svg { width: 100%; height: 100%; overflow: visible; }

/* Nested pivots, all anchored at the trunk base (viewBox 100,400).
   Each runs extreme-to-extreme on `alternate` with a sine easing, so the
   motion is one continuous oscillation with no keyframe seam. */
.palm-sway {
  transform-box: view-box; transform-origin: 100px 400px;
  animation: palmSway var(--dur, 6s) var(--sine) infinite alternate var(--dl, 0s);
}
.palm-wind {
  transform-box: view-box; transform-origin: 100px 400px;
  transform: rotate(calc(var(--wind, 0deg) * var(--windk, 1) * var(--windk-layer, 1)));
}
.palm-crown {
  transform-box: view-box; transform-origin: 0 0;
  animation: crownSway var(--cdur, 5s) var(--sine) infinite alternate var(--cdl, 0s);
}
.frond {
  transform-box: view-box; transform-origin: 0 0;
  animation: frondSway var(--fdur, 4s) var(--sine) infinite alternate var(--fdl, 0s);
}
.coconuts { transform-box: view-box; transform-origin: 0 0; animation: coconutBob 4.5s ease-in-out infinite; }

/* Only the four hero/footer layers carry .palms — the grove in the gate is
   .gate__layer and is deliberately left out, so nothing here reaches it.
   --sine, not --ease: --ease front-loads its segment, which is exactly the
   wrong shape for wind. --gdl staggers each tree inside its own layer so
   the gust rolls across the row instead of the whole row lurching at once. */
.palms.is-gusting .palm-gust {
  transform-box: view-box; transform-origin: 100px 400px;
  animation: gustBend var(--gdur, 6s) var(--sine) var(--gdl, 0s) 1;
}

/* Parking the sway on groves nobody can see. Two separate reasons, two
   separate classes so they cannot overwrite one another: `is-still` is
   owned by the off-screen observer, `is-occluded` by the entry sequence
   for the scene hidden behind the gate. */
.is-still .palm-sway,    .is-occluded .palm-sway,
.is-still .palm-crown,   .is-occluded .palm-crown,
.is-still .frond,        .is-occluded .frond,
.is-still .coconuts,     .is-occluded .coconuts,
.is-still .palm-gust,    .is-occluded .palm-gust {
  animation-play-state: paused;
}

/* ============================================================
   PARKING THE SCENE
   ------------------------------------------------------------
   This page keeps around 297 animations running at once, 122 of
   them on SVG nodes. Chrome cannot composite a transform
   animation on an SVG <g>, so every one of those repaints its
   subtree on every frame for as long as the page is open. At
   100% zoom the rasterizer keeps up. At 300% it is being asked
   for nine times the pixels and it does not: tiles that are not
   ready yet are filled with the document's base colour, --ink,
   and on a near-black page that reads as black rectangles
   appearing over the content.

   Nothing below changes how the page looks when it is sitting
   still at 100%. They are all "stop drawing what nobody can
   currently see".
   ============================================================ */

/* 1. A whole scene that has left the viewport. Containers, so one
      class covers the stars, clouds, birds, sun, aurora and haze
      inside them rather than listing every one. */
.hero.is-still *,
.hero.is-still *::before,
.hero.is-still *::after,
.contact__scene.is-still *,
.contact__scene.is-still *::before,
.contact__scene.is-still *::after {
  animation-play-state: paused;
}

/* 2. A zoom in flight. Chrome is re-rasterizing everything, so give
      it the whole budget for the length of the gesture — there is
      nothing to see in a star twinkling mid-pinch. */
html.is-zooming *,
html.is-zooming *::before,
html.is-zooming *::after {
  animation-play-state: paused !important;
}

/* 3. Still zoomed in afterwards. The decorative micro-motion stays
      parked: 130 twinkling stars, 55 fronds, 13 coconuts, 12 wings
      and 6 sun bands — 216 of the 297 animations, none of which is
      legible at 200%+, all of which cost a repaint per frame at
      whatever resolution the zoom asks for. The structural motion
      (the trees' sway, the sun, the clouds, the birds' paths) keeps
      running, so the scene is still alive. */
html.is-zoomed .sky__stars i,
html.is-zoomed .frond,
html.is-zoomed .coconuts,
html.is-zoomed .bird i,
html.is-zoomed .sun__bands i,
/* The gradient-panning headings belong here for a second reason. They are
   `background-clip: text` with an animated background-position, and that
   pairing is a known way to make Chrome give up on the text clip and paint
   the gradient as a plain filled box instead — a coloured rectangle sitting
   over the words. Freezing the pan while zoomed leaves the gradient exactly
   where it is and takes the moving part, and the risk, away. */
html.is-zoomed .h2 em,
html.is-zoomed .hero__title .line--accent .char,
html.is-zoomed .btn--solid {
  animation-play-state: paused;
}
.gate.is-open .gate__layer { will-change: auto; }

/* Dusk falling on the land. This replaces a brightness() filter that was
   running on three mountain layers and the desert floor every frame —
   four full-width repaints per frame, for what one composited opacity
   does here. Sits above the far and mid palms, below the near ones. */
.hero__night {
  position: absolute; left: 0; right: 0; bottom: 0; height: 54%;
  z-index: -3; pointer-events: none;
  background: linear-gradient(180deg, rgba(10, 12, 18, 0) 0%, rgba(10, 12, 18, .74) 42%, rgba(10, 12, 18, .86) 100%);
  opacity: var(--dusk);
}

/* Both dusk layers are promoted only while --dusk is actually moving.
   js/main.js puts .dusk-moving on :root on the first real change and takes
   it off 220ms after the last one, so the scroll still gets its composited
   opacity and a still page — or a page being zoomed — carries no layer. */
.dusk-moving .sky__night,
.dusk-moving .hero__night { will-change: opacity; }

/* ---- dust + scrim ---- */
/* A canvas is a replaced element, so `inset: 0` alone does NOT stretch it —
   width/height:auto resolve to its intrinsic 300x150 and the insets are
   simply over-constrained. Without the explicit size the dust drew into a
   small box in the top-left corner and the motes vanished at its edge. */
.dust {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  z-index: -1; pointer-events: none;
}
.hero__vignette {
  position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background:
    /* held to the left third, where the copy is, so it no longer greys the sun */
    linear-gradient(100deg, rgba(18, 21, 28, .7) 0%, rgba(18, 21, 28, .34) 30%, transparent 47%),
    radial-gradient(120% 80% at 50% 45%, transparent 42%, rgba(18, 21, 28, .4) 100%),
    linear-gradient(180deg, rgba(18, 21, 28, .55) 0%, transparent 22%, transparent 72%, rgba(18, 21, 28, .85) 100%);
}

/* ============================================================
   HERO CONTENT
   ============================================================ */
.hero__content {
  position: relative; z-index: 3;
  width: min(100% - (var(--gutter) * 2), var(--maxw));
  margin-inline: auto;
  padding-block: 9rem 7rem;
}

.hero__eyebrow {
  display: inline-flex; align-items: center; gap: .65rem;
  font-size: .72rem; letter-spacing: .24em; text-transform: uppercase;
  color: var(--gold);
  padding: .45rem .95rem .45rem .8rem;
  border: 1px solid rgba(242, 181, 68, .34);
  border-radius: var(--r);
  /* This label sits directly on top of the hero — a full-viewport scene with
     swaying palms, drifting dust and a pulsing sun. A backdrop-filter there
     asks the compositor to snapshot and blur everything underneath on every
     frame that the backdrop changes, which is every frame. Under zoom that
     readback happens against a surface Chrome is still re-rasterizing, and
     what comes back is sometimes a black or stale buffer — the pill
     flickering, filling black, or vanishing outright.

     A slightly heavier translucent fill reads the same at rest and cannot
     fail, because there is nothing to read back. */
  background: rgba(18, 21, 28, .62);
}
.pin { position: relative; width: 6px; height: 6px; border-radius: 50%; background: var(--orange); flex: none; }
.pin::after {
  content: ""; position: absolute; inset: 0; border-radius: 50%;
  background: var(--orange); animation: pinPing 2.4s ease-out infinite;
}

.hero__title {
  font-family: "Syne", sans-serif; font-weight: 800;
  font-size: clamp(2.6rem, 8.2vw, 7.4rem);
  line-height: .9; letter-spacing: -.035em;
  margin: 1.25rem 0 1rem;
  perspective: 900px;
}
.hero__title .line { display: block; white-space: nowrap; }
.hero__title .char {
  display: inline-block;
  opacity: 0;
  transform: translateY(.5em) rotateX(-70deg);
  transform-origin: 50% 100%;
  filter: blur(7px);
  transition: opacity .9s var(--ease) var(--cd, 0s),
              transform .9s var(--ease) var(--cd, 0s),
              filter .9s var(--ease) var(--cd, 0s);
}
/* `filter: none`, not `filter: blur(0)`. A zero blur is still a filter, so
   every character in the title kept its own compositor layer for the whole
   session after the reveal had finished. `none` interpolates from blur(7px)
   exactly the same way — the transition is identical — and releases the
   layer when it lands. */
body.title-in .hero__title .char { opacity: 1; transform: none; filter: none; }
/* One flat ink for the accent line — the sun's colour, not a blend. Both
   lines carry a hard ink offset, the second impression of a two-colour
   print, which is also what keeps the orange line legible where it
   crosses the orange sun. */
.hero__title .line--accent .char { color: var(--orange); }
/* A hairline ink outline under the offset, so each letter keeps its own
   edge wherever it crosses the sun, the palms or a sky band. Shadows only,
   so it costs a paint and no layers. */
.hero__title .char {
  text-shadow:
    .045em .045em 0 var(--ink),
    1px 1px 0 var(--ink), -1px 1px 0 var(--ink),
    1px -1px 0 var(--ink), -1px -1px 0 var(--ink);
}

.hero__role {
  font-family: "Syne", sans-serif; font-weight: 600;
  font-size: clamp(1rem, 2.3vw, 1.4rem);
  color: var(--cream);
  min-height: 1.8em;
  display: flex; align-items: center; gap: .16rem;
}
.caret {
  display: inline-block; width: 2px; height: 1.05em; background: var(--orange);
  animation: caretBlink 1.05s steps(1) infinite;
}
.hero__blurb {
  max-width: 44ch; margin-top: 1rem;
  color: rgba(241, 230, 208, .76);
  font-size: clamp(.95rem, 1.4vw, 1.06rem);
}
.hero__cta { display: flex; flex-wrap: wrap; gap: .9rem; margin-top: 2.2rem; }

/* ---- buttons ---- */
.btn {
  position: relative; overflow: hidden; isolation: isolate;
  display: inline-flex; align-items: center; gap: .6rem;
  padding: .95rem 1.5rem; border-radius: var(--r);
  font-size: .9rem; letter-spacing: .02em;
  transition: transform .35s var(--ease), box-shadow .35s var(--ease), color .35s, border-color .35s;
  /* No will-change: a button only moves on hover, and promoting every
     button on the page permanently to save a few milliseconds on a hover
     that may never happen is a bad trade — especially under zoom, where
     each layer has to be re-rasterized at the new scale. */
}
.btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; transition: transform .35s var(--ease); }
.btn:hover svg { transform: translateX(4px); }
.btn span { position: relative; z-index: 1; }
.btn--solid {
  color: var(--ink); font-weight: 600;
  background: var(--orange);
  /* a printer's offset: a second, flat impression just below and right */
  box-shadow: 4px 4px 0 var(--ink), 4px 4px 0 1px var(--orange);
}
.btn--solid:hover { background: var(--cream); transform: translate(2px, 2px); box-shadow: 2px 2px 0 var(--ink), 2px 2px 0 1px var(--orange); }
/* Same story as .hero__eyebrow: this button sits on the animating hero, and
   its backdrop-filter was the other half of the "text and buttons
   disappearing" problem. Opaque fill, no readback, no failure mode. */
.btn--ghost {
  color: var(--cream);
  border: 1px solid var(--rule);
  background: rgba(18, 21, 28, .6);
}
.btn--ghost::before {
  content: ""; position: absolute; inset: 0; z-index: 0;
  background: var(--cream);
  transform: translateY(101%); transition: transform .45s var(--ease);
}
.btn--ghost:hover { color: var(--ink); font-weight: 600; border-color: transparent; }
.btn--ghost:hover::before { transform: translateY(0); }

/* ---- scroll cue ---- */
.scroll-cue {
  position: absolute; left: 50%; bottom: 1.6rem; translate: -50% 0; z-index: 4;
  display: grid; justify-items: center; gap: .6rem;
  opacity: calc(var(--heroIn, 1) * (1 - var(--dusk) * 2.4));
}
.scroll-cue__line { width: 1px; height: 46px; background: rgba(241, 230, 208, .2); position: relative; overflow: hidden; }
.scroll-cue__line::after {
  content: ""; position: absolute; inset: 0; background: var(--gold);
  animation: scrollNudge 2.2s var(--ease) infinite;
}
.scroll-cue__label {
  font-size: .6rem; letter-spacing: .3em; text-transform: uppercase;
  color: var(--muted); text-indent: .3em;
}

/* ============================================================
   RESPONSIVE
   ============================================================ */
@media (max-width: 900px) {
  .nav__links {
    position: fixed; inset: 0 0 auto; top: 0;
    flex-direction: column; align-items: center; justify-content: center;
    gap: 1.6rem; height: 100svh;
    /* No backdrop-filter. This breakpoint is not only phones — zooming the
       desktop page in far enough drops the CSS viewport below 900px and
       turns this panel on, so a full-screen backdrop readback would land
       exactly when the compositor is already busiest. At .96 alpha the blur
       behind it was very nearly invisible regardless; .985 is opaque enough
       to need no blur at all. */
    background: rgba(18, 21, 28, .985);
    clip-path: circle(0% at 100% 0%);
    transition: clip-path .6s var(--ease);
  }
  .nav.is-open .nav__links { clip-path: circle(150% at 100% 0%); }
  .nav__links a { font-size: 1.45rem; font-family: "Syne", sans-serif; font-weight: 700; }
  .nav__indicator { display: none; }
  .nav__toggle { display: grid; z-index: 2; }
  .nav__logo-text { display: none; }
  .hero__content { padding-block: 7.5rem 8rem; }
  .palms--near { height: 62%; }
  .palms--mid { height: 46%; }
  /* No cursor rule here on purpose: a narrow window is not the same thing
     as a touch device, and hiding our cursor by width alone would leave a
     mouse user with no pointer at all. Pointer type decides — see below. */
}

@media (max-width: 620px) {
  /* PRESTON is the long line — 7 glyphs must clear both gutters */
  .hero__title { font-size: min(11.4vw, 3.4rem); }
  .hero__eyebrow { font-size: .58rem; letter-spacing: .13em; padding: .4rem .75rem .4rem .65rem; }
  .hero__role { font-size: 1.05rem; }
  .sun { bottom: 26%; width: clamp(200px, 62vw, 320px); }
  .scroll-cue__label { font-size: .55rem; }
  /* the cloud strata are sized for a wide sky; on a phone they crowd it */
  .clouds { display: none; }
  /* On a phone the blurb lands on the sun and the palms, so it gets the
     printed treatment of a caption block: a flat ink plate and a rule. */
  .hero__blurb {
    padding: .75rem .9rem;
    background: rgba(18, 21, 28, .8);
    border-left: 2px solid var(--orange);
  }
}

@media (hover: none) {
  .cursor, .cursor-ring { display: none; }
}
