/* ============================================================
   Cyber-Oracle — jasonosaj.me
   Dark, nocturnal, technical. Nature fused with data.
   One theme (dark), one accent (icy blue), violet + antique gold rare.
   Hand-written; no framework. Motion is restrained and reduced-motion aware.
   ============================================================ */

/* ---- Fonts (self-hosted, no CDN at runtime) ---- */
@font-face { font-family: "Space Grotesk"; src: url("/assets/fonts/space-grotesk-500.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap; }
@font-face { font-family: "Space Grotesk"; src: url("/assets/fonts/space-grotesk-700.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap; }
@font-face { font-family: "Newsreader"; src: url("/assets/fonts/newsreader-400.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: "Newsreader"; src: url("/assets/fonts/newsreader-400-italic.woff2") format("woff2");
  font-weight: 400; font-style: italic; font-display: swap; }
@font-face { font-family: "Newsreader"; src: url("/assets/fonts/newsreader-600.woff2") format("woff2");
  font-weight: 600; font-style: normal; font-display: swap; }
@font-face { font-family: "Space Mono"; src: url("/assets/fonts/space-mono-400.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap; }

/* ---- Tokens ---- */
:root {
  --bg:        #0B0D10;   /* near-black ground */
  --panel:     #12161C;   /* charcoal panel */
  --elev:      #171C24;   /* elevated panel */
  --hair:      rgba(233, 238, 245, 0.10);   /* silver hairline */
  --hair-2:    rgba(233, 238, 245, 0.16);

  --text:      #E9EEF5;   /* moonlight white */
  --dim:       #AEB7C4;   /* dimmed body (verified >=4.5:1 on --bg) */
  --faint:     #7C8797;   /* meta / captions (large text only) */

  --accent:    #7FC4FF;   /* icy blue — the single accent */
  --accent-ink:#0B1622;   /* text on accent fills */
  --violet:    #B9A6FF;   /* rare second accent (MAI) */
  --gold:      #CBAA6E;   /* antique gold — bridges to the Gardener-era cards, rare */
  --road:      #E8B55C;   /* lit gold — the movement thread, a path you travel */

  /* Brushed-silver material for the wordmark and single accent words. */
  --chrome-grad: linear-gradient(100deg, #A8B2C0 0%, #FFFFFF 20%, #C3D0E0 38%,
    #EAF2FB 54%, #A8B2C0 74%, #FFFFFF 100%);

  --ff-display:"Space Grotesk", ui-sans-serif, system-ui, sans-serif;
  --ff-body:   "Newsreader", Georgia, "Times New Roman", serif;
  --ff-mono:   "Space Mono", ui-monospace, "SFMono-Regular", monospace;

  --wrap: 1200px;
  /* The journal's binding, defined once. The hero spine and every entry-card
     spine are the SAME coil - previously 2px/16px and 2px/5px, which read as two
     different bindings on one page. Anything drawing a coil uses these three. */
  --coil-rung:   2px;    /* height of one ring */
  --coil-period: 16px;   /* ring to ring */
  --coil-w:      14px;   /* how far a ring crosses the page */
  --coil-ink:    rgba(203, 170, 110, .34);
  /* Radius scale, documented so the mixed values are a rule and not drift:
     panels + buttons = --r, imagery = --r-img, chat bubbles = --r-bubble. */
  --r: 4px;
  --r-img: 2px;
  --r-bubble: 12px;

  --s1: 0.5rem;  --s2: 0.875rem;  --s3: 1.25rem;  --s4: 2rem;
  --s5: 3rem;    --s6: 4.5rem;    --s7: 7rem;

  /* ---- Motion ------------------------------------------------------------
     The curves are the cubic-bezier equivalents of the GSAP eases this codebase
     already uses, so the CSS layer and the GSAP layer speak ONE language.
     Only the three the stylesheet actually needs are declared. If CSS ever has
     to match the other two GSAP eases in use, these are their values:
       expo.out     = cubic-bezier(.19,1,.22,1)    (trellis members, fx.js)
       power3.inOut = cubic-bezier(.77,0,.175,1)   (book open, journal.js)
     Durations are named by ROLE, not by number. Exit is deliberately ~65% of
     enter so dismissals feel quicker than arrivals. */
  --ease-out:      cubic-bezier(.215,.61,.355,1);   /* = gsap power2.out */
  --ease-out-firm: cubic-bezier(.165,.84,.44,1);    /* = gsap power3.out */
  /* INTERACTION feedback only - press, hover, focus. Deliberately NOT --ease-out:
     that one is pinned to gsap's power2.out so the CSS reveals and the GSAP
     choreography agree, and it is too soft for something the pointer is waiting
     on. This curve has covered 68% of the distance a fifth of the way in, where
     power2.out has covered 51%. Scroll narrative keeps --ease-out; anything
     answering a pointer uses this. */
  --ease-ui:       cubic-bezier(.23,1,.32,1);
  --ease-exit:     cubic-bezier(.4,0,1,1);          /* accelerate away */

  --dur-micro: .12s;   /* press */
  --dur-fast:  .2s;    /* hover, colour */
  --dur-exit:  .28s;   /* leaving */
  --dur-base:  .35s;   /* state change */
  --dur-mid:   .45s;   /* small move */
  --dur-enter: .6s;    /* element arriving */
  --dur-slow:  .8s;    /* large reveal */
  --dur-draw:  1.6s;   /* motif line draw */

  /* ---- Type scale --------------------------------------------------------
     One step per ROLE. Replaces 38 distinct values across 65 declarations. */
  --fs-mono-xs: .68rem;
  --fs-mono:    .72rem;
  --fs-mono-lg: .78rem;
  --fs-ui:      .95rem;
  --fs-ui-lg:   1.06rem;
  /* Exactly 16px. Anything smaller makes iOS zoom the page on focus, so this
     one is pinned and must not be folded into --fs-ui. */
  --fs-input:   1rem;
  --fs-body:    clamp(1.02rem, .98rem + .25vw, 1.15rem);
  --fs-body-lg: clamp(1.06rem, 1rem + .3vw, 1.2rem);
  --fs-lede:    clamp(1.15rem, 1rem + .7vw, 1.5rem);
  --fs-h4:      clamp(1.1rem, .95rem + .6vw, 1.45rem);
  --fs-h3:      clamp(1.2rem, 1rem + .9vw, 1.75rem);
  --fs-h2:      clamp(1.5rem, 1.2rem + 1.1vw, 2.1rem);
  --fs-h2-lg:   clamp(1.8rem, 1.35rem + 1.9vw, 2.7rem);
  --fs-h1:      clamp(2.2rem, 1.5rem + 3vw, 3.6rem);
  --fs-display: clamp(1.4rem, 1rem + 1.9vw, 2.5rem);
}

/* ---- Reset ---- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
/* overflow-x on BOTH html and body, and clip rather than hidden.
   hidden on body alone is the recipe that does not hold on iOS Safari: the page
   still pans sideways there even when every overflowing child is clipped by a
   local ancestor, which is exactly what was measured - .cf-card inside
   .coverflow-wrap, .gate-media inside .gate, .tr-pane inside .trellis-stage, and
   scrollWidth equal to clientWidth in the desktop preview while a phone still
   scrolled right.
   clip, not hidden: next to overflow-y: visible, hidden computes to auto and the
   scrollbar returns; clip also avoids creating a scroll container, which would
   break the position: sticky the trellis stage falls back to without GSAP.
   This was briefly removed on the theory that it broke the hero pin. It does not:
   an A/B across the scrub, with refresh() applied to both branches, was identical.
   The pin jump was dvh, fixed at .trellis and .gates. */
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; overflow-x: clip; }
body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--ff-body);
  font-size: var(--fs-body);
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;      /* fallback: pre-Safari-16, pre-Chrome-90 */
  overflow-x: clip;        /* the one that actually holds, see the html rule */
}
img, svg, iframe { display: block; max-width: 100%; }
img { height: auto; border-radius: var(--r-img); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }
h1, h2, h3, h4 { font-family: var(--ff-display); font-weight: 700; line-height: 1.08;
  letter-spacing: -0.015em; text-wrap: balance; }
strong { color: var(--text); font-weight: 600; }
em { font-style: italic; }
hr { border: 0; border-top: 1px solid var(--hair); margin: var(--s4) 0; }

/* ---- Atmospheric ground: static starfield + faint data-grid ----
   The static gradient (::before) is the always-present base and the fallback
   when the canvas/WebGL field in #fx-field can't run (reduced-motion, no-JS,
   WebGL failure). fx.js paints the living node-graph + nebula into #fx-field. */
body::before {
  content: ""; position: fixed; inset: 0; z-index: -3; pointer-events: none;
  background:
    radial-gradient(1px 1px at 18% 24%, rgba(233,238,245,.35), transparent 60%),
    radial-gradient(1px 1px at 72% 16%, rgba(233,238,245,.28), transparent 60%),
    radial-gradient(1px 1px at 44% 62%, rgba(233,238,245,.22), transparent 60%),
    radial-gradient(1px 1px at 88% 74%, rgba(233,238,245,.30), transparent 60%),
    radial-gradient(1px 1px at 8% 82%, rgba(233,238,245,.20), transparent 60%),
    radial-gradient(1px 1px at 60% 40%, rgba(127,196,255,.28), transparent 60%),
    radial-gradient(1200px 700px at 78% -8%, rgba(127,196,255,.06), transparent 70%),
    radial-gradient(900px 600px at 8% 108%, rgba(185,166,255,.05), transparent 70%);
}
body::after {
  content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; opacity: .5;
  background-image:
    linear-gradient(var(--hair) 1px, transparent 1px),
    linear-gradient(90deg, var(--hair) 1px, transparent 1px);
  background-size: 64px 64px;
  -webkit-mask-image: radial-gradient(120% 90% at 50% 0%, #000 15%, transparent 72%);
          mask-image: radial-gradient(120% 90% at 50% 0%, #000 15%, transparent 72%);
}

/* ============================================================
   Phase 4 — GSAP foundation: smooth scroll + native page transitions
   ============================================================ */

/* ScrollSmoother wrapper. Inert by default: with no smoother these are plain
   divs and the page lays out exactly as before. fx.js adds html.smooth only
   once ScrollSmoother actually initialises. */
#smooth-wrapper, #smooth-content { width: 100%; }
:root.smooth .site-head { position: fixed; left: 0; right: 0; }
:root.smooth #smooth-content { padding-top: var(--head-h, 64px); }

/* Cross-document view transitions. ~5 lines for a genuinely premium page
   change, no library and no routing hijack; unsupported browsers simply
   navigate normally. */
@view-transition { navigation: auto; }
::view-transition-old(root) { animation: vt-out var(--dur-exit) var(--ease-exit) both; }
::view-transition-new(root) { animation: vt-in var(--dur-exit) var(--ease-out) both; }
@keyframes vt-out { to { opacity: 0; transform: translateY(-8px); } }
@keyframes vt-in { from { opacity: 0; transform: translateY(10px); } }
/* Chrome persists across the transition instead of cross-fading with the page */
.site-head { view-transition-name: site-head; }
/* The gate -> hero morph. Slower than the root cross-fade on purpose: this one is
   a camera move into a space, and at --dur-exit it reads as a glitch rather than
   a movement. The UA default cross-fades the two images while it morphs the box,
   which is exactly what is wanted, so only the timing is overridden. */
::view-transition-group(gate-media) {
  animation-duration: var(--dur-slow); animation-timing-function: var(--ease-out-firm); }
#fx-field { view-transition-name: fx-field; }

@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}

/* ---- Layout ---- */
.wrap { width: 100%; max-width: var(--wrap); margin-inline: auto; padding-inline: clamp(1.1rem, 4vw, 2.5rem); }
.section { padding-block: clamp(3.5rem, 8vw, var(--s7)); }
.section + .section { padding-top: 0; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* ---- Shared type helpers ---- */
/* p.eyebrow (0,1,1) wins over container `p` color rules like .section-head p */
.eyebrow, p.eyebrow { font-family: var(--ff-mono); font-size: var(--fs-mono); letter-spacing: .22em;
  text-transform: uppercase; color: var(--accent); margin-bottom: var(--s2); }
.lede { font-size: var(--fs-lede); line-height: 1.5; color: var(--dim); max-width: 34ch; }
.prose { max-width: 66ch; }
.prose > * + * { margin-top: var(--s3); }
.prose h2 { font-size: var(--fs-h2); margin-top: var(--s5); }
.prose h3 { font-size: var(--fs-h4); margin-top: var(--s4); color: var(--text); }
.prose p, .prose li { color: var(--dim); }
.prose a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.prose ul, .prose ol { padding-left: 1.2em; }
.prose li + li { margin-top: .4em; }
.prose blockquote { border-left: 2px solid var(--gold); padding-left: var(--s3);
  font-style: italic; color: var(--text); }

/* ---- Skip link ---- */
.skip { position: fixed; top: -100px; left: 1rem; z-index: 100; background: var(--accent);
  color: var(--accent-ink); padding: .6rem 1rem; border-radius: var(--r); font-family: var(--ff-display); }
.skip:focus { top: 1rem; }

/* ---- Focus ---- */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--r-img); }

/* ---- Buttons ---- */
.btn {
  --bg-btn: transparent;
  display: inline-flex; align-items: center; gap: .5em; white-space: nowrap;
  font-family: var(--ff-display); font-weight: 500; font-size: var(--fs-ui); line-height: 1;
  padding: .85em 1.4em; border-radius: var(--r); border: 1px solid var(--hair-2);
  color: var(--text); background: var(--bg-btn);
  transition: transform var(--dur-micro) var(--ease-ui), border-color var(--dur-fast) var(--ease-ui), background var(--dur-fast) var(--ease-ui), color var(--dur-fast) var(--ease-ui);
}
.btn:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn-solid { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn-solid:hover { background: #9BD1FF; border-color: #9BD1FF; color: var(--accent-ink); }
.btn-lg { padding: 1em 1.7em; font-size: var(--fs-ui-lg); }
.btn-head { padding: .6em 1.1em; }

/* ---- Header ---- */
.site-head { position: sticky; top: 0; z-index: 50;
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  backdrop-filter: blur(10px); border-bottom: 1px solid var(--hair); }
.head-inner { display: flex; align-items: center; gap: var(--s3); min-height: 64px; }
/* Wordmark: NOSAJASON as machined silver ("silver hardware", per MAI canon).
   The gradient's darkest stop is #A8B2C0 so the mark still clears 4.5:1 even
   sitting over the lit part of a hero photo. */
.brand { font-family: var(--ff-display); font-weight: 700; font-size: var(--fs-ui-lg);
  text-transform: uppercase; letter-spacing: .085em; }
.brand, .foot-name { background: var(--chrome-grad); background-size: 260% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  animation: sheen 8s linear infinite; }
.brand:hover { text-decoration: none; filter: brightness(1.18); }
.foot-name { font-family: var(--ff-display); font-weight: 700; font-size: var(--fs-ui-lg);
  text-transform: uppercase; letter-spacing: .085em; }
.nav { margin-left: auto; }
.nav ul { display: flex; gap: clamp(.9rem, 2vw, 1.6rem); list-style: none; padding: 0; }
.nav a { font-family: var(--ff-display); font-weight: 500; font-size: var(--fs-ui); color: var(--dim);
  padding-block: .4rem; }
.nav a:hover { color: var(--text); text-decoration: none; }
.head-actions { display: flex; align-items: center; gap: var(--s2); }
.ask-mai[hidden] { display: none; }
.nav-toggle { display: none; }

/* active nav state per page (set via body[data-page]) */
body[data-page="movement"]  .nav a[data-nav="movement"],
body[data-page="sound"]     .nav a[data-nav="sound"],
body[data-page="journal"]   .nav a[data-nav="journal"],
body[data-page="resources"] .nav a[data-nav="resources"],
body[data-page="about"]     .nav a[data-nav="about"] { color: var(--text); }
body[data-page="movement"]  .nav a[data-nav="movement"]::after,
body[data-page="sound"]     .nav a[data-nav="sound"]::after,
body[data-page="journal"]   .nav a[data-nav="journal"]::after,
body[data-page="resources"] .nav a[data-nav="resources"]::after,
body[data-page="about"]     .nav a[data-nav="about"]::after {
  content: ""; display: block; height: 1px; background: var(--accent); margin-top: 3px; }

/* ---- Hero ---- */
.hero { position: relative; padding-top: clamp(2.5rem, 6vw, 5.5rem);
  padding-bottom: clamp(3rem, 7vw, 6rem); }
.hero-grid { display: grid; grid-template-columns: 1.25fr 0.75fr; gap: clamp(1.5rem, 4vw, 4rem);
  align-items: center; }
.hero h1 { font-size: var(--fs-h1); }
.hero .lede { margin-top: var(--s3); max-width: 40ch; }
.hero-actions { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s4); }
.hero-figure { position: relative; }
.hero-figure img { width: 100%; border: 1px solid var(--hair-2); }
.hero-figure figcaption { font-family: var(--ff-mono); font-size: var(--fs-mono); color: var(--faint);
  margin-top: var(--s2); letter-spacing: .04em; }

/* ---- Section head ---- */
.section-head { max-width: 52ch; margin-bottom: var(--s5); }
.section-head h2 { font-size: var(--fs-h2-lg); }
.section-head p { color: var(--dim); margin-top: var(--s3); }

/* ---- Split feature (asymmetric text + image) ---- */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(1.5rem, 5vw, 4.5rem); align-items: center; }
.split.reverse > :first-child { order: 2; }
.split-media img { width: 100%; border: 1px solid var(--hair-2); }
.split-body h2 { font-size: var(--fs-h2-lg); }
.split-body > * + * { margin-top: var(--s3); }
.split-body p { color: var(--dim); }

/* ---- Principles (numbered node list) ---- */
.principles { list-style: none; padding: 0; display: grid; gap: 0;
  border-top: 1px solid var(--hair); }
.principles li { display: grid; grid-template-columns: auto 1fr; gap: var(--s3);
  padding: var(--s3) 0; border-bottom: 1px solid var(--hair); align-items: baseline; }
.principles .n { font-family: var(--ff-mono); font-size: var(--fs-mono-lg); color: var(--accent); }
.principles h3 { font-size: var(--fs-h4); color: var(--text); display: inline; }
.principles p { color: var(--dim); margin-top: .3rem; }
/* That margin exists to clear the h3 above it on /about/. The EPK's facts list
   uses these rows as label/value with no h3, so the p is first-child there and
   the margin pushed the value 5px off the label's baseline. */
.principles li > div > p:first-child { margin-top: 0; }

/* ---- Releases (sound / EPK) ---- */
.releases { display: grid; gap: 0; border-top: 1px solid var(--hair); }
.release { display: grid; grid-template-columns: 1fr auto; gap: var(--s3) var(--s4);
  padding: var(--s4) 0; border-bottom: 1px solid var(--hair); align-items: start; }
.release h3 { font-size: var(--fs-h4); color: var(--text); }
/* Deliberately NOT scoped to .release. It was, and the three uses outside one -
   the "PDF - free" tags at resources.html:57, resources.html:73 and
   movement.html:162 - were silently rendering as 18.4px Newsreader body serif
   instead of a small mono tag. The embed captions on /sound/ rely on this too. */
.rel-label { font-family: var(--ff-mono); font-size: var(--fs-mono); color: var(--faint);
  letter-spacing: .08em; text-transform: uppercase; margin-bottom: var(--s1); }
/* :not(.rel-label) so this cannot fight the rule above. De-scoping .rel-label
   dropped it to (0,1,0), which LOSES to `.release p` at (0,1,1) - the labels
   silently turned --dim. Excluding them here keeps the two rules disjoint
   instead of escalating specificity. */
.release p:not(.rel-label) { color: var(--dim); margin-top: var(--s1); max-width: 52ch; }
.release .rel-links { display: flex; flex-wrap: wrap; gap: var(--s3); margin-top: var(--s2);
  font-family: var(--ff-display); font-size: var(--fs-ui); }
/* margin:0 because .embed is a <figure> on /sound/ and the UA gives figures a
   40px inline margin. There is no global figure reset in this stylesheet. */
.embed { position: relative; margin: 0; }
.embed iframe { width: 100%; border: 1px solid var(--hair-2); border-radius: var(--r-img); display: block; }
/* Video players are 16:9 at ANY width, so the same rule serves the full-bleed
   feature and the half-width one in the pair. The SoundCloud player is not a
   video and keeps its fixed height="300" instead. */
.embed--video iframe { aspect-ratio: 16 / 9; height: auto; }
.embed figcaption { margin-top: var(--s2); }

/* ---- Article (single post) ---- */
.article { padding-block: clamp(2.5rem, 6vw, 4.5rem); }
.article-head { max-width: 66ch; margin-bottom: var(--s5); }
.article-head h1 { font-size: var(--fs-h2-lg); }
.article-head .meta { font-family: var(--ff-mono); font-size: var(--fs-mono-lg); color: var(--faint);
  letter-spacing: .06em; margin-bottom: var(--s3); text-transform: uppercase; }
.article-body { font-size: var(--fs-body-lg); }
.article-body h2 { color: var(--text); }
.back-link { font-family: var(--ff-mono); font-size: var(--fs-mono-lg); letter-spacing: .05em; }

/* ---- Resources (downloads) ---- */
.resource { display: grid; grid-template-columns: minmax(140px, 200px) 1fr; gap: var(--s4);
  padding: var(--s4); border: 1px solid var(--hair); border-radius: var(--r); background: var(--panel);
  align-items: start; }
.resource + .resource { margin-top: var(--s3); }
/* These are the real book covers, and they carry their own palettes (black/yellow,
   green) that deliberately do NOT match the site's single-accent lock. Rather than
   recolour them — which would misrepresent the actual PDFs — they are presented as
   OBJECTS sitting on the dark ground, the way sleeve art sits on a music site.
   A gold hairline, a lift shadow and a spine edge do that work. */
/* ---- The shelf ----
   /resources/'s member, and it now holds the actual goods. The previous version
   was a 1px rule floating inside the copy block at an arbitrary height, aligned
   to nothing, which is exactly why it read as a stray underline. */
.hero-shelf { min-height: min(92dvh, 780px); }
.shelf-grid { display: grid; grid-template-columns: 1fr .9fr;
  gap: clamp(2rem, 6vw, 5rem); align-items: center; }
.shelf { position: relative; display: flex; align-items: flex-end;
  justify-content: center; gap: 0; padding-bottom: 26px; }
.shelf-cover { width: clamp(120px, 15vw, 190px); height: auto; display: block;
  border: 1px solid rgba(203,170,110,.5); border-radius: var(--r-img);
  box-shadow: 0 26px 54px rgba(0,0,0,.62), 0 3px 10px rgba(0,0,0,.5);
  transition: transform var(--dur-enter) var(--ease-ui); }
/* two objects standing on a shelf: angled, overlapping, the near one in front */
.shelf-cover-a { transform: rotate(-6deg) translateY(4px); z-index: 1; }
.shelf-cover-b { transform: rotate(5deg); margin-left: -14%; z-index: 2; }
/* Hover MOTION is gated: a touch tap fires a false :hover that then sticks
   until you tap elsewhere, so the covers would lift and stay lifted on a phone. */
@media (hover: hover) and (pointer: fine) {
  .shelf:hover .shelf-cover-a { transform: rotate(-8deg) translateY(-4px); }
  .shelf:hover .shelf-cover-b { transform: rotate(7deg) translateY(-4px); }
}
/* the ledge they stand on: full width of the shelf, with the light under them */
.shelf-ledge { position: absolute; left: -6%; right: -6%; bottom: 20px; height: 1px;
  transform-origin: 0 50%; pointer-events: none;
  background: linear-gradient(90deg, transparent, rgba(203,170,110,.62) 18%,
    rgba(203,170,110,.62) 82%, transparent); }
.shelf-ledge::after { content: ""; position: absolute; left: 12%; right: 12%; top: 0;
  height: 42px; background: radial-gradient(60% 100% at 50% 0,
    rgba(203,170,110,.16), transparent 70%); }
:root.js .shelf-ledge { animation: ledge-draw var(--dur-slow) var(--ease-out-firm) both; }
:root.js .shelf-cover { animation: shelf-set var(--dur-enter) var(--ease-out-firm) both; }
.shelf-cover-b { animation-delay: .12s; }
@keyframes ledge-draw { from { transform: scaleX(0); } }
@keyframes shelf-set { from { opacity: 0; transform: translateY(18px) rotate(0deg); } }

@media (max-width: 860px) {
  .shelf-grid { grid-template-columns: 1fr; }
  .shelf { padding-bottom: 18px; }
  .shelf-cover { width: clamp(104px, 32vw, 150px); }
}

.resource-cover { position: relative; }
.resource-cover img { display: block; width: 100%; border: 1px solid rgba(203,170,110,.5);
  border-radius: var(--r-img);
  box-shadow: 0 18px 40px rgba(0,0,0,.55), 0 2px 6px rgba(0,0,0,.4); }
/* the stacked page edges of a real book, on the binding side */
.resource-cover::after { content: ""; position: absolute; left: -3px; top: 5%; bottom: 5%;
  width: 3px; border-radius: var(--r-img) 0 0 var(--r-img); pointer-events: none;
  background: repeating-linear-gradient(180deg,
    rgba(203,170,110,.34) 0 2px, transparent 2px 5px); }
.resource h3 { font-size: var(--fs-h3); color: var(--text); }
.resource p { color: var(--dim); margin-top: var(--s2); }
.resource-actions { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s3); align-items: center; }

/* ---- Email opt-in (optional, non-blocking) ---- */
.optin { margin-top: var(--s5); padding: var(--s4); border: 1px solid var(--hair); border-radius: var(--r);
  background: var(--elev); max-width: 48rem; }
.optin h3 { color: var(--text); font-size: var(--fs-h4); }
.optin p { color: var(--dim); margin-top: var(--s1); font-size: var(--fs-ui); }
.optin form { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s3); align-items: center; }
.optin input[type="email"] { flex: 1 1 16rem; background: var(--bg); color: var(--text);
  border: 1px solid var(--hair-2); border-radius: var(--r); padding: .8em 1em; font-family: var(--ff-body);
  font-size: var(--fs-input); }
.optin input::placeholder { color: var(--faint); }
.optin input:focus-visible { border-color: var(--accent); }
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

/* ---- Book (Cal.com) ---- */
.book-embed { border: 1px solid var(--hair-2); border-radius: var(--r); overflow: hidden;
  background: var(--panel); min-height: 640px; }
.book-embed iframe { width: 100%; min-height: 640px; border: 0; }

/* ---- CTA band ---- */
/* The scrim runs ACROSS, not down. A flat .86-.90 overlay was hiding the
   photograph completely - measured, it lifted the band by +0.6 luma out of 255,
   so 197KB shipped on every page for something invisible. It could not simply be
   lightened either: --dim body copy falls to 3.53:1 at a flat .68, under the
   4.5:1 floor.
   Directional solves both, because the band's own layout cooperates: .cta-inner
   is 1fr auto, so the heading and copy sit LEFT while the button sits RIGHT -
   and this photograph's left third is empty sky with the moon and detail right
   of centre. So the left stays heavily scrimmed for the type and the right opens
   up behind the button, which is solid --accent and needs no protection.
   Measured at 1440x314: --dim holds 7.28:1 while the photograph's contribution
   goes from +2.8 to +9.4. The 10% crop keeps the moon in frame. */
.cta-band { border-top: 1px solid var(--hair); border-bottom: 1px solid var(--hair);
  background:
    radial-gradient(700px 300px at 85% 0%, rgba(127,196,255,.09), transparent 70%),
    linear-gradient(90deg, rgba(11,13,16,.92) 0%, rgba(11,13,16,.88) 55%,
      rgba(11,13,16,.35) 100%),
    url("/assets/img/atmo-forest.jpg") center 10% / cover no-repeat,
    var(--panel);
  padding-block: clamp(3rem, 6vw, 5rem); }
.cta-inner { display: grid; grid-template-columns: 1fr auto; gap: var(--s4); align-items: center; }
.cta-inner h2 { font-size: var(--fs-h2-lg); }
.cta-copy { color: var(--dim); margin-top: var(--s2); max-width: 46ch; }

/* ---- Footer ---- */
.site-foot { border-top: 1px solid var(--hair); margin-top: 0; padding-top: var(--s6); padding-bottom: var(--s4); }
.foot-inner { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: var(--s4); }
.foot-name { font-family: var(--ff-display); font-weight: 700; font-size: var(--fs-ui-lg); color: var(--text); }
.foot-tag { color: var(--dim); margin-top: var(--s1); max-width: 34ch; }
.foot-loc { color: var(--faint); font-family: var(--ff-mono); font-size: var(--fs-mono-lg); margin-top: var(--s2); }
.foot-nav ul, .social { list-style: none; padding: 0; display: grid; gap: .5rem; }
.foot-nav a, .social a { color: var(--dim); font-family: var(--ff-display); font-size: var(--fs-ui); }
.foot-nav a:hover, .social a:hover { color: var(--accent); text-decoration: none; }
.foot-contact { display: grid; gap: var(--s3); align-content: start; }
.social { margin-top: 0; }
.foot-motto { color: var(--faint); font-family: var(--ff-mono); font-size: var(--fs-mono); letter-spacing: .04em;
  margin-top: var(--s6); padding-top: var(--s3); border-top: 1px solid var(--hair); }

/* ---- Scroll reveal (progressive enhancement) ----
   Hidden state is gated on html.js (set inline in <head>) so no-JS visitors
   always see content. JS adds .in on intersection; reduced-motion shows all. */
.reveal { transition: opacity var(--dur-slow) var(--ease-out), transform var(--dur-slow) var(--ease-out); }
:root.js .reveal { opacity: 0; transform: translateY(18px); }
:root.js .reveal.in { opacity: 1; transform: none; }

/* ============================================================
   Phase 2 — Visual uplevel (fx.js drives these; all degrade to static)
   ============================================================ */

/* ---- Living atmospheric field (canvas node-graph + WebGL nebula) ---- */
#fx-field { position: fixed; inset: 0; z-index: -2; pointer-events: none; overflow: hidden; }
#fx-field .fx-nebula, #fx-field .fx-nodes { position: absolute; inset: 0; width: 100%; height: 100%; }
#fx-field .fx-nebula { z-index: 0; }
#fx-field .fx-nodes  { z-index: 1; }

/* ---- Full-bleed hero ---- */
.hero-full { position: relative; isolation: isolate; display: flex; align-items: center;
  min-height: min(88vh, 760px); overflow: hidden;
  padding-block: clamp(3rem, 8vw, 6rem); }
.hero-bg { position: absolute; inset: -3%; z-index: -2; will-change: transform;
  background:
    radial-gradient(120% 90% at 78% 8%, rgba(127,196,255,.12), transparent 58%),
    radial-gradient(100% 85% at 6% 105%, rgba(185,166,255,.09), transparent 64%),
    var(--bg); }
/* Two per-page dials, both defaulting to today's values so every existing hero
   is untouched. --focus picks which band of a portrait photo survives the
   cover() crop; --photo-op lifts a subject that reads too dim at .5. The scrim
   is what protects the copy, not the opacity: measured behind the /about/
   headline, .8 still returns 6.5:1 against the 4.5:1 floor. */
.hero-photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  object-position: center var(--focus, 50%);
  opacity: var(--photo-op, .5); filter: saturate(1.05) contrast(1.02); }
.hero-sound .hero-photo { opacity: .72; }
.hero-scrim { position: absolute; inset: 0; z-index: -1;
  background:
    linear-gradient(180deg, rgba(11,13,16,.5), rgba(11,13,16,.32) 42%, rgba(11,13,16,.82)),
    linear-gradient(90deg, rgba(11,13,16,.72), rgba(11,13,16,.15) 62%); }
.hero-inner { position: relative; width: 100%; }
.hero-full .hero-grid { display: grid; grid-template-columns: 1.25fr .75fr;
  gap: clamp(1.5rem, 4vw, 4rem); align-items: center; }
.hero-full h1 { font-size: var(--fs-h1); }
.hero-full .lede { margin-top: var(--s3); max-width: 42ch; }

/* Hero headline word reveal — fx.js only splits into words; the animation is
   pure CSS with fill:both so it always ENDS visible, even if nothing toggles a
   class. (A JS-toggled reveal risked leaving the headline permanently hidden.) */
.hero-full h1 .word, .trellis-copy h1 .word { display: inline-block; }
/* Motion only when GSAP is NOT driving it — otherwise the two would double up. */
:root:not(.gsap) .hero-full h1 .word,
:root:not(.gsap) .trellis-copy h1 .word {
  animation: word-in var(--dur-enter) var(--ease-out-firm) both;
  animation-delay: calc(var(--i, 0) * 65ms); }
@keyframes word-in { from { opacity: 0; transform: translateY(.5em); } to { opacity: 1; transform: none; } }

/* ---- Liquid-metal / chrome accent ----
   One shared material. Floor raised to #A8B2C0 (was #7f8a99) so chromed text
   stays >=4.5:1 anywhere it is used, including over a scrimmed hero photo. */
.chrome { background: var(--chrome-grad); background-size: 260% 100%;
  -webkit-background-clip: text; background-clip: text;
  color: transparent; animation: sheen 7s linear infinite; }
@keyframes sheen { to { background-position: -260% 0; } }

/* ---- 3D coverflow oracle gallery ---- */
/* The fan is SUPPOSED to run off the screen - that is what makes it read as depth
   rather than a row - so it has to be CONTAINED, not narrowed, or it drags a
   sideways scroll onto every phone.
   Contained by going FULL-BLEED first, then clipping. Clipping this wrapper at its
   natural width does not work: it is inset by the .wrap padding, so the cut lands
   18px short of the screen on a phone and draws a hard vertical line with the outer
   cards sliced against it - a stray border in the middle of the page. The negative
   margin pulls the box out to the viewport edges, so the clip edge IS the screen
   edge and there is nothing to see.
   50% - 50vw is the standard full-bleed escape: 50% of the parent minus half the
   viewport, applied to both sides. On desktop the fan is narrower than the wrap, so
   nothing is clipped there and this changes only where the boundary sits.
   Nothing inside needs the wrap's padding back: the cards, the prev/next controls
   and the caption are all centred, so going edge to edge moves none of them.
   Known ceiling: 100vw INCLUDES the scrollbar and 50% does not, so wherever a
   scrollbar is shown this box overshoots the viewport by half of it - measured at
   8px per side on a 1265px desktop. Harmless, because html/body clip absorbs it
   and the fan is nowhere near the edge at that width. If it ever needs to be exact,
   the fix is a --scrollbar-w custom property set once from JS, not more vw math. */
.coverflow-wrap { position: relative;
  margin-inline: calc(50% - 50vw); overflow-x: clip; }
.coverflow { position: relative; display: flex; align-items: center; gap: var(--s3);
  overflow-x: auto; padding-block: var(--s3); scroll-snap-type: x mandatory; }  /* flat fallback */
.coverflow .cf-card { position: static; flex: 0 0 auto; width: min(46vw, 190px);
  aspect-ratio: 603 / 921; scroll-snap-align: center; cursor: pointer;
  border: 1px solid var(--gold); border-radius: var(--r-img); overflow: hidden;
  box-shadow: 0 12px 44px rgba(0,0,0,.5); background: #000; }
.coverflow .cf-card img { width: 100%; height: 100%; object-fit: cover; }
/* 3D mode (fx.js adds .cf-on) */
.coverflow.cf-on { display: block; height: clamp(340px, 44vw, 480px); overflow: visible;
  perspective: 1500px; }
.coverflow.cf-on .cf-card { position: absolute; left: 50%; top: 50%; width: min(62vw, 250px);
  transform-style: preserve-3d; will-change: transform;
  transition: transform var(--dur-enter) var(--ease-out-firm), opacity var(--dur-enter) var(--ease-out);
  box-shadow: 0 26px 70px rgba(0,0,0,.62); }
.cf-controls { display: flex; gap: var(--s2); justify-content: center; margin-top: var(--s3); }
.cf-btn { font-family: var(--ff-display); font-size: var(--fs-ui-lg); line-height: 1; width: 44px; height: 44px;
  display: inline-flex; align-items: center; justify-content: center; color: var(--text);
  background: var(--panel); border: 1px solid var(--hair-2); border-radius: var(--r); cursor: pointer; }
.cf-btn:hover { border-color: var(--accent); color: var(--accent); }
.cf-caption { text-align: center; font-family: var(--ff-mono); font-size: var(--fs-mono); color: var(--faint);
  letter-spacing: .08em; text-transform: uppercase; margin-top: var(--s2); }

/* ---- Line-art motifs (draw on scroll) ---- */
.motif { display: block; color: var(--accent); }
.motif svg { width: 100%; height: 100%; overflow: visible; }
.motif [data-draw] { fill: none; stroke: currentColor; stroke-width: 1.4;
  stroke-linecap: round; stroke-linejoin: round; }
/* The undrawn state is INVISIBLE, so it is gated behind :root.js — without JS
   there is no IntersectionObserver to add .drawn, and an ungated dashoffset
   would leave every motif (and all nine journal plates) permanently blank.
   This is the invisible-content rule applied to the motif system. */
:root.js .motif [data-draw] {
  stroke-dasharray: var(--len, 1200); stroke-dashoffset: var(--len, 1200); }
/* MUST out-specify the :root.js hidden state above (0,4,0), or the motif can
   never draw. Gating the hidden state behind :root.js fixed no-JS invisibility
   and silently broke every motif on the site until this selector matched it. */
:root.js .motif.drawn [data-draw] { transition: stroke-dashoffset var(--dur-draw) var(--ease-out); stroke-dashoffset: 0; }
.motif.drawn [data-draw] { stroke-dashoffset: 0; }
.motif .node { fill: currentColor; stroke: none; opacity: .9; }
:root.js .motif .node { opacity: 0; transition: opacity var(--dur-mid) var(--ease-out) var(--dur-slow); }
:root.js .motif.drawn .node { opacity: .9; }
.motif.drawn .node { opacity: .9; }
.motif-divider { max-width: 120px; margin: clamp(2rem,5vw,3.5rem) auto; opacity: .5; }
.motif-mandala, .motif-loop { display: block; width: clamp(150px, 20vw, 220px);
  margin-inline: auto; opacity: .6; }

/* ============================================================
   THE TRELLIS — home page only. "I build containers, not willpower."
   A frame is erected, the photograph is uncovered inside it pane by pane, and
   then the frame HOLDS while the copy travels through it. Its two verticals sit
   on exact thirds so they resolve into the dividers between the three gates:
   the container becomes the navigation. That is what earns the lines their place.
   ScrollTrigger pins the stage (CSS sticky is the no-GSAP fallback).
   ============================================================ */
:root {
  --trellis-scroll: 100vh;  /* TUNABLE: scroll the pinned sequence consumes.
                               Was 140 to give the spark descent its ~50vh of
                               travel at 36% of the scrub. The descent window in
                               fx.js widened to 50% to hold that same 50vh on a
                               shorter scrub, so the hero hands off to the gates
                               in one screen of scrolling instead of one and a
                               half. Nothing else needs the extra distance: the
                               scrub carries the veil and the spark descent, and
                               both fit the shorter run. */
  /* The two interior verticals. EXACT thirds, because they resolve into the
     dividers between the three gates below. Do not nudge these for looks —
     if they stop lining up with the gates the frame is only decoration. */
  --tr-c1: 33.3333%;
  --tr-c2: 66.6667%;
  /* The horizontals are deliberately NOT evenly spaced. Nothing is ruled. */
  --tr-r1: 30%;
  --tr-r2: 68%;
}

/* svh, NOT dvh. dvh is the DYNAMIC viewport height: it grows the moment a phone's
   address bar collapses, so a pinned element sized in it resizes mid-scroll and the
   pin slips, then snaps when ScrollTrigger recomputes. svh is the small viewport
   height - the value with the bar VISIBLE - and it never changes, so there is no
   mid-scroll reflow to chase. On desktop svh, dvh and lvh are the same number, so
   this costs the desktop hero nothing. The vh line before each is the fallback for
   browsers without svh; it is the old behaviour, not a regression. */
.trellis { position: relative; height: calc(100vh + var(--trellis-scroll));
  height: calc(100svh + var(--trellis-scroll)); }
.trellis-stage { position: sticky; top: 0; height: 100vh; height: 100svh; overflow: hidden;
  display: flex; align-items: center; isolation: isolate; }
/* With GSAP present, ScrollTrigger pins the stage and its pin-spacer supplies the
   scroll distance — so CSS sticky must stand down or the two fight each other. */
:root.gsap .trellis { height: auto; }
:root.gsap .trellis-stage { position: relative; top: auto; }

.trellis-scene { position: absolute; inset: 0; z-index: 0; }
/* The photograph comes UP as the veil lifts, so the container reveals what it is
   holding rather than just un-dimming. .58 at rest is the pre-scrub appearance. */
.trellis-photo { width: 100%; height: 100%; object-fit: cover;
  opacity: calc(.58 + var(--reveal, 0) * .3);
  filter: saturate(1.05) contrast(1.02); }
/* Split in two on purpose. The VERTICAL gradient grounds the image and stays put.
   The HORIZONTAL one only exists to keep the headline legible over the left two
   thirds - and the headline is gone by p=0.40, so it lifts with the copy instead
   of blacking out an empty column for the rest of the scrub. --reveal is written
   by trellis(); at rest it is 0, which is exactly the old appearance. */
.trellis-veil { position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg, rgba(11,13,16,.5), rgba(11,13,16,.28) 45%, rgba(11,13,16,.88)); }
.trellis-veil::after { content: ""; position: absolute; inset: 0;
  background: linear-gradient(90deg, rgba(11,13,16,.8), rgba(11,13,16,.1) 66%);
  opacity: calc(1 - var(--reveal, 0) * .85); }

/* ---- The container itself ---- */
/* There is deliberately no rule for the third column. A portrait filled it and was
   removed: a daylight frame in a night hero, which two rounds of grading narrowed
   and never closed. Nothing took its place because nothing needs to - the moon sits
   at about 74% across and the portrait had been covering it. The frame's --tr-c2
   vertical and the card border already draw that cell; leaving it empty is the
   composition, not a gap in it. */

.trellis-frame { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
/* The card the copy sits in. A pseudo-element, not markup:
   it is one rectangle of pure decoration and the frame is already the element
   that owns the hero's lines.
   Its top and bottom ARE --tr-r1 and --tr-r2, so the card does not introduce a
   single new horizontal - it just closes two that were already being drawn. Only
   its left and right edges are new, and they sit on the wrap, which is where the
   header and every other page's content already align. */
.trellis-frame::before { content: ""; position: absolute;
  left: max(0px, (100% - var(--wrap)) / 2);
  right: max(0px, (100% - var(--wrap)) / 2);
  top: var(--tr-r1); bottom: calc(100% - var(--tr-r2));
  border: 1px solid rgba(255, 255, 255, .16); }

/* Shutter panes. The photograph does not fade in, it is UNCOVERED pane by pane,
   like panels being taken off a frame.
   RESTING STATE IS TRANSPARENT. GSAP animates FROM opacity 1, so a trigger that
   never fires leaves the photograph visible rather than boarded over. This is the
   invisible-content rule, which this project has been bitten by four times. */
.tr-pane { position: absolute; opacity: 0; background: var(--bg);
  left: var(--l); top: var(--t); width: var(--w); height: var(--h); }

/* Members. Resting state is fully extended; JS scales them from 0. */
.tr-v { position: absolute; top: 0; bottom: 0; left: var(--x); width: 1px;
  transform-origin: 50% 0;
  background: linear-gradient(180deg, transparent, rgba(127,196,255,.4) 12%,
    rgba(127,196,255,.4) 88%, transparent); }
.tr-h { position: absolute; left: 0; right: 0; top: var(--y); height: 1px;
  transform-origin: 0 50%;
  background: linear-gradient(90deg, transparent, rgba(233,238,245,.15) 10%,
    rgba(233,238,245,.15) 90%, transparent); }
/* Nodes mark where members actually meet. No outer glow: a tinted dot, same
   family as the motif nodes elsewhere on the site. */
.tr-node { position: absolute; left: var(--x); top: var(--y);
  width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px; border-radius: 50%;
  background: var(--accent); opacity: .45; }

/* The About portrait uses the same frame at hero scale. Sits above the scrim so
   the members read against the photograph, below the copy so text stays legible. */
/* Jason's name written twice as a sigil, so it closes the page the way a
   signature closes a letter: centred on a hairline, small, desaturated. It is a
   sign-off, not another piece of art competing with the section above it. */
.signature { margin-top: var(--s6); padding-top: var(--s5);
  border-top: 1px solid var(--hair); }
/* margin-inline, not text-align: line 115 makes every img display:block, so
   text-align has nothing inline to centre. */
.signature img { width: clamp(64px, 9vw, 104px); height: auto;
  margin-inline: auto; opacity: .55; filter: saturate(0); }

/* RESTING STATE IS FADED. The frame is the page's argument while it is being
   built, so the reveal starts it at full strength - then it has nothing left to
   do. On the home page these members go on to become the gate dividers, which is
   what earns their weight; here they hand off to nothing, so once the photograph
   is uncovered the container recedes and lets the portrait hold the frame.
   This value is the FINISHED state on purpose: fx.js animates down to it, so a
   script error or reduced motion leaves the quiet frame, never the loud one. */
.about-frame { position: absolute; inset: 0; z-index: 2; pointer-events: none;
  opacity: .6; }
/* The verticals ship at .4 accent against the horizontals' .15 white - nearly 3x
   the alpha, and the only coloured member. Trimmed here since they no longer
   need to survive a handover; hue kept, because this must still read as the same
   container the home page builds. Effective at rest: .26 x .6 = .156. */
.about-frame .tr-v { background: linear-gradient(180deg, transparent,
  rgba(127,196,255,.26) 12%, rgba(127,196,255,.26) 88%, transparent); }
.about-frame .tr-node { opacity: .34; }
.hero-framed .hero-inner { position: relative; z-index: 3; }

/* Positioned in the STAGE's coordinate space, not .wrap's, so the copy can land
   on the same thirds the frame draws and the gates inherit. The percentages below
   resolve against the stage because inset:0 makes this element stage-sized —
   which is the whole point: --tr-c2 means the same thing here as it does to .tr-v.
     left  = what .wrap would have given, so the copy still aligns with the header
     right = --tr-c2, the second vertical, plus a gutter
   The copy therefore occupies the first two thirds and the third stays open. A
   7-word headline runs 2 lines here; in a single third it ran 5, which the design
   standard calls a font-size error rather than a copy problem.
   The gutter is why --tr-c2 is not the bare right edge: that line is a drawn
   member of the frame, and type set hard against a rule reads as a collision even
   when the geometry is exact. Removing the padding altogether is not a tidy-up -
   it runs the headline across both verticals and the hero stops composing in
   thirds, which is the whole idea it hands down to the gates. */
.trellis-inner { position: absolute; inset: 0; z-index: 3;
  display: flex; align-items: center;
  padding-left: calc(max(0px, (100% - var(--wrap)) / 2) + clamp(1.1rem, 4vw, 2.5rem));
  padding-right: calc(100% - var(--tr-c2) + clamp(1rem, 2vw, 2.25rem)); }
/* Hero headline: bigger and tighter for weight, with a bloom behind the type.
   The chrome is applied to the LAST WORD ONLY — a full gradient fill across a
   multi-line headline reads cheap and drags contrast down. Applying it to the
   .word spans (not the h1) also keeps the staggered reveal working, since element
   opacity fades a background-clipped span but not a parent's clipped background. */
.trellis-copy h1 { font-size: var(--fs-h1);
  letter-spacing: -.028em; line-height: 1.03;
  text-shadow: 0 0 42px rgba(127, 196, 255, .17); }
/* Chrome is MATERIAL, not motion — kept separate so whichever system drives the
   reveal (GSAP or the CSS keyframe) never has to also carry the gradient. */
.trellis-copy h1 .word:last-of-type {
  background: var(--chrome-grad); background-size: 260% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  text-shadow: none;                       /* a shadow under transparent text ghosts */
  animation: sheen 8s linear infinite; }
.trellis-copy .lede { margin-top: var(--s3); max-width: 42ch; }
/* Two doors, not a primary and a secondary. Both .btn, no .btn-solid: the header
   already spends the one solid accent this viewport gets, and a second filled
   button beside it turns a choice into a queue. */
.trellis-cta { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s4); }
.trellis-figure figcaption { font-family: var(--ff-mono); font-size: var(--fs-mono); color: var(--faint);
  margin-top: var(--s2); letter-spacing: .04em; }

/* ---- Destination gates: full-height expanding panels ----
   The gates OVERLAP the tail of the trellis (negative margin) and their opacity is
   scrubbed by the same scroll progress, so the two cross-dissolve instead of one
   sliding over the other. The top edge is feathered rather than a hard cut, and a
   faint afterglow carries the aperture's light into the new space. */
/* svh for the same reason as .trellis: this section is pulled up under the pinned
   hero by a negative margin, so its height is part of the pin's arithmetic. In dvh
   it grew the instant a phone's address bar collapsed, which moved the handoff
   underneath a finger already mid-scroll. */
.gates { position: relative; z-index: 2; min-height: 100vh; min-height: 100svh;
  display: flex; flex-direction: column;
  background: linear-gradient(to bottom, rgba(11,13,16,0) 0, var(--bg) 20vh); }
/* The trellis verticals, continued. fx.js scrubs scaleY from 0 as the frame
   releases, so the structure grows down into the gates rather than the two
   sections simply cross-fading past each other. Resting state is full height:
   no JS means these are just quiet dividers, never missing content. */
/* Selector must out-specify `.gates > *` below, which sets position:relative on
   every direct child to lift content above the ::before glow. At equal
   specificity that rule wins on source order, which silently flattened these to
   position:relative -> top/bottom create no height -> zero-height, invisible.
   z-index 3 puts them ABOVE the gate panels, since these replaced the panels'
   border-right and have to be visible at the boundaries. */
.gates > .gates-divider { position: absolute; top: 0; bottom: 0; left: var(--x);
  width: 1px; z-index: 3; transform-origin: 50% 0; pointer-events: none;
  background: linear-gradient(180deg, rgba(127,196,255,.40),
    rgba(127,196,255,.12) 55%, transparent);
  /* opacity ONLY. The trellis scrub writes transform: scaleY() to these inline
     every frame, so transitioning transform would make each frame chase a
     lagging tween - the same bug that hit .gate's opacity during the scrub. */
  transition: opacity var(--dur-mid) var(--ease-out); }

/* The dividers sit at fixed thirds (--tr-c1/--tr-c2) because they inherit the
   trellis columns, but a hovered gate grows to flex-grow: 2.4 - so the boundary
   moves and the line does not, leaving it drawn across the middle of the
   expanded image. They mark boundaries; once one panel is opened there are no
   boundaries to mark, so they step out. Same trigger as the expansion itself
   (.gates-row:hover / :focus-within) so the two can never disagree. */
.gates:has(.gates-row:hover) > .gates-divider,
.gates:has(.gates-row:focus-within) > .gates-divider { opacity: 0; }

/* ---- The spark that draws the divider ----
   `.gates > .gates-spark` (0,2,0) NOT `.gates-spark` (0,1,0): the blanket
   `.gates > *` rule below is (0,1,0) and declared later, and at equal weight
   source order wins - that is exactly what flattened .gates-divider to zero
   height for several sessions. Any absolutely-positioned child of .gates must
   be selected as a direct child.
   Rests invisible, which is safe here and only here: this carries no content,
   it is aria-hidden, and the divider it draws already rests fully drawn. */
.gates > .gates-spark { position: absolute; left: var(--x); top: 0; width: 1px; height: 0;
  z-index: 4; pointer-events: none; opacity: 0; will-change: top, opacity; }
/* the head: a bloom sitting on the line */
.gates > .gates-spark::after { content: ""; position: absolute; left: 50%; top: 0;
  width: 7px; height: 7px; margin: -3.5px 0 0 -3.5px; border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 10px 3px rgba(127,196,255,.5), 0 0 24px 9px rgba(127,196,255,.2); }
/* the trail, extending UPWARDS from the head, so the light reads as having just
   drawn the line beneath the frame rather than falling down an existing one */
.gates > .gates-spark::before { content: ""; position: absolute; left: 0; bottom: 0;
  width: 1px; height: 14vh;
  background: linear-gradient(180deg, transparent, rgba(127,196,255,.6)); }

/* The overlap only applies once fx.js is actually driving the cross-dissolve.
   Without it (no-JS, reduced motion, script error) the sections simply stack. */
.trellis-on .gates { margin-top: calc(-1 * var(--gates-overlap, 100vh)); }
.gates::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 62vh;
  pointer-events: none; z-index: 0;
  background: radial-gradient(125% 82% at 50% -12%, rgba(127,196,255,.11), transparent 72%); }
.gates > * { position: relative; z-index: 1; }
.gates-head { padding-block: clamp(2rem, 5vw, 3.5rem); }
.gates-head h2 { font-size: var(--fs-h2-lg); max-width: 24ch; }

/* ---- The three-point figure in the gates band ----
   Points sit at 1/6, 3/6, 5/6: the centres of the three `flex: 1 1 0` panels
   below, so the figure is DERIVED from the row's geometry rather than placed by
   eye. That is what keeps it structure and not decoration.
   Rests fully drawn, same contract as .gates-divider - the trellis scrub
   animates it down from here, so a scrub that never runs leaves the band
   populated instead of empty. */
.gates-constellation { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
.gc-point { position: absolute; left: var(--x); top: 50%;
  width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px; border-radius: 50%;
  background: var(--accent); box-shadow: 0 0 8px 2px rgba(127,196,255,.3); }
.gc-link { position: absolute; left: var(--a); top: 50%; height: 1px;
  width: calc(var(--b) - var(--a)); transform-origin: 0 50%;
  background: rgba(127,196,255,.26); }
/* The left link draws from its RIGHT end so both links reach outward from the
   centre point, matching the middle-out order the gates arrive in. Without this
   the pair would draw left-to-right and contradict the panels beneath them. */
.gc-link:nth-child(1) { transform-origin: 100% 50%; }
.gates-row { flex: 1; display: flex; min-height: 0; border-top: 1px solid var(--hair); }
/* The frame's lower horizontal resolving into the grid. ADDITIVE on top of the
   border-top above, which stays: that border is the resting state for no-JS and
   reduced motion, so this accent can start at scaleX(0) without ever leaving the
   row edgeless. .gates-row already has position:relative from `.gates > *`. */
.gates-row::before { content: ""; position: absolute; left: 0; right: 0; top: -1px; height: 1px;
  z-index: 2; pointer-events: none; transform: scaleX(var(--rule, 0)); transform-origin: 50% 50%;
  background: linear-gradient(90deg, transparent, rgba(127,196,255,.55), transparent); }

/* No border-right here: .gates-divider already draws the boundary, full-height
   and on the same --tr-c1/--tr-c2 tokens as the trellis frame. Two 1px lines at
   the same x read as one smudged 2px line. */
.gate { position: relative; flex: 1 1 0; min-width: 0; overflow: hidden; isolation: isolate;
  display: flex; align-items: flex-end; text-decoration: none;
  transition: flex-grow var(--dur-enter) var(--ease-out-firm), opacity var(--dur-mid) var(--ease-out), filter var(--dur-mid) var(--ease-out); }
.gate:hover { text-decoration: none; }
.gate-media { position: absolute; inset: 0; z-index: 0; background-size: cover;
  background-position: center; opacity: .34; transform: scale(1.04);
  transition: transform var(--dur-slow) var(--ease-out-firm), opacity var(--dur-mid) var(--ease-out); }
/* per-gate scrim keeps label contrast well above AA over any image */
.gate::after { content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg, rgba(11,13,16,.45) 0%, rgba(11,13,16,.72) 55%, rgba(11,13,16,.93) 100%); }
/* cursor spotlight */
.gate::before { content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; opacity: 0;
  background: radial-gradient(340px circle at var(--mx, 50%) var(--my, 50%),
    rgba(127,196,255,.16), transparent 62%);
  transition: opacity var(--dur-base) var(--ease-out); }
.gate:hover::before, .gate:focus-visible::before { opacity: 1; }

.gate-body { position: relative; z-index: 3; display: block; padding: clamp(1.4rem, 2.4vw, 2.2rem); }
.gate-kicker { display: block; font-family: var(--ff-mono); font-size: var(--fs-mono); letter-spacing: .22em;
  text-transform: uppercase; color: var(--accent); margin-bottom: var(--s2); }
.gate-title { display: block; font-family: var(--ff-display); font-weight: 700; line-height: 1.1;
  letter-spacing: -.015em; color: var(--text); font-size: var(--fs-h3); }
.gate-desc { display: block; color: var(--dim); margin-top: var(--s2); max-width: 34ch;
  font-size: var(--fs-ui); opacity: .55; transition: opacity var(--dur-mid) var(--ease-out); }
.gate-go { display: inline-block; font-family: var(--ff-mono); font-size: var(--fs-mono-lg); letter-spacing: .1em;
  text-transform: uppercase; color: var(--accent); margin-top: var(--s3); }
.gate-go::after { content: " \2192"; }

/* hover/focus: expand this one, recede the others */
.gates-row:hover .gate,
.gates-row:focus-within .gate { flex-grow: .82; opacity: .5; filter: saturate(.55); }
.gates-row .gate:hover,
.gates-row .gate:focus-within { flex-grow: 2.4; opacity: 1; filter: none; }
.gates-row .gate:hover .gate-media,
.gates-row .gate:focus-within .gate-media { transform: scale(1.12); opacity: .5; }
.gates-row .gate:hover .gate-desc,
.gates-row .gate:focus-within .gate-desc { opacity: 1; }

/* Arrive as the frame resolves into them. Default state is VISIBLE — fx.js
   adds .armed only once it can guarantee the reveal, so a JS failure can never
   leave the destinations invisible. */
.gates.armed { opacity: 0; transform: scale(.985);
  transition: opacity var(--dur-slow) var(--ease-out), transform var(--dur-slow) var(--ease-out-firm); }
.gates.armed.in { opacity: 1; transform: none; }

/* ---- Film grain + vignette (fixed overlay; never on a scrolling container) ---- */
#fx-grain { position: fixed; inset: 0; z-index: 80; pointer-events: none; opacity: .04;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
#fx-grain::after { content: ""; position: absolute; inset: 0;
  box-shadow: inset 0 0 24vmax rgba(0,0,0,.5); }

/* ---- Scroll progress ---- */
#fx-progress { position: fixed; top: 0; left: 0; height: 2px; width: 0; z-index: 85;
  background: linear-gradient(90deg, var(--violet), var(--accent)); pointer-events: none; }

/* ============================================================
   Pillar 1 — SOUND: waves and spectrums
   ============================================================ */

/* The waveform occupies the lower band of the hero so it never sits under the
   copy (text over a moving, variable-luminance surface is the thing to avoid).
   20% clears the CTA row at 900px; capped in px so tall viewports don't grow it
   back into the content. */
.sound-wave { position: absolute; left: 0; right: 0; bottom: 0;
  height: min(20%, 190px); z-index: 2; pointer-events: none; width: 100%; }

.sound-player { display: flex; align-items: center; gap: var(--s3); margin-top: var(--s4);
  flex-wrap: wrap; }
.sound-play { display: inline-flex; align-items: center; gap: .7em; cursor: pointer;
  font-family: var(--ff-display); font-weight: 500; font-size: var(--fs-ui); line-height: 1;
  padding: .8em 1.35em; min-height: 44px; border-radius: var(--r);   /* 44px touch min */
  color: var(--text); background: rgba(18,22,28,.72); backdrop-filter: blur(6px);
  border: 1px solid var(--hair-2);
  transition: border-color var(--dur-fast) var(--ease-ui), color var(--dur-fast) var(--ease-ui), transform var(--dur-micro) var(--ease-ui); }
.sound-play:hover { border-color: var(--accent); color: var(--accent); }
.sound-play:active { transform: translateY(1px); }
/* play triangle / pause bars, driven by data-state */
.sound-play-icon { width: 12px; height: 14px; position: relative; display: block; }
.sound-play-icon::before, .sound-play-icon::after { content: ""; position: absolute; inset: 0; }
.sound-play[data-state="paused"] .sound-play-icon::before {
  border-left: 11px solid currentColor; border-top: 7px solid transparent;
  border-bottom: 7px solid transparent; width: 0; height: 0; }
.sound-play[data-state="playing"] .sound-play-icon::before,
.sound-play[data-state="playing"] .sound-play-icon::after {
  width: 4px; background: currentColor; }
.sound-play[data-state="playing"] .sound-play-icon::after { left: 8px; }
.sound-track { font-family: var(--ff-display); font-size: var(--fs-ui); color: var(--text);
  display: inline-flex; align-items: baseline; gap: .55em; }
.sound-track-meta { font-family: var(--ff-mono); font-size: var(--fs-mono-xs); letter-spacing: .12em;
  text-transform: uppercase; color: var(--faint); }
.sound-status { font-family: var(--ff-mono); font-size: var(--fs-mono); letter-spacing: .06em;
  color: var(--faint); }

/* Release sparklines. Visible by default; GSAP DrawSVG animates them in when
   available, so a missing library leaves a complete waveform rather than nothing. */
.sparkline { display: block; width: 100%; max-width: 340px; height: 34px;
  margin: var(--s2) 0 var(--s1); color: var(--accent); opacity: .75; }
.sparkline path { fill: none; stroke: currentColor; stroke-width: 1.2;
  stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.release:hover .sparkline { opacity: 1; }

/* char-level heading reveal (SplitText) */
.ch { display: inline-block; will-change: transform; }

@media (max-width: 900px) {
  /* The hero is content-height on mobile, so a percentage band would sit under
     the copy. Fixed band + matching padding guarantees separation at any height. */
  .sound-wave { height: 96px; }
  .hero-sound .hero-inner { padding-bottom: 112px; }
  .sparkline { max-width: none; height: 28px; }
}

/* ============================================================
   Pillar 2 — MOVEMENT: one unbroken line threading the page
   ============================================================ */
/* Hero: the body flows, the ground holds. Fluidity is the pose sequence
   cross-fading as you scroll; stability is the ground rule and the anchors,
   which never move at all. That contrast is the whole point of the page. */
.hero-movement { min-height: 100dvh; }
.hero-movement .hero-bg { overflow: hidden; }
.move-ground { position: absolute; left: 0; right: 0; bottom: 22%; height: 1px; z-index: 2;
  background: linear-gradient(90deg, transparent, rgba(127,196,255,.55) 18%,
    rgba(127,196,255,.55) 82%, transparent); pointer-events: none; }

@media (max-width: 900px) {
  .hero-movement { min-height: 92dvh; }
  .move-ground { bottom: 18%; }
}

.thread-wrap { position: relative; }
.thread { position: absolute; left: 0; top: 0; width: 100%; height: 100%;
  pointer-events: none; z-index: 0; overflow: visible; }
/* Fully drawn by default. GSAP DrawSVG animates it when available, so a missing
   library leaves a complete line rather than an empty page. */
/* 10px, not 2.6px. Below ~7px the goo filter's alpha crush erases the stroke
   entirely and the road can never fuse with the pools - it just stays a line. */
.thread [data-thread-path] { fill: none; stroke: url(#road-metal); stroke-width: 6;
  stroke-linecap: round; stroke-linejoin: round;
  stroke-linecap: round; opacity: .45; }
/* The road. Gold, not icy blue: on this page the line is the PATH you travel,
   while the plumb beside it is the container that holds. Giving the two roles
   different colours is what stops the page reading as one undifferentiated
   scribble. --road is a brighter cousin of --gold, warm enough to read as lit
   rather than antique. */
/* A tight halo, not a spill. At 22px/.20 under a 10px seam this read as brown
   haze around the edge and was a large part of why the whole thing looked muddy. */
.thread [data-thread-bed] { fill: none; stroke: rgba(232,181,92,.11);
  stroke-width: 11; stroke-linecap: round; stroke-linejoin: round; }
/* Molten pieces. These are FILLED, not stroked, so the goo filter can fuse them
   with the road into one mass — a stroked circle would keep a visible outline
   through the merge and break the illusion. */
/* Pools, not beads. Lens-shaped and wider than tall, so the seam visibly
   swells and narrows again instead of having circles threaded onto it. */
.thread-node { fill: url(#road-metal); opacity: 1; }
/* The plumb: this page's container. Quieter than the thread on purpose — it is
   what holds, not what moves, and it must never compete with the line crossing
   it. Resting state is the full-height line movement.js measures. */
.thread-plumb { stroke: rgba(127,196,255,.15); stroke-width: 1; }
.thread-wrap > section { position: relative; z-index: 1; }

@media (max-width: 700px) {
  .thread [data-thread-path] { stroke-width: 1; opacity: .3; }
  .thread-node { rx: 5; ry: 3; }
}

/* ============================================================
   Photo layout families — these exist to break the hero/split/split rhythm.
   Adding more photos inside the same split layout would not fix monotony;
   introducing genuinely different section shapes does.
   ============================================================ */

/* --- 1. Full-bleed band: edge to edge, one line of copy, no container --- */
/* NO BORDER, and taller. This sits immediately above the template's .cta-band on
   every page that uses it, and both used to be full-bleed hairline-bordered dark
   blocks with left-aligned display type - the same object twice, meeting at a
   0px gap that drew TWO 1px lines. The band is the photographic breath and the
   CTA is the contained panel that closes; only one of them should be a framed
   box. Dropping the border here leaves .cta-band's own border-top as the single
   seam, and the extra height lets the photograph read as a moment rather than a
   strip. bands() in fx.js needs nothing: [data-band-img] is inset -6% / 112% of
   this element, so the parallax scales with whatever height it gets. */
.band { position: relative; isolation: isolate; overflow: hidden;
  min-height: clamp(380px, 62vh, 660px); display: flex; align-items: center; }
.band-img { position: absolute; inset: -6% 0; width: 100%; height: 112%;
  object-fit: cover; opacity: .55; z-index: -2; will-change: transform; }
.band::after { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background: linear-gradient(90deg, rgba(11,13,16,.86), rgba(11,13,16,.35) 62%, rgba(11,13,16,.7)); }
.band-copy { position: relative; }
.band-line { font-family: var(--ff-display); font-weight: 700; line-height: 1.12;
  letter-spacing: -.02em; font-size: var(--fs-display);
  max-width: 22ch; color: var(--text); text-wrap: balance; }
.band-sub { color: var(--dim); margin-top: var(--s3); max-width: 44ch; }

/* --- 2. Triptych: three portraits, one per audience --- */
.tri { list-style: none; margin: 0; padding: 0; display: grid;
  grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.2vw, 1.75rem); }
/* No photographs: the numeral is the anchor, and a hairline separates it from the
   heading. --fs-mono-lg is the same step .principles .n uses, so the two numbered
   lists on the site agree. The .tri-media rules that held the portraits (and their
   hover scale) are deleted, not overridden - nothing references them. */
.tri-item { position: relative; }
.tri-n { display: block; font-family: var(--ff-mono); font-size: var(--fs-mono-lg);
  letter-spacing: .2em; color: var(--accent); margin-top: 0; }
.tri-item h3 { font-size: var(--fs-h4); color: var(--text);
  margin-top: var(--s3); padding-top: var(--s3); border-top: 1px solid var(--hair); }
.tri-item p { color: var(--dim); margin-top: var(--s2); font-size: var(--fs-ui); }

/* --- 3. Portrait + pull-quote: asymmetric, not another 50/50 split --- */
.portrait-quote { display: grid; grid-template-columns: 0.85fr 1.15fr;
  gap: clamp(1.5rem, 4vw, 3.5rem); align-items: center; }
.portrait-quote figure { margin: 0; }
.portrait-quote img { width: 100%; border: 1px solid var(--hair-2); border-radius: var(--r-img); }
.pq-line { font-family: var(--ff-body); font-style: italic;
  font-size: var(--fs-h3); line-height: 1.45; color: var(--text);
  border-left: 2px solid var(--gold); padding-left: var(--s4); }
.pq-attr { font-family: var(--ff-mono); font-size: var(--fs-mono); letter-spacing: .16em;
  text-transform: uppercase; color: var(--faint); margin-top: var(--s3); padding-left: calc(var(--s4) + 2px); }

/* --- 4. Press gallery (EPK) --- */
/* Exactly four columns for exactly four shots — auto-fit generated a fifth,
   empty track at desktop widths. */
.press-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s3); }
@media (max-width: 820px) { .press-grid { grid-template-columns: repeat(2, 1fr); } }
.press-grid a { display: block; overflow: hidden; border: 1px solid var(--hair);
  border-radius: var(--r-img); aspect-ratio: 3 / 4; }
.press-grid img { width: 100%; height: 100%; object-fit: cover; opacity: .72;
  transition: transform var(--dur-enter) var(--ease-ui), opacity var(--dur-base) var(--ease-ui); }
/* Split on purpose: :focus-visible stays ungated so keyboard users keep the
   cue, and the opacity lift stays for both. Only the hover TRANSFORM is gated. */
.press-grid a:hover img, .press-grid a:focus-visible img { opacity: 1; }
.press-grid a:focus-visible img { transform: scale(1.06); }
@media (hover: hover) and (pointer: fine) {
  .press-grid a:hover img { transform: scale(1.06); }
}

@media (max-width: 820px) {
  .tri { grid-template-columns: 1fr; gap: var(--s4); }
  .portrait-quote { grid-template-columns: 1fr; }
  .portrait-quote figure { max-width: 22rem; }
  .band { min-height: 320px; }
}

/* ============================================================
   Pillar 3 — JOURNAL: an actual journal, not a list
   Most entries were published in twos (reflection + the research it stands on).
   A flat list hid that entirely; facing pages make the structure the design.
   Still dark: "paper" here is an elevated charcoal panel with a warm binding,
   never a light-mode section (the page theme stays locked).
   ============================================================ */
/* --- About hero: a moving key light ---
   Contrast does the work. A single soft highlight drifts across the portrait
   while the edges fall away; almost nothing actually moves. Defaults are a
   flattering fixed position, so no-JS and reduced-motion still look composed. */
.hero-lit { --lx: 62%; --ly: 38%; --lit: 0; }
.hero-lit .hero-photo {
  filter: contrast(calc(1 + var(--lit) * .28)) saturate(.9) brightness(.92); }
.hero-lit::before { content: ""; position: absolute; inset: 0; z-index: 1;
  pointer-events: none;
  background: radial-gradient(48% 58% at var(--lx) var(--ly),
    rgba(233,238,245,.20), rgba(233,238,245,.07) 42%, transparent 72%); }
.hero-lit::after { content: ""; position: absolute; inset: 0; z-index: 1;
  pointer-events: none;
  box-shadow: inset 0 0 calc(14vmax + var(--lit) * 8vmax) rgba(0,0,0,.72); }
.hero-lit .hero-inner { position: relative; z-index: 2; }

/* --- Journal hero: the book opens ---
   Leaves rest OPEN. GSAP animates *from* closed, so every failure mode
   (no JS, reduced motion, script error) leaves the headline readable. */
.book-hero { position: relative; isolation: isolate; overflow: hidden;
  min-height: min(72vh, 600px); display: flex; align-items: center; }
/* ---- The spread ----
   The hero at full scale: binding seam, page edges, ruled verso, copy on the
   recto. Deliberately NOT hairlines - the previous version was a 1px rule at 55%
   opacity, which is below the threshold of being noticed at all. */
.book-hero { --seam: rgba(203,170,110,.55); }
/* 1.15fr follows the KEY, whichever page it is on. The key is seven items and
   the copy is three lines, so the wider column must go to the denser content -
   otherwise a third of the key's page is dead. The key is now on the recto, so
   the wide column moved with it. The gap is a token because the seam is
   positioned off it - see .spread-seam. */
.spread { --spread-gap: clamp(2rem, 6vw, 5rem);
  position: relative; display: grid;
  grid-template-columns: 1fr 1.15fr;   /* verso : recto - .spread-seam depends on this */
  gap: var(--spread-gap); align-items: start; }
/* Both pages begin on the same line, the way a real spread does. Centring each
   column independently left the title floating in the middle of an empty recto;
   aligned to the top, the space below it reads as the rest of the page. */
.spread-verso, .spread-recto { padding-block: var(--s3); }

/* Ruled paper under both pages. This is a journal, so the ground is ruled like
   one: warm, very faint, and radially masked so it dissolves at the edges rather
   than ending on a hard line. Sits behind the type, never over it. */
.spread::before { content: ""; position: absolute; inset: -12% -3%; z-index: -1;
  pointer-events: none;
  background: repeating-linear-gradient(180deg,
    transparent 0 37px, rgba(203,170,110,.075) 37px 38px);
  -webkit-mask-image: radial-gradient(115% 85% at 50% 50%, #000 40%, transparent 86%);
  mask-image: radial-gradient(115% 85% at 50% 50%, #000 40%, transparent 86%); }
.spread-recto { position: relative; }

/* the binding: a warm seam with real weight, brightest at the middle */
/* The binding sits in the CENTRE OF THE GRID GAP, derived - not the old hardcoded
   42.5%, which tracked nothing and drew the seam 49px inside the verso column,
   straight through the key's text. fr cannot go in calc(), so the 1.15/2.15 here
   is the one place that must stay in step with grid-template-columns above. */
/* THE BINDING IS THE COIL, and it belongs at the spine. This used to be a solid
   line here while the identical perforated gradient ran down the OUTER margins -
   so the same mark meant "binding" on the nine entry cards (.leaf-edge, left:-1px)
   and "page edge" in the hero. One mark, one meaning: coil = spine, everywhere.
   Wider and longer-period than .leaf-edge because it binds a spread, not a card.
   mask, not a gradient background, since the paint is now a repeating pattern;
   drop-shadow, not box-shadow, so the glow follows the rings and not the box. */
.spread-seam { position: absolute; top: -10%; bottom: -10%; width: var(--coil-w);
  left: calc((100% - var(--spread-gap)) * (1 / 2.15) + var(--spread-gap) / 2);
  margin-left: calc(var(--coil-w) / -2);
  z-index: 1; pointer-events: none; transform-origin: 50% 50%;
  /* --seam over --coil-ink: same coil, brighter ink. The spread binds the whole
     page and should out-rank the card spines it sits above. */
  background: repeating-linear-gradient(180deg,
    var(--seam) 0 var(--coil-rung), transparent var(--coil-rung) var(--coil-period));
  -webkit-mask-image: linear-gradient(180deg, transparent, #000 13%, #000 87%, transparent);
  mask-image: linear-gradient(180deg, transparent, #000 13%, #000 87%, transparent);
  filter: drop-shadow(0 0 5px rgba(203,170,110,.45)); }
/* stacked page edges at the outer margins of the block */
/* The outer margin is a cut page edge, so it is solid. It was the perforated
   pattern, which is the binding's mark - see .spread-seam. A hairline, not the
   old 4px band: the spine should be the loudest thing on the spread. */
.spread-edge { position: absolute; top: -6%; bottom: -6%; width: 1px; z-index: 1;
  pointer-events: none;
  background: linear-gradient(180deg, transparent, rgba(203,170,110,.34) 12%,
    rgba(203,170,110,.34) 88%, transparent); }
.spread-edge-l { left: -22px; }
.spread-edge-r { right: -22px; }

/* the verso: ruled lines, like the facing page of a notebook */
.spread-verso { display: grid; align-content: center; min-height: 15rem; }

@media (max-width: 860px) {
  /* One page, not two. The verso used to be empty rules and was hidden here as
     noise; the key is content and must survive, so only the binding furniture
     goes. No `order` needed any more: the key sits second in the source now, so
     stacking in document order already puts it after the title. */
  .spread { grid-template-columns: 1fr; gap: var(--s5); }
  .spread-seam, .spread-edge { display: none; }
  .spread-verso { min-height: 0; }
}
.book-ground { position: absolute; inset: 0; z-index: -2;
  background:
    radial-gradient(120% 90% at 50% 8%, rgba(127,196,255,.13), transparent 60%),
    radial-gradient(90% 80% at 50% 110%, rgba(185,166,255,.08), transparent 65%),
    var(--bg); }
.book-inner { position: relative; z-index: 3; width: 100%; }

@media (max-width: 760px) { .book-hero { min-height: 70vh; } }
/* ---- Journal plates ----------------------------------------------------
   One SVG per TOPIC, included in both halves of a spread. The wrapper class
   picks the rendering, so a pair is the same geometry twice:

     .plate--reflection   icy blue, continuous          -> felt
     .plate--research     gold, tick marks exposed      -> measured
     .plate--mirror       blue, second copy alongside   -> two views, one idea

   Colour rides on `currentColor`, which .motif already uses for its strokes,
   so switching the wrapper's `color` recolours the whole plate. Adding a new
   entry means composing one glyph from the kit and picking a class. */
/* Sized to actually be seen. At 208x72 the art rendered 168x17 — a flat hairline
   that read as nothing at all. Wider box, taller box, heavier stroke, and the
   glyphs themselves redrawn to use their full artboard rather than a flat band
   through the middle of it. */
.plate { display: block; width: min(100%, 300px); height: clamp(72px, 8vw, 104px);
  margin-bottom: var(--s2); opacity: 1; }
.plate [data-draw] { stroke-width: 2.1; }
.plate .plate-svg { width: 100%; height: 100%; overflow: visible; }
.plate--reflection { color: var(--accent); }
.plate--research   { color: var(--gold); }
.plate--mirror     { color: var(--accent); }
/* the two conditional layers, hidden unless their rendering asks for them */
.plate .plate-ticks, .plate .plate-echo { display: none; }
.plate--research .plate-ticks { display: block; }
.plate--research .plate-ticks path { stroke: currentColor; stroke-width: 1; opacity: .7; }
/* The echo paths are the base paths traversed backwards (M 230 50 H 144 vs
   M 144 50 H 230), so without a transform they land EXACTLY on top of the
   original and a pair renders identically to a single reflection - verified:
   both bBoxes read 10,50 86x0. The offset is what makes it a second copy
   ALONGSIDE rather than a second copy on top. In user units, so it scales with
   the glyph and holds at both the 44px key size and the 104px leaf size. */
.plate--mirror .plate-echo { display: block; opacity: .55; transform: translateY(16px); }
/* research reads as measurement: a finer, cooler stroke than the felt version */
.plate--research [data-draw] { stroke-width: 1.1; }
.leaf-page:has(a:hover) .plate { opacity: 1; }

/* ---- The key ----
   The marks carry meaning, so it is stated once rather than left to be guessed.
   Deliberately quiet: mono labels, a hairline above, no panel. It is a caption
   for the page, not a feature of it. */
/* The key lives on the RECTO of the hero spread (.spread-recto - the verso holds
   the title and lede), so it is sized to that column (558px at the 1200px wrap),
   not to a full-width section: two columns for the four suits, three for the
   kinds. Measured at 566px tall inside a 606px hero at 1280px, so there are 40px
   of headroom and the glyphs stay at 44px.
   That headroom is also why the key carries no prose of its own. The line that
   orients it - "every entry also carries a mark; here is how to read it" - lives
   on the VERSO lede instead, which runs 293px inside the same 606px column and
   has the room to spare. Put explanatory copy there, not here. */
.key-head { font-family: var(--ff-mono); font-size: var(--fs-mono); letter-spacing: .18em;
  text-transform: uppercase; color: var(--faint); margin: var(--s5) 0 var(--s3); }
.key-head:first-child { margin-top: 0; }
.key-list { list-style: none; padding: 0; margin: 0; display: grid;
  grid-template-columns: repeat(2, 1fr); gap: clamp(1.1rem, 2.4vw, 1.8rem); }
.key-list-3 { grid-template-columns: repeat(3, 1fr); }
.key-list li { display: grid; gap: var(--s1); }
.key-glyph { width: min(100%, 180px); height: 44px; margin: 0 0 var(--s1); opacity: .95; }
.key-name { font-family: var(--ff-mono); font-size: var(--fs-mono); letter-spacing: .18em;
  text-transform: uppercase; color: var(--text); }
.key-desc { color: var(--faint); font-size: var(--fs-ui); max-width: 30ch; }
@media (max-width: 900px) { .key-list, .key-list-3 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .key-list, .key-list-3 { grid-template-columns: 1fr; } }

.leaves { display: grid; gap: clamp(1.75rem, 4vw, 3rem); }

.leaf { position: relative; display: grid; grid-template-columns: 1fr 1fr;
  gap: clamp(1.5rem, 3.5vw, 3rem);
  padding: clamp(1.5rem, 3vw, 2.5rem) clamp(1.5rem, 3vw, 2.75rem);
  background: linear-gradient(100deg, var(--elev), var(--panel));
  border: 1px solid var(--hair); border-radius: var(--r);
  transition: border-color var(--dur-base) var(--ease-ui), transform var(--dur-mid) var(--ease-ui); }
/* Border colour is harmless on a sticky touch-hover; the 2px lift is not. */
.leaf:hover { border-color: rgba(203,170,110,.42); }
@media (hover: hover) and (pointer: fine) {
  .leaf:hover { transform: translateY(-2px); }
}
.leaf-single { grid-template-columns: 1fr; max-width: 46rem; }

/* the binding: a warm seam down the gutter, plus stacked page edges at the spine */
.leaf::before { content: ""; position: absolute; top: 12%; bottom: 12%; left: 50%;
  width: 1px; background: linear-gradient(180deg, transparent,
    rgba(203,170,110,.30) 22%, rgba(203,170,110,.30) 78%, transparent); }
.leaf-single::before { display: none; }
/* The card's spine, drawn with the SAME coil as the hero spine - same rung, same
   period, same width, straight off the tokens. It was 2px/5px and 3px wide,
   which at card size read as stitching rather than the binding the hero uses.
   No border-radius: a coil is discrete rings, so there is no bar to round off.
   drop-shadow, not box-shadow, so the glow follows the rings not the box. */
.leaf-edge { position: absolute; left: -2px; top: 8%; bottom: 8%; width: var(--coil-w);
  background: repeating-linear-gradient(180deg,
    var(--coil-ink) 0 var(--coil-rung), transparent var(--coil-rung) var(--coil-period));
  filter: drop-shadow(0 0 4px rgba(203,170,110,.28)); }

.leaf-date { grid-column: 1 / -1; font-family: var(--ff-mono); font-size: var(--fs-mono);
  letter-spacing: .18em; text-transform: uppercase; color: var(--faint); }
/* The title link alone is a ~21px tap target, too small for the page's primary
   navigation. Stretch it over its half of the spread so the whole page is
   tappable, while the <a> keeps its real text for screen readers. */
.leaf-page { min-width: 0; position: relative; }
.leaf-title a::after { content: ""; position: absolute; inset: 0; }
.leaf-page:has(a:hover) .leaf-title a { color: var(--accent); }
.leaf-tag { display: inline-block; font-family: var(--ff-mono); font-size: var(--fs-mono-xs);
  letter-spacing: .18em; text-transform: uppercase; color: var(--violet);
  border: 1px solid rgba(185,166,255,.4); border-radius: var(--r-img);
  padding: .18em .5em; margin-bottom: var(--s2); }
.leaf-title { font-size: var(--fs-h4); line-height: 1.2; }
.leaf-title a { color: var(--text); }
.leaf-title a:hover { color: var(--accent); text-decoration: none; }
.leaf-ex { color: var(--dim); margin-top: var(--s2); font-size: var(--fs-ui); }

@media (max-width: 760px) {
  .leaf { grid-template-columns: 1fr; }
  .leaf::before { top: auto; bottom: auto; left: 0; right: 0; width: auto; height: 1px;
    top: 50%; background: linear-gradient(90deg, transparent, rgba(203,170,110,.3), transparent); }
}

/* ---- Article: made to be read ---- */
/* Opt-in, not :first-of-type — four of the nine articles open on a curly quote,
   and a 60px floating quotation mark is not a drop cap. journal.js adds the
   class only when the paragraph actually starts with a letter. */
.article-body p.dropcap::first-letter {
  float: left; font-family: var(--ff-display); font-weight: 700; font-size: 3.4em;
  line-height: .82; padding: .06em .12em 0 0; color: var(--accent); }
.article-body { max-width: 68ch; }

/* bookmark ribbon: reading progress, hung from the top edge */
.ribbon { position: fixed; top: 0; left: clamp(10px, 3vw, 34px); width: 10px; z-index: 60;
  height: 0; pointer-events: none;
  background: linear-gradient(180deg, var(--gold), rgba(203,170,110,.35));
  border-radius: 0 0 2px 2px; }
.ribbon::after { content: ""; position: absolute; left: 0; right: 0; bottom: -6px;
  border-left: 5px solid transparent; border-right: 5px solid transparent;
  border-top: 6px solid rgba(203,170,110,.55); }
@media (max-width: 900px) { .ribbon { display: none; } }

/* line-by-line reveal; default visible so a failed trigger can never hide prose */
.article-body .line { display: block; }
:root.gsap .article-body .line-mask { overflow: hidden; }

/* ---- MAI chat panel (dormant until body[data-mai="1"]) ---- */
.mai-panel { position: fixed; inset: 0; z-index: 90; }
.mai-scrim { position: absolute; inset: 0; background: rgba(6, 8, 11, .6);
  backdrop-filter: blur(3px); opacity: 0; transition: opacity var(--dur-fast) var(--ease-out); }
.mai-panel.mai-in .mai-scrim { opacity: 1; }
.mai-card { position: absolute; top: 0; right: 0; height: 100%; width: min(440px, 100%);
  display: flex; flex-direction: column; background: var(--panel);
  border-left: 1px solid var(--hair-2); box-shadow: -20px 0 60px rgba(0,0,0,.5);
  transform: translateX(100%); transition: transform var(--dur-exit) var(--ease-out-firm); }
.mai-panel.mai-in .mai-card { transform: none; }
.mai-head { display: flex; align-items: center; justify-content: space-between;
  padding: var(--s3) var(--s3); border-bottom: 1px solid var(--hair); }
.mai-title { font-family: var(--ff-display); font-weight: 700; font-size: var(--fs-h4); letter-spacing: .02em;
  background: linear-gradient(100deg,#8b93a1,#e9eef5 30%,#b9a6ff 60%,#e9eef5);
  background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; }
.mai-x { background: none; border: 0; color: var(--dim); font-size: var(--fs-h3); line-height: 1; cursor: pointer;
  width: 40px; height: 40px; border-radius: var(--r); }
.mai-x:hover { color: var(--text); background: var(--elev); }
.mai-log { flex: 1; overflow-y: auto; padding: var(--s3); display: flex; flex-direction: column; gap: var(--s2); }
.mai-msg { max-width: 88%; padding: .7em 1em; border-radius: var(--r-bubble); font-size: var(--fs-ui); line-height: 1.5;
  white-space: pre-wrap; }
.mai-mai { align-self: flex-start; background: var(--elev); color: var(--text);
  border: 1px solid var(--hair); border-left: 2px solid var(--violet); }
.mai-user { align-self: flex-end; background: rgba(127,196,255,.14); color: var(--text);
  border: 1px solid rgba(127,196,255,.3); }
.mai-typing { color: var(--faint); letter-spacing: .1em; }
.mai-form { display: flex; gap: var(--s2); padding: var(--s2) var(--s3); border-top: 1px solid var(--hair);
  align-items: flex-end; }
.mai-form textarea { flex: 1; resize: none; max-height: 120px; background: var(--bg); color: var(--text);
  border: 1px solid var(--hair-2); border-radius: var(--r); padding: .7em .9em; font-family: var(--ff-body);
  font-size: var(--fs-input); line-height: 1.4; }
.mai-form textarea:focus-visible { border-color: var(--accent); }
.mai-send { padding: .7em 1.1em; }
.mai-send:disabled { opacity: .5; cursor: default; }
.mai-foot { padding: 0 var(--s3) var(--s2); color: var(--faint); font-family: var(--ff-mono);
  font-size: var(--fs-mono-xs); letter-spacing: .03em; }

/* ---- Responsive ---- */
@media (max-width: 900px) {
  .hero-grid, .hero-full .hero-grid { grid-template-columns: 1fr; }
  .hero-figure { order: -1; max-width: 26rem; }
  .hero-full { min-height: auto; }
  /* Long pins are punishing on phones: shorten the sequence and cap the zoom.
     The overlap shrinks with it, since it must stay well inside the shorter pin. */
  :root { --trellis-scroll: 80vh; --gates-overlap: 50vh; }
  /* The gates stack at this width, so there are no thirds to align to - and
     --tr-c2 as right padding would squeeze the copy to about 230px on a phone. */
  /* 2.2rem, not the 1.1rem the card is inset by: the copy must clear the card's
     border, not sit on it. */
  .trellis-inner { padding-left: 2.2rem; padding-right: 2.2rem; }
  .trellis-copy .lede { max-width: none; }
  /* The card is sized to the copy rather than to --tr-r2. The thirds mean nothing
     at this width anyway (see above), which is what frees the bottom edge to move. */
  .trellis-frame::before { left: 1.1rem; right: 1.1rem; top: 22%; bottom: 8%; }
  .gates-row { flex-direction: column; border-top: 0; }
  .gate { flex: 1 1 auto; min-height: 46vh; border-right: 0; border-top: 1px solid var(--hair); }
  /* no hover expansion on touch — every panel reads fully, taps go straight through */
  .gates-row:hover .gate, .gates-row:focus-within .gate { flex-grow: 1; opacity: 1; filter: none; }
  .gate-desc { opacity: 1; }
  .split, .split.reverse > :first-child { grid-template-columns: 1fr; order: 0; }
  .split-media { max-width: 34rem; }
  .foot-inner { grid-template-columns: 1fr 1fr; }
  /* Stacked, the copy now spans the full width and would run into the light end
     of the band's directional scrim. Flatten it back to even protection here. */
  .cta-inner { grid-template-columns: 1fr; }
  .cta-band { background:
    radial-gradient(700px 300px at 85% 0%, rgba(127,196,255,.09), transparent 70%),
    linear-gradient(rgba(11,13,16,.88), rgba(11,13,16,.9)),
    url("/assets/img/atmo-forest.jpg") center 10% / cover no-repeat,
    var(--panel); }
  .door-sound, .door-movement, .door-journal { grid-column: 1 / -1; }
}
@media (max-width: 720px) {
  .nav-toggle { display: inline-flex; align-items: center; justify-content: center;
    order: 3; margin-left: auto; width: 44px; height: 44px; background: transparent;
    border: 1px solid var(--hair-2); border-radius: var(--r); cursor: pointer; }
  .nav-toggle-bars, .nav-toggle-bars::before, .nav-toggle-bars::after {
    content: ""; display: block; width: 18px; height: 2px; background: var(--text); position: relative; }
  .nav-toggle-bars::before { position: absolute; top: -6px; }
  .nav-toggle-bars::after  { position: absolute; top: 6px; }
  .head-inner { flex-wrap: wrap; }
  .head-actions { order: 2; margin-left: auto; }
  .nav { flex-basis: 100%; margin-left: 0; display: none; }
  .nav.open { display: block; }
  .nav ul { flex-direction: column; gap: 0; padding-block: var(--s2); }
  .nav li { border-top: 1px solid var(--hair); }
  .nav a { display: block; padding: .8rem 0; }
  .principles li { grid-template-columns: 1fr; }
  .release { grid-template-columns: 1fr; }
  .resource { grid-template-columns: 1fr; }
  .resource-cover { max-width: 12rem; }
  .foot-inner { grid-template-columns: 1fr; }
}

/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  :root.js .reveal { opacity: 1; transform: none; transition: none; }
  .btn, .door { transition: none; }
  .door:hover { transform: none; }
  .chrome, .brand, .foot-name { animation: none; }   /* no silver sheen, material stays */
  .hero-full h1 .word, .trellis-copy h1 .word:last-of-type {
    animation: none; opacity: 1; transform: none; }
  .motif [data-draw] { stroke-dashoffset: 0; }    /* motifs render complete */
  .shelf-ledge, .shelf-cover { animation: none; transform: none; }
  .motif .node { opacity: .9; transition: none; }
  .mai-scrim, .mai-card { transition: none; }
  .mai-title { animation: none; }
  /* Trellis collapses to a plain static hero: no pin, no shutter reveal.
     The frame stays drawn, the photograph stays uncovered, all copy reachable. */
  .trellis { height: auto; }
  .trellis-stage { position: static; height: auto; min-height: 100dvh;
    padding-block: clamp(3rem, 8vw, 6rem); }
  .tr-pane { opacity: 0 !important; }
  .tr-v, .tr-h { transform: none !important; }
  .trellis-copy { opacity: 1 !important; transform: none !important; }
  .gates { opacity: 1 !important; transform: none !important; transition: none;
    margin-top: 0 !important; background: var(--bg); }
  .gates::before { display: none; }
  .gate, .gate-media, .gates-divider { transition: none; }
}
