/* =========================================================
   Grapes Foundation — single-page multi-project landing.
   Signature effect: per-project fixed background that
   cross-fades with blur when a new project enters the
   viewport. Text scrolls above. Mobile-safe.
   ========================================================= */

:root {
    --bg-base: #050814;
    --fg: #f5f5f5;
    --fg-muted: rgba(245, 245, 245, 0.72);
    --fg-dim: rgba(245, 245, 245, 0.55);
    --accent: #6ab4d4;
    --accent-strong: #8ed0ec;
    --line: rgba(245, 245, 245, 0.18);
    --line-strong: rgba(245, 245, 245, 0.32);
    --font-display: "Space Grotesk", system-ui, -apple-system, Segoe UI, sans-serif;
    --font-body: "Inter", system-ui, -apple-system, Segoe UI, sans-serif;
    --transition-bg: 1400ms cubic-bezier(0.22, 0.61, 0.36, 1);
    --scrim-strong: rgba(5, 8, 20, 0.62);
    --scrim-light: rgba(5, 8, 20, 0.35);
}

* { box-sizing: border-box; }

html {
    scroll-behavior: smooth;
    -webkit-text-size-adjust: 100%;
}

body {
    margin: 0;
    background: var(--bg-base);
    color: var(--fg);
    font-family: var(--font-body);
    font-size: 17px;
    line-height: 1.55;
    font-weight: 400;
    overflow-x: hidden;
    min-height: calc(var(--vh, 1vh) * 100);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

::selection { background: var(--accent); color: #050814; }

a { color: inherit; text-decoration: none; }

/* =========================================================
   Loader — blurred LQIP + spinning "G".

   Three stages:
     stage 1 (.is-loading)   LQIP + spinner; bg-stack and main hidden
     stage 2 (.is-bg-ready)  intro bg dissolves up; LQIP fades; spinner stays
     stage 3 (.is-ready)     spinner fades; main fades in

   The LQIP is a 32-px-wide AVIF (~700 B) of the intro bg, inlined as a
   data URI so it paints with first byte and never makes a request.
   inset: -10% extends past the edges so the 20px blur doesn't reveal
   the underlying solid bg at the corners.
   ========================================================= */

.loader {
    position: fixed;
    inset: 0;
    z-index: 100;
    display: grid;
    place-items: center;
    pointer-events: none;
    overflow: hidden;
}
/* Per-layer LQIPs — 32-px AVIF stills (≤ 700 B each) inlined as data
   URIs. SynaBrain has no video, so its LQIP is a CSS radial gradient
   matching .bg--synabrain itself. The default in the var() fallback
   chain is intro, so any unrecognised data-active-project value (or
   none) lands on the intro LQIP. */
:root {
    /* SVG mask of the Grapes "G" letterform — used by .loader__spinner.
       Inlined as a base64 data URI so it paints on first byte with no
       network request. Base64 instead of URL-encoded because some
       browsers (Firefox especially) had trouble parsing the inline
       url(#g) mask reference inside a percent-encoded SVG body during
       first paint, leaving the spinner momentarily unmasked — visible
       as a "rectangle of glow" before the mask landed. */
    --grapes-g-mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2Ij48bWFzayBpZD0iZyI+PHJlY3Qgd2lkdGg9IjI1NiIgaGVpZ2h0PSIyNTYiIGZpbGw9ImJsYWNrIi8+PGNpcmNsZSBjeD0iMTI4IiBjeT0iMTI4IiByPSI4MCIgZmlsbD0id2hpdGUiLz48Y2lyY2xlIGN4PSIxMjgiIGN5PSIxMjgiIHI9IjU4IiBmaWxsPSJibGFjayIvPjxyZWN0IHg9IjE5MCIgeT0iMTE3IiB3aWR0aD0iMzAiIGhlaWdodD0iMjIiIGZpbGw9ImJsYWNrIi8+PHJlY3QgeD0iMTI4IiB5PSIxMTciIHdpZHRoPSI2MiIgaGVpZ2h0PSIyMiIgZmlsbD0id2hpdGUiLz48L21hc2s+PHJlY3Qgd2lkdGg9IjI1NiIgaGVpZ2h0PSIyNTYiIGZpbGw9IiNmZmYiIG1hc2s9InVybCgjZykiLz48L3N2Zz4=");
    --lqip-intro: url('data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUEAAAJwbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAAOcGl0bQAAAAAAAQAAAB5pbG9jAAAAAEQAAAEAAQAAAAEAAAKYAAAAKgAAAChpaW5mAAAAAAABAAAAGmluZmUCAAAAAAEAAGF2MDFDb2xvcgAAAAHvaXBycAAAAc9pcGNvAAAAFGlzcGUAAAAAAAAAIAAAABYAAAAQcGl4aQAAAAADCAgIAAAADGF2MUOBIAAAAAABhGNvbHJwcm9mAAABeGxjbXMCIAAAbW50clJHQiBYWVogB9AAAQABAAAAAAAAYWNzcE1TRlQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1hdmlmID8PMYir10rTwbyW8Gff9wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJZGVzYwAAAPAAAABfd3RwdAAAAQwAAAAUclhZWgAAASAAAAAUZ1hZWgAAATQAAAAUYlhZWgAAAUgAAAAUclRSQwAAAVwAAAAQZ1RSQwAAAVwAAAAQYlRSQwAAAVwAAAAQY3BydAAAAWwAAAAMZGVzYwAAAAAAAAAFYXZpZgAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAG+gAAA49AAAA5FYWVogAAAAAAAAYpYAALeHAAAY21hZWiAAAAAAAAAkogAAD4UAALbVY3VydgAAAAAAAAABAfYAAHRleHQAAAAAQ0MwAAAAABNjb2xybmNseAACAAIABoAAAAAYaXBtYQAAAAAAAAABAAEFAQKDBAUAAAAybWRhdBIACgg4ET9eUCAgaTIcHgAOAAEEBPq3pdMAFZhkYg/5zyf03WHZdJG/Aw==');
    --lqip-anser: url('data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUEAAADrbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAAOcGl0bQAAAAAAAQAAAB5pbG9jAAAAAEQAAAEAAQAAAAEAAAETAAAAKAAAAChpaW5mAAAAAAABAAAAGmluZmUCAAAAAAEAAGF2MDFDb2xvcgAAAABqaXBycAAAAEtpcGNvAAAAFGlzcGUAAAAAAAAAIAAAABYAAAAQcGl4aQAAAAADCAgIAAAADGF2MUOBIAAAAAAAE2NvbHJuY2x4AAEADQAGgAAAABdpcG1hAAAAAAAAAAEAAQQBAoMEAAAAMG1kYXQSAAoIOBE/XlAQ0GkyGh4ACgAiBAT1rQDoYkLPsnM7xw5vZ6bin4ke');
    --lqip-zorachka: url('data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUEAAADrbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAAOcGl0bQAAAAAAAQAAAB5pbG9jAAAAAEQAAAEAAQAAAAEAAAETAAAALQAAAChpaW5mAAAAAAABAAAAGmluZmUCAAAAAAEAAGF2MDFDb2xvcgAAAABqaXBycAAAAEtpcGNvAAAAFGlzcGUAAAAAAAAAIAAAABYAAAAQcGl4aQAAAAADCAgIAAAADGF2MUOBIAAAAAAAE2NvbHJuY2x4AAEADQAGgAAAABdpcG1hAAAAAAAAAAEAAQQBAoMEAAAANW1kYXQSAAoIOBE/XlAQ0GkyHx4AAzyBBASrEcLLkRSenDGpA2sqds99t8mdL6OuD9A=');
    --lqip-synabrain: radial-gradient(circle at 80% 20%, #0e1a33, #050814 60%);
    --lqip-hoollywoody: url('data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUEAAADrbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAAOcGl0bQAAAAAAAQAAAB5pbG9jAAAAAEQAAAEAAQAAAAEAAAETAAAAMgAAAChpaW5mAAAAAAABAAAAGmluZmUCAAAAAAEAAGF2MDFDb2xvcgAAAABqaXBycAAAAEtpcGNvAAAAFGlzcGUAAAAAAAAAIAAAABIAAAAQcGl4aQAAAAADCAgIAAAADGF2MUOBIAAAAAAAE2NvbHJuY2x4AAEADQAGgAAAABdpcG1hAAAAAAAAAAEAAQQBAoMEAAAAOm1kYXQSAAoIOBE/HlAQ0GkyJB4AABRCBATKNoo/m0lovaL5GjEgahooNUXEvi9l1hb3CAPJcQ==');
    --lqip-castcast: url('data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUEAAADrbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAAOcGl0bQAAAAAAAQAAAB5pbG9jAAAAAEQAAAEAAQAAAAEAAAETAAAALgAAAChpaW5mAAAAAAABAAAAGmluZmUCAAAAAAEAAGF2MDFDb2xvcgAAAABqaXBycAAAAEtpcGNvAAAAFGlzcGUAAAAAAAAAIAAAABIAAAAQcGl4aQAAAAADCAgIAAAADGF2MUOBIAAAAAAAE2NvbHJuY2x4AAEADQAGgAAAABdpcG1hAAAAAAAAAAEAAQQBAoMEAAAANm1kYXQSAAoIOBE/HlAQ0GkyIB4AABwABAS4l9zSCJOS6n3KfwzuXo9Y3BRMZ0XNxe/X');
    /* Scrim overlay for the loading layer — matches the per-section
       scrim baked into each .bg--X::after, so brightness on the LQIP
       lines up with the brightness on the real bg below it and there's
       no flash when the LQIP fades out. Default = intro/anser values;
       per-section overrides below for layers with different scrims. */
    --scrim-loading: linear-gradient(rgba(5, 8, 20, 0.55), rgba(5, 8, 20, 0.70));
}
/* Glassy card behind the spinner — same dark/translucent treatment
   as the contact-form inputs and menu CTA button (rgba(5,8,20,0.45)
   + 8 px backdrop blur), kept as its own selector so the input/button
   styles can evolve independently later. Bigger border-radius (28 px
   vs the inputs' 6 px) to rhyme with the G letterform's curves. */
.loader__spinner-backdrop {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    /* ~1.4x the spinner so there's visible padding around the G. */
    width: clamp(132px, 20vw, 200px);
    height: clamp(132px, 20vw, 200px);
    background: rgba(5, 8, 20, 0.45);
    -webkit-backdrop-filter: blur(8px);
            backdrop-filter: blur(8px);
    border-radius: 28px;
    /* Start fully off — only visible past SPINNER_DELAY_MS (see loader.js).
       visibility: hidden + opacity 0 keeps Safari from briefly rendering
       the backdrop's dark fill + backdrop-filter on first paint (a known
       Safari quirk where the "from" state of a transition flashes). The
       transition is intentionally NOT declared on the initial rule — it
       only kicks in when .is-show-spinner / .is-ready add it, so there's
       no implicit "transition from default" for Safari to render. */
    opacity: 0;
    visibility: hidden;
}
/* The LQIP layer that used to live in .loader as .loader__lqip moved
   into .bg-stack as .bg-stack__loading (see index.html and the new
   rule below the bg-stack section). Two separate fixed elements with
   independent opacity transitions were causing Safari to render
   intermediate states with bg-stack's fill bleeding through the
   fading LQIP — the user-reported "blur → gradient → clear bg"
   flicker. Putting the LQIP INSIDE bg-stack means the LQIP is just
   the topmost layer; fading it out reveals the layer beneath, with
   no second element fading in concurrently. */
/* Per-section LQIP + scrim-loading variables. Selected via
   body[data-active-project] which is set:
     - by HTML default (data-active-project="intro")
     - by inline JS priming in main.js (from URL hash on deep-link)
     - by scrollToSection (on click / programmatic navigation)
     - by pickClosest (on scroll, after loader releases)
   We previously also had a parallel set of body.is-loading:has(#X:target)
   rules for the same purpose, but :has() selectors in WebKit-based
   browsers (Safari, DuckDuckGo iOS) re-evaluate during class changes
   and were briefly mismatching ALL .bg layers as opacity 1 when the
   .is-bg-ready class landed — causing every inactive bg layer to
   transition from 1→0 instead of staying at 0. The visible result
   was the user-reported "castcast flash" (castcast is the last bg
   layer in DOM order, so it stacked on top during the transient
   transition). Removed the :has() rules; data-active-project does
   the same job without that side effect.
   intro/about/overview/fund/contact map to the intro bg already via
   the existing data-active-project="intro" default + the existing
   body[data-active-project]=intro/about/overview/fund/contact .bg--intro
   rule below, so no LQIP/scrim override needed for them. */
body[data-active-project="anser"]       { --lqip-current: var(--lqip-anser); }
body[data-active-project="zorachka"]    {
    --lqip-current: var(--lqip-zorachka);
    --scrim-loading: linear-gradient(rgba(5, 8, 20, 0.60), rgba(5, 8, 20, 0.75));
}
body[data-active-project="synabrain"]   {
    --lqip-current: var(--lqip-synabrain);
    /* synabrain has no scrim — its bg is already a dark radial gradient */
    --scrim-loading: linear-gradient(transparent, transparent);
}
body[data-active-project="hoollywoody"] {
    --lqip-current: var(--lqip-hoollywoody);
    --scrim-loading: linear-gradient(rgba(5, 8, 20, 0.40), rgba(5, 8, 20, 0.62));
}
body[data-active-project="castcast"]    {
    --lqip-current: var(--lqip-castcast);
    --scrim-loading: linear-gradient(rgba(5, 8, 20, 0.50), rgba(5, 8, 20, 0.70));
}
/* The "G" — same shine vocabulary as .hero__title (translucent base
   + two bright peaks sweeping with no dead zone), so the loading
   glyph visually rhymes with the page's reveal target. Layered
   filter: bright white halo + accent-tinted cyan halo + the same
   two depth shadows the hero title uses. No rotation — the motion
   is the shine sweep. */
.loader__spinner {
    position: relative;
    width: clamp(96px, 14vw, 144px);
    height: clamp(96px, 14vw, 144px);
    background-image: linear-gradient(
        100deg,
        rgba(255, 255, 255, 0.35) 0%,
        rgba(255, 255, 255, 0.35) 12%,
        rgba(255, 255, 255, 0.55) 18%,
        rgba(255, 255, 255, 0.85) 23%,
        rgba(255, 255, 255, 1)    25%,
        rgba(255, 255, 255, 0.85) 27%,
        rgba(255, 255, 255, 0.55) 32%,
        rgba(255, 255, 255, 0.35) 38%,
        rgba(255, 255, 255, 0.35) 62%,
        rgba(255, 255, 255, 0.55) 68%,
        rgba(255, 255, 255, 0.85) 73%,
        rgba(255, 255, 255, 1)    75%,
        rgba(255, 255, 255, 0.85) 77%,
        rgba(255, 255, 255, 0.55) 82%,
        rgba(255, 255, 255, 0.35) 88%,
        rgba(255, 255, 255, 0.35) 100%
    );
    background-size: 200% 100%;
    -webkit-mask-image: var(--grapes-g-mask);
            mask-image: var(--grapes-g-mask);
    -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    /* Glow + depth, no close white halo — that close halo was bleeding
       into the symbol's edges and visually fusing with the masked
       gradient, making the symbol read as much more opaque than its
       actual 0.35 base. The accent-tinted halo at 56 px blur stays
       far enough off the symbol to not corrupt its perceived alpha,
       and the three depth shadows give the floating-above-page feel. */
    filter:
        drop-shadow(0 0 56px rgba(106, 180, 212, 0.35))
        drop-shadow(0 4px 10px rgba(0, 0, 0, 0.60))
        drop-shadow(0 18px 54px rgba(0, 0, 0, 0.50))
        drop-shadow(0 40px 96px rgba(0, 0, 0, 0.35));
    animation: loader-shine 5s linear infinite;
    /* Start fully off — only visible past SPINNER_DELAY_MS (see loader.js).
       Same visibility-hidden + no-initial-transition strategy as the
       backdrop above, for the same Safari first-paint reason. */
    opacity: 0;
    visibility: hidden;
    will-change: background-position;
}
@keyframes loader-shine {
    from { background-position: 100% 0; }
    to   { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
    .loader__spinner {
        animation: none;
        background-image: none;
        background-color: rgba(244, 241, 234, 0.85);
    }
    .bg-stack__loading { transition-duration: 0ms; }
}

/* Hero gating — main / nav / etc are held at opacity 0 while the page
   is still loading, then fade in on .is-ready. The bg-stack itself is
   NOT gated anymore — it stays at opacity 1 from the start, with the
   .bg-stack__loading layer covering the bg layers beneath. The loading
   layer fades out on .is-bg-ready, revealing the real bg in place
   without a second cross-fade competing for visibility. */
main,
.dotnav,
.menu-trigger,
#version-btn { transition: opacity 800ms ease; }

body.is-loading main,
body.is-loading .dotnav,
body.is-loading .menu-trigger,
body.is-loading #version-btn {
    opacity: 0;
    /* visibility: hidden ensures the element isn't rendered at all
       (defense vs. browsers that progressively paint before CSS is
       fully applied — DuckDuckGo on iOS is the user-reported case).
       Mirrored in the inline critical CSS in index.html so this lands
       even if style.css is delayed. */
    visibility: hidden;
    /* The loader element has pointer-events: none, so clicks pass through
       to whatever's underneath. Without this, an invisible menu-trigger
       or nav link could be tapped/activated during loading. */
    pointer-events: none;
}

/* Loading layer fades out when intro is fully decoded (handled in
   loader.js). It's INSIDE bg-stack so its parent stays opaque — only
   one element transitions, no cross-fade race. */
body.is-bg-ready .bg-stack__loading { opacity: 0; }

/* Spinner + backdrop only become visible if the loader hasn't completed
   within SPINNER_DELAY_MS (see loader.js). Fast loads skip this entirely.
   Transition is declared HERE (not on the base rule) so Safari has no
   implicit "from" state to flash on first paint — the transition only
   exists once a class change requires it. */
body.is-show-spinner .loader__spinner,
body.is-show-spinner .loader__spinner-backdrop {
    opacity: 1;
    visibility: visible;
    transition: opacity 600ms ease, visibility 0s;
}

body.is-ready main,
body.is-ready .dotnav,
body.is-ready .menu-trigger,
body.is-ready #version-btn {
    opacity: 1;
    visibility: visible;
}
/* Source-order: this overrides .is-show-spinner above when both classes
   are present (slow load that finished). Spinner fades back to 0; the
   visibility transition is delayed until after the opacity fade so the
   element doesn't pop out mid-fade. */
body.is-ready .loader__spinner,
body.is-ready .loader__spinner-backdrop {
    opacity: 0;
    visibility: hidden;
    transition: opacity 600ms ease, visibility 0s 600ms;
}

h1, h2, h3 {
    font-family: var(--font-display);
    font-weight: 600;
    margin: 0;
    letter-spacing: -0.01em;
}

p { margin: 0; }

/* =========================================================
   Fixed background stack
   ========================================================= */

.bg-stack {
    position: fixed;
    inset: 0;
    width: 100%;
    /* JS-managed --vh (assets/js/viewport-units.js) so the bg-stack
       doesn't resize on browser-chrome toggles. background-size:
       cover on each .bg layer re-fits on container resize, which
       made every poster/video subtly shift on direction-change
       scrolls. --vh only ever grows, so the stack is sized to the
       largest viewport ever observed and stays one constant size
       regardless of chrome state — works for browsers (DDG etc.)
       whose chrome toggles don't fire visualViewport.resize. */
    height: calc(var(--vh, 1vh) * 100);
    z-index: -1;
    pointer-events: none;
    /* Two-layer background: a dark-blue radial gradient on top of the
       solid bg-base. The gradient is the immediate fallback when none
       of the .bg layers' pictures have decoded yet — without it the
       user sees solid #050814 (reads as black) during the brief window
       between the loading layer fading out and the intro picture
       appearing. */
    background:
        radial-gradient(circle at 50% 30%, #1a2640, #050814 70%) center / cover no-repeat,
        var(--bg-base);
}

/* Top-most layer inside .bg-stack — covers the .bg layers from page
   load until the intro poster is decoded, at which point loader.js
   adds .is-bg-ready and this layer fades out, revealing the (now-
   decoded) intro picture beneath. inset: -10% extends past the
   parent so the 20 px blur doesn't reveal sharp edges at corners. */
.bg-stack__loading {
    position: absolute;
    inset: -10%;
    z-index: 1;
    /* Two-layer background: scrim ON TOP of the LQIP image, matching
       the per-section --scrim-loading variable. The scrim density
       matches what's baked into each .bg--X::after, so the LQIP and
       the real bg below it have the same effective brightness — when
       the LQIP fades out, the user doesn't see a sudden brightness
       jump (the previous "flash"). */
    background:
        var(--scrim-loading),
        var(--lqip-current, var(--lqip-intro)) center / cover no-repeat;
    filter: blur(20px);
    transition: opacity 1000ms ease;
}

.bg {
    position: absolute;
    inset: 0;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    opacity: 0;
    will-change: opacity;
    /* Transition is intentionally NOT declared here — only under
       .is-ready below. Some WebKit-based browsers fire a transition on
       the initial style application when transition-property is set on
       the base rule and a more-specific rule overrides it, which made
       every .bg layer fade 1→0 on first load (leaving castcast, last in
       DOM, briefly visible on top). Gating on .is-ready limits the
       crossfade to scroll-driven layer changes after the loader releases.

       The dissolve is opacity-only. A blur() filter used to animate
       alongside opacity (incoming layer "focusing in"), but transitioning
       a full-viewport blur janked hard on WebKit/iPad — during the initial
       load, with bg videos decoding at the same time, the main thread
       dropped ~1s of frames mid-crossfade, so the background appeared to
       CUT between blocks instead of dissolving. Pure opacity is GPU-cheap
       and crossfades smoothly everywhere. */
}
body.is-ready .bg {
    transition: opacity var(--transition-bg);
}

/* Per-project background — image + scrim baked into the
   background stack so each photo's brightness is tuned to
   keep overlayed text legible. Replace SVG placeholders
   with real photos by swapping the filename only. */
/* Intro, Anser Rossii, Zorachka, HoollyWoody, and CastCast use real
   <picture> + <video> child elements (see .bg__poster and .bg__video).
   Scrim is applied via ::after so it sits above both. The scrim
   density is tuned per layer to match each video's brightness. */
.bg--intro,
.bg--anser,
.bg--zorachka,
.bg--hoollywoody,
.bg--castcast { background-image: none; }
.bg--intro::after,
.bg--anser::after,
.bg--zorachka::after,
.bg--hoollywoody::after,
.bg--castcast::after {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 3;
}
.bg--intro::after,
.bg--anser::after {
    background: linear-gradient(rgba(5, 8, 20, 0.55), rgba(5, 8, 20, 0.70));
}
.bg--zorachka::after {
    background: linear-gradient(rgba(5, 8, 20, 0.60), rgba(5, 8, 20, 0.75));
}
.bg--hoollywoody::after {
    background: linear-gradient(rgba(5, 8, 20, 0.40), rgba(5, 8, 20, 0.62));
}
/* Diagonal readability scrim — darkens the lower-left where the body
   text lives, leaves the upper-right transparent so the video detail
   reads cleanly. Layered on ::before so the existing ::after vertical
   scrim per layer stays untouched. Applied to the four portfolio
   layers always-on; their parent .bg--X opacity already hides the
   pseudo-element when the layer is inactive. */
.bg--anser::before,
.bg--zorachka::before,
.bg--hoollywoody::before,
.bg--castcast::before,
.bg--intro::before {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 3;
    background: linear-gradient(
        to bottom left,
        transparent 30%,
        rgba(5, 8, 20, 0.30) 65%,
        rgba(5, 8, 20, 0.55) 100%
    );
}

/* Intro layer is shared by intro/about/overview/fund/contact. The
   scrim is only wanted on fund (Grapes Vision Fund I — heavy body
   copy needs the legibility help); the other four chapters of the
   intro layer don't need it. Default opacity 0; promoted to 1 when
   fund is the active project. transition on opacity gives the smooth
   dissolve when the user scrolls fund → contact (or contact → fund). */
.bg--intro::before {
    opacity: 0;
    transition: opacity var(--transition-bg);
}
body[data-active-project="fund"] .bg--intro::before { opacity: 1; }
.bg--castcast::after {
    background: linear-gradient(rgba(5, 8, 20, 0.50), rgba(5, 8, 20, 0.70));
}
.bg__poster,
.bg__video {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
}
.bg__poster { z-index: 1; }
.bg__poster img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
    display: block;
}
.bg__video {
    object-fit: cover;
    object-position: center;
    z-index: 2;
    opacity: 0;
    transition: opacity 1200ms ease;
    pointer-events: none;
}
.bg.is-video-ready .bg__video { opacity: 1; }
/* Preloaded fast-path: when the video is already decoded as its layer
   becomes active — the sequential queue loaded it ahead of the user, or
   it's a re-entry / cached — show it immediately with no poster→video
   crossfade. The 1200ms fade above is reserved for the one case it's
   meant for: the user is looking at the poster and the video finishes
   loading underneath it. */
.bg.is-video-instant .bg__video { transition: none; }

/* Kill every trace of native media chrome. The video is a
   background element — no play button, no overlays, not even
   the transient "blocked autoplay" indicator. */
.bg__video::-webkit-media-controls,
.bg__video::-webkit-media-controls-enclosure,
.bg__video::-webkit-media-controls-panel,
.bg__video::-webkit-media-controls-start-playback-button,
.bg__video::-webkit-media-controls-overlay-play-button,
.bg__video::-webkit-media-controls-play-button {
    display: none !important;
    -webkit-appearance: none !important;
    appearance: none !important;
}

@media (prefers-reduced-motion: reduce) {
    .bg__video { display: none; }
}
/* SynaBrain bg uses a radial gradient as the base + a child canvas
   (.bg__neurons) that paints the animated neural mesh on top. The
   gradient doubles as the reduced-motion / no-JS fallback. */
.bg--synabrain   { background: radial-gradient(1200px 800px at 80% -10%, #0e1a33 0%, #050814 60%), #050814; }

/* Intro/About/Overview form the opening chapter — they share the
   hero's poster/video background. Grapes Vision Fund I + Contact
   form the closing chapter — they also share the intro bg, so the
   page bookends back to the hero after the five portfolio projects.
   The five portfolio projects each have their own dedicated bg. */
body[data-active-project="intro"]       .bg--intro,
body[data-active-project="about"]       .bg--intro,
body[data-active-project="overview"]    .bg--intro,
body[data-active-project="anser"]       .bg--anser,
body[data-active-project="zorachka"]    .bg--zorachka,
body[data-active-project="synabrain"]   .bg--synabrain,
body[data-active-project="hoollywoody"] .bg--hoollywoody,
body[data-active-project="castcast"]    .bg--castcast,
body[data-active-project="fund"]        .bg--intro,
body[data-active-project="contact"]     .bg--intro {
    opacity: 1;
}

/* =========================================================
   Dot nav (desktop only)
   ========================================================= */

/* The dots stay visually anchored to the right edge (the element is
   right:24px pinned); padding only grows the invisible box leftward.
   align-items: flex-end keeps the dots at the right of the content box;
   the labels are absolutely positioned so they don't shift.

   The wide left padding (clamp 120–240px) is a HOVER BRIDGE for pointer
   devices — it lets the mouse reveal the rail without pinpointing the
   tiny dots. On touch it's harmful: that 120–240px invisible strip down
   the right edge is exactly where a thumb lands to start a scroll, and
   iOS Safari's sticky :hover (WebKit #158517) then pins the rail open —
   the reported "scrolling opens the menu" bug. So the bridge is gated to
   real pointers (below); touch gets only the dot column as the hit area,
   so a scroll gesture in the content never lands on the rail. Tapping a
   dot still reveals it (sticky :hover on the dot itself is unchanged).
   touch-action: manipulation drops the legacy tap delay; the page still
   pans freely. */
.dotnav {
    position: fixed;
    right: max(24px, env(safe-area-inset-right, 0));
    top: 50%;
    transform: translateY(-50%);
    z-index: 10;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 14px;
    padding: 24px 0;
    touch-action: manipulation;
}
@media (hover: hover) and (pointer: fine) {
    .dotnav { padding-left: clamp(120px, 16vw, 240px); }
}

.dotnav__dot {
    position: relative;
    display: grid;
    place-items: center;
    width: 22px;
    height: 22px;
    border-radius: 50%;
    transition: transform 160ms ease;
}

.dotnav__mark {
    display: block;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--line-strong);
    transition: background 180ms ease, transform 180ms ease;
}

/* Section name floats to the LEFT of the dot — absolute-positioned
   so it doesn't widen the dotnav box and shift dots around. Hidden
   by default (slid 8px right + opacity 0); revealed when the user
   hovers/focuses anywhere in the dotnav (group reveal — reads as
   the menu opening). The active section's label is pinned ON, so
   the user always sees a "you are here" cue without hovering.
   Stronger text-shadow keeps labels legible over bright bgs
   (Anser sky photo, gold HoollyWoody scene, etc.). */
.dotnav__label {
    position: absolute;
    right: calc(100% + 14px);
    top: 50%;
    transform: translateY(-50%) translateX(8px);
    opacity: 0;
    pointer-events: none;
    font-family: "Roboto", var(--font-display);
    font-size: 11px;
    font-weight: 400;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    color: var(--fg-muted);
    white-space: nowrap;
    text-shadow:
        0 1px 4px rgba(0, 0, 0, 0.85),
        0 1px 18px rgba(0, 0, 0, 0.55);
    transition: opacity 240ms ease, transform 240ms ease, color 180ms ease;
}

/* When labels are visible (group reveal OR active state), they
   become clickable. Without this, clicks on the label text just
   fall through — the click target was only the 22×22 dot box.
   :not(.is-collapsed) lets the touch handler force the rail shut while
   the user is scrolling over it (main.js): on iOS, scrolling on top of
   the dots keeps sticky :hover pinned open, so JS adds .is-collapsed on
   a scroll gesture and removes it on a deliberate tap. */
.dotnav:not(.is-collapsed):hover .dotnav__label,
.dotnav:not(.is-collapsed):focus-within .dotnav__label {
    opacity: 1;
    transform: translateY(-50%) translateX(0);
    pointer-events: auto;
}

/* Touch: enlarge each label's tap target. The label text is only ~13px
   tall and floats over the page content, so on a finger a near-miss
   (especially just above/below the text) lands on the content instead —
   which both fails to navigate AND clears iOS's sticky :hover, hiding the
   rail. That's the "sometimes it hides instead of jumping" bug. The label
   is inside the <a>, so padding it grows the jump target without any
   markup change. Vertical padding is kept under the 36px dot pitch (22px
   dot + 14px gap) so adjacent labels never overlap; the text stays
   centered on its dot (symmetric padding + translateY centering). */
@media (hover: none) {
    .dotnav__label { padding: 9px 14px; }
}
/* Dot hover affordances are pointer-only. On touch the synthesized
   sticky :hover would scale/brighten whichever dot the finger passes
   over while scrolling on the rail — the "glitching with menu selection"
   the user saw. The active-dot scale/colour (below, keyed off
   data-active-project, not :hover) is the real selection indicator and
   is unaffected. */
@media (hover: hover) and (pointer: fine) {
    .dotnav__dot:hover .dotnav__label { color: var(--fg); }
    .dotnav__dot:hover { transform: scale(1.15); }
    .dotnav__dot:hover .dotnav__mark { background: var(--fg); }
}
.dotnav__dot:focus-visible {
    outline: 2px solid var(--accent-strong);
    outline-offset: 3px;
}

body[data-active-project="intro"]       .dotnav__dot[data-project="intro"] .dotnav__mark,
body[data-active-project="about"]       .dotnav__dot[data-project="intro"] .dotnav__mark,
body[data-active-project="overview"]    .dotnav__dot[data-project="intro"] .dotnav__mark,
body[data-active-project="anser"]       .dotnav__dot[data-project="anser"] .dotnav__mark,
body[data-active-project="zorachka"]    .dotnav__dot[data-project="zorachka"] .dotnav__mark,
body[data-active-project="synabrain"]   .dotnav__dot[data-project="synabrain"] .dotnav__mark,
body[data-active-project="hoollywoody"] .dotnav__dot[data-project="hoollywoody"] .dotnav__mark,
body[data-active-project="castcast"]    .dotnav__dot[data-project="castcast"] .dotnav__mark,
body[data-active-project="fund"]        .dotnav__dot[data-project="fund"] .dotnav__mark,
body[data-active-project="contact"]     .dotnav__dot[data-project="contact"] .dotnav__mark {
    background: var(--fg);
    transform: scale(1.4);
}

/* Active project's label: always visible + brighter color.
   Distinguished from hovered (non-active) labels by the dot
   itself — the active mark is scaled 1.4× and pinned to var(--fg)
   regardless of hover, while a hovered (non-active) mark only
   brightens on direct cursor contact. */
body[data-active-project="intro"]       .dotnav__dot[data-project="intro"] .dotnav__label,
body[data-active-project="about"]       .dotnav__dot[data-project="intro"] .dotnav__label,
body[data-active-project="overview"]    .dotnav__dot[data-project="intro"] .dotnav__label,
body[data-active-project="anser"]       .dotnav__dot[data-project="anser"] .dotnav__label,
body[data-active-project="zorachka"]    .dotnav__dot[data-project="zorachka"] .dotnav__label,
body[data-active-project="synabrain"]   .dotnav__dot[data-project="synabrain"] .dotnav__label,
body[data-active-project="hoollywoody"] .dotnav__dot[data-project="hoollywoody"] .dotnav__label,
body[data-active-project="castcast"]    .dotnav__dot[data-project="castcast"] .dotnav__label,
body[data-active-project="fund"]        .dotnav__dot[data-project="fund"] .dotnav__label,
body[data-active-project="contact"]     .dotnav__dot[data-project="contact"] .dotnav__label {
    opacity: 1;
    transform: translateY(-50%) translateX(0);
    color: var(--fg);
    pointer-events: auto;
}

/* Collision-aware label. The active label is pinned ON above, but the
   rail is fixed to the viewport's right edge while the content reserves
   no right gutter — so on medium viewports (e.g. iPad landscape) the
   label can overlap wide section text. main.js measures the active
   label box against the section text each frame and toggles
   body[data-dotnav-collision]; when set, the label fades out (the dot
   stays as the "you are here" cue) and returns once the text scrolls
   clear. Opacity-only (transform untouched) so the layout box stays put
   and the JS measurement doesn't oscillate. Guarded by
   :not(:hover):not(:focus-within) so the instant the user engages the
   rail — pointer hover on PC, focus on iPad tap — the reveal-all rules
   above win and every label shows on demand, even over text. */
body[data-dotnav-collision="true"] .dotnav:not(:hover):not(:focus-within) .dotnav__label {
    opacity: 0;
    pointer-events: none;
}

/* =========================================================
   Mobile menu — floating trigger + full-screen overlay.
   On phones the right-side dotnav is hidden (touch targets
   collide with thumb scrolling); a single bottom-right
   trigger opens a full-screen list of sections instead.
   ========================================================= */

.menu-trigger { display: none; }

.mobile-menu { display: none; }

@media (max-width: 768px) {
    .dotnav { display: none; }

    .menu-trigger {
        display: flex;
        flex-direction: column;
        justify-content: center;
        gap: 5px;
        position: fixed;
        top: max(20px, env(safe-area-inset-top, 0));
        left: max(20px, env(safe-area-inset-left, 0));
        z-index: 26;
        width: 48px;
        height: 48px;
        padding: 0 14px;
        border: 1px solid var(--line);
        border-radius: 50%;
        background: rgba(5, 8, 20, 0.6);
        backdrop-filter: blur(10px);
        -webkit-backdrop-filter: blur(10px);
        cursor: pointer;
    }
    .menu-trigger__bar {
        display: block;
        height: 1px;
        background: var(--fg);
        transition: transform 240ms ease, opacity 200ms ease;
        transform-origin: center;
    }
    .menu-trigger[aria-expanded="true"] .menu-trigger__bar:nth-child(1) {
        transform: translateY(6px) rotate(45deg);
    }
    .menu-trigger[aria-expanded="true"] .menu-trigger__bar:nth-child(2) {
        opacity: 0;
    }
    .menu-trigger[aria-expanded="true"] .menu-trigger__bar:nth-child(3) {
        transform: translateY(-6px) rotate(-45deg);
    }

    /* Overflow safety — on very short viewports (landscape phones,
       split-screen) 8 links + gaps can exceed the visible area.
       overflow-y: auto + safe top/bottom padding lets the user
       scroll the menu if needed. */
    .mobile-menu {
        display: flex;
        position: fixed;
        inset: 0;
        z-index: 25;
        background: rgba(5, 8, 20, 0.92);
        -webkit-backdrop-filter: blur(14px);
        backdrop-filter: blur(14px);
        flex-direction: column;
        justify-content: center;
        padding: clamp(48px, 12vh, 96px) clamp(28px, 8vw, 64px);
        overflow-y: auto;
        opacity: 0;
        pointer-events: none;
        transition: opacity 280ms ease;
    }
    .mobile-menu[aria-hidden="false"] {
        opacity: 1;
        pointer-events: auto;
    }
    .mobile-menu__list {
        list-style: none;
        padding: 0;
        margin: 0;
        display: flex;
        flex-direction: column;
        gap: clamp(18px, 2.5vh, 28px);
    }
    .mobile-menu__link {
        display: inline-block;
        font-family: var(--font-display);
        font-size: clamp(28px, 6.5vw, 40px);
        font-weight: 600;
        line-height: 1;
        letter-spacing: -0.02em;
        color: var(--fg-muted);
        text-decoration: none;
        text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
        opacity: 0;
        transform: translateY(10px);
        transition: opacity 320ms ease, transform 320ms ease, color 180ms ease;
    }
    /* Cascading entrance — links fade in one after another when the
       overlay opens, instead of a flat group fade. */
    .mobile-menu[aria-hidden="false"] .mobile-menu__link {
        opacity: 1;
        transform: translateY(0);
    }
    .mobile-menu[aria-hidden="false"] li:nth-child(1) .mobile-menu__link { transition-delay:  60ms; }
    .mobile-menu[aria-hidden="false"] li:nth-child(2) .mobile-menu__link { transition-delay: 100ms; }
    .mobile-menu[aria-hidden="false"] li:nth-child(3) .mobile-menu__link { transition-delay: 140ms; }
    .mobile-menu[aria-hidden="false"] li:nth-child(4) .mobile-menu__link { transition-delay: 180ms; }
    .mobile-menu[aria-hidden="false"] li:nth-child(5) .mobile-menu__link { transition-delay: 220ms; }
    .mobile-menu[aria-hidden="false"] li:nth-child(6) .mobile-menu__link { transition-delay: 260ms; }
    .mobile-menu[aria-hidden="false"] li:nth-child(7) .mobile-menu__link { transition-delay: 300ms; }
    .mobile-menu[aria-hidden="false"] li:nth-child(8) .mobile-menu__link { transition-delay: 340ms; }

    .mobile-menu__link:active,
    .mobile-menu__link:focus-visible {
        color: var(--fg);
        outline: none;
    }

    body[data-active-project="intro"]       .mobile-menu__link[data-project="intro"],
    body[data-active-project="about"]       .mobile-menu__link[data-project="intro"],
    body[data-active-project="overview"]    .mobile-menu__link[data-project="intro"],
    body[data-active-project="anser"]       .mobile-menu__link[data-project="anser"],
    body[data-active-project="zorachka"]    .mobile-menu__link[data-project="zorachka"],
    body[data-active-project="synabrain"]   .mobile-menu__link[data-project="synabrain"],
    body[data-active-project="hoollywoody"] .mobile-menu__link[data-project="hoollywoody"],
    body[data-active-project="castcast"]    .mobile-menu__link[data-project="castcast"],
    body[data-active-project="fund"]        .mobile-menu__link[data-project="fund"],
    body[data-active-project="contact"]     .mobile-menu__link[data-project="contact"] {
        color: var(--fg);
    }
}

/* =========================================================
   Project sections
   ========================================================= */

main { position: relative; }

.project {
    position: relative;
    padding: 0 clamp(20px, 5vw, 56px);
    max-width: 100%;
}

/* default: 3 subsections × 100dvh + header + CTA = tall.
   Exclude the intro-chapter sections (intro/about/overview) and the
   contact section — those have their own min-height and layout. */
.project:not(.project--intro):not(.project--contact):not(.project--about):not(.project--overview) {
    display: flex;
    flex-direction: column;
}

/* Header min-height is sized so that, with content pinned to flex-end,
   "0X" lands ~100vh from the section top. Adjacent sections ⇒ exactly
   one viewport of empty space between the outgoing CTA and the incoming
   "0X", so the moment the last pixel of the previous project scrolls
   off the top of the viewport is the same moment "0X" first peeks in
   from the bottom. The bg cross-fade fires at that same tick.

   Sized via JS-managed --vh (assets/js/viewport-units.js) so section
   heights are pinned to the largest-ever-observed viewport and don't
   reflow when mobile browser chrome appears/disappears. Was dvh, then
   svh, then this — DDG and similar browsers don't always update CSS
   viewport units consistently with their custom chrome. */
.project__header {
    min-height: calc(var(--vh, 1vh) * 125);
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    padding-bottom: 40px;
    max-width: 960px;
}

/* Tagline + plaque-date group. Sized via width: max-content so the
   group collapses to the width of its widest child — always the
   tagline (our taglines are longer than "Est. YYYY"). The .project__
   index row then stretches to that same width, and the trailing rule
   flex-grows to fill whatever's left after the "Est. YYYY" text + gap.
   Net result: the trailing rule under Est. ends precisely at the right
   edge of the tagline above, per-project, with no hard-coded widths. */
.project__tagline-group {
    display: flex;
    flex-direction: column;
    width: max-content;
    max-width: 100%;
    align-self: flex-start;
}

/* Project plaque-date — same Roboto 13px / 0.32em uppercase / muted
   vocabulary as the hero's "— Est. 2020 —" kicker. Rendered as a flex
   row that fills its parent (.project__tagline-group) width so the
   trailing rule has room to extend to the tagline's right edge.
   padding-inline-end matches the tagline's 0.32em trailing letter-
   spacing — the tagline's max-content intrinsic width includes the
   trailing track after its last letter, and the group sizes to that
   full max-content. Without this compensating padding the rule runs
   one letter-spacing past the visually-last character of the tagline. */
.project__index {
    font-family: "Roboto", var(--font-display);
    font-size: 13px;
    font-weight: 400;
    letter-spacing: 0.32em;
    color: var(--fg-muted);
    margin-top: 24px;
    text-transform: uppercase;
    display: flex;
    align-items: center;
    gap: 16px;
    width: 100%;
    padding-inline-end: 0.32em;
}
.project__index-rule {
    display: block;
    flex-grow: 1;
    height: 1px;
    background: var(--line-strong);
}

.project__name {
    font-size: clamp(48px, 9vw, 128px);
    line-height: 0.98;
    letter-spacing: -0.03em;
    font-weight: 700;
    text-shadow: 0 2px 28px rgba(0, 0, 0, 0.5);
}

/* Shine variant — same vocabulary as the .hero__title GRAPES
   FOUNDATION treatment: Roboto 900 caps, translucent-white base
   gradient with two bright shine stops sweeping across via the
   shared @keyframes hero-shine cycle, drop-shadow pair instead of
   text-shadow (text-shadow doesn't render on transparent text).
   Sized at the project clamp (smaller than the hero) so it sits in
   the project header rhythm rather than competing with the intro. */
.project__name--shine {
    font-family: "Roboto", var(--font-display);
    font-weight: 900;
    line-height: 0.95;
    letter-spacing: -0.01em;
    text-transform: uppercase;
    background-image: linear-gradient(
        100deg,
        rgba(255, 255, 255, 0.4) 0%,
        rgba(255, 255, 255, 0.4) 12%,
        rgba(255, 255, 255, 0.55) 18%,
        rgba(255, 255, 255, 0.75) 23%,
        rgba(255, 255, 255, 1)    25%,
        rgba(255, 255, 255, 0.75) 27%,
        rgba(255, 255, 255, 0.55) 32%,
        rgba(255, 255, 255, 0.4) 38%,
        rgba(255, 255, 255, 0.4) 62%,
        rgba(255, 255, 255, 0.55) 68%,
        rgba(255, 255, 255, 0.75) 73%,
        rgba(255, 255, 255, 1)    75%,
        rgba(255, 255, 255, 0.75) 77%,
        rgba(255, 255, 255, 0.55) 82%,
        rgba(255, 255, 255, 0.4) 88%,
        rgba(255, 255, 255, 0.4) 100%
    );
    background-size: 200% 100%;
    background-repeat: repeat;
    -webkit-background-clip: text;
            background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
    text-shadow: none;
    filter:
        drop-shadow(0 2px 6px rgba(0, 0, 0, 0.55))
        drop-shadow(0 14px 48px rgba(0, 0, 0, 0.45));
    animation: hero-shine 18s linear infinite;
    will-change: background-position;
}

@media (prefers-reduced-motion: reduce) {
    .project__name--shine {
        animation: none;
        background: none;
        -webkit-text-fill-color: rgba(255, 255, 255, 0.85);
        color: rgba(255, 255, 255, 0.85);
    }
}

/* Logo variant — the h2 still exists for semantics/a11y, but its content
   is a vector wordmark. Drop-shadow substitutes for text-shadow.
   A masked gradient overlay adds a shine sweep matching the hero title
   (see @keyframes hero-shine); mix-blend-mode: screen layers it on top
   of the two-tone grey without replacing it. */
/* Standardized title-box height across all projects. min-height here
   matches the .project__name--particles value so logos and the
   SynaBrain particle canvas all land in the same-height h2 box. The
   logo img is max-height-capped to the same clamp so it never
   overflows on wide viewports; flex-centering vertically centers
   shorter logos (e.g. Zorachka, whose 4:1 aspect makes it shorter
   than the min-height) inside the h2 box. Net: every project's
   0X / logo / tagline stack occupies the same vertical footprint. */
.project__name--logo {
    line-height: 1;
    text-shadow: none;
    min-height: clamp(96px, 14vw, 160px);
    display: flex;
    align-items: center;
}
.project__logo-wrap {
    position: relative;
    display: inline-block;
    width: clamp(240px, 34vw, 480px);
    max-width: 100%;
    isolation: isolate;
}
.project__logo {
    display: block;
    width: 100%;
    height: auto;
    max-height: clamp(96px, 14vw, 160px);
    filter:
        drop-shadow(0 2px 8px rgba(0, 0, 0, 0.65))
        drop-shadow(0 16px 48px rgba(0, 0, 0, 0.55));
}
/* Monochrome variant — matches the GRAPES FOUNDATION hero title:
   brightness(0) + invert(1) recolors the SVG's native fills to white
   (regardless of what they were), opacity: 0.2 lands on the same
   translucent-white baseline as the title's gradient stops at 0%/62%.
   The shine overlay's 0→100% peaks then screen-blend up to full white
   on the same 18s cadence. Drop-shadow stays inside the filter chain
   so it gets dimmed proportionally — matches how the hero title's own
   drop-shadows fade at the gradient baseline. */
.project__logo-wrap--mono .project__logo {
    filter:
        brightness(0) invert(1)
        drop-shadow(0 2px 8px rgba(0, 0, 0, 0.65))
        drop-shadow(0 16px 48px rgba(0, 0, 0, 0.55));
    opacity: 0.5;
}

/* Zorachka-specific size bump: the mono treatment (translucent
   white at 50% opacity) reads lighter than the full-color logos,
   so it gets desktop +15% (cumulative — was +10% in v0.7.15, +5%
   here) and mobile +15% (mobile in matching @media block, no
   further bump from v0.7.15) to balance visual weight. */
.project__logo-wrap--mono {
    width: clamp(277px, 39vw, 554px);
}
.project__logo-wrap--mono .project__logo {
    max-height: clamp(111px, 16vw, 185px);
}

/* HoollyWoody-specific size bump: the ornate gold logo carries
   more internal detail than the simpler wordmarks (Anser,
   Zorachka, CastCast), so at the standard logo size it reads
   visually smaller. Desktop +18% (cumulative — was +15% in
   v0.7.14, +3% here) / mobile +20% (no further bump). The h2's
   min-height is the floor — h2 grows to fit the bigger logo
   without explicit override. The searchlight beams scale via
   percentages so they track the wrap automatically. */
.project__logo-wrap--searchlight {
    width: clamp(284px, 40vw, 569px);
}
.project__logo-wrap--searchlight .project__logo {
    max-height: clamp(113px, 16vw, 190px);
}

.project__logo-shine {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background-image: linear-gradient(
        100deg,
        rgba(255, 255, 255, 0) 0%,
        rgba(255, 255, 255, 0) 15%,
        rgba(255, 255, 255, 0.25) 20%,
        rgba(255, 255, 255, 0.6) 23%,
        rgba(255, 255, 255, 1)    25%,
        rgba(255, 255, 255, 0.6) 27%,
        rgba(255, 255, 255, 0.25) 30%,
        rgba(255, 255, 255, 0) 35%,
        rgba(255, 255, 255, 0) 65%,
        rgba(255, 255, 255, 0.25) 70%,
        rgba(255, 255, 255, 0.6) 73%,
        rgba(255, 255, 255, 1)    75%,
        rgba(255, 255, 255, 0.6) 77%,
        rgba(255, 255, 255, 0.25) 80%,
        rgba(255, 255, 255, 0) 85%,
        rgba(255, 255, 255, 0) 100%
    );
    background-size: 200% 100%;
    -webkit-mask-image: var(--logo-mask);
            mask-image: var(--logo-mask);
    -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    mix-blend-mode: screen;
    animation: hero-shine 18s linear infinite;
    will-change: background-position;
}

@media (prefers-reduced-motion: reduce) {
    .project__logo-shine { animation: none; display: none; }
}

/* Hollywood searchlight variant — three independent beams pivot from
   points along the bottom edge of the logo, swinging back and forth
   through the letters like real premiere floodlights rigged at ground
   level. Each beam has its own sweep arc, duration, and phase offset
   so they cross unpredictably; when two beams overlap on a letter,
   the 'screen' blend piles brightness onto the gold for a brief hit.
   Warm tungsten color (#fff6dc) matches tungsten studio lamps. */
.project__logo-rays {
    position: absolute;
    inset: 0;
    pointer-events: none;
    overflow: hidden;
    -webkit-mask-image: var(--logo-mask);
            mask-image: var(--logo-mask);
    -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    mix-blend-mode: screen;
}

/* Each beam is sculpted into a true volumetric cone by combining two
   masks on the same element:
   - A linear gradient (top→bottom) paints a column that's BRIGHTEST
     near the source (90% mark, aligned with the logo's bottom edge)
     and fades upward, mimicking atmospheric scatter dimming the light
     the further it travels from the lamp.
   - A conic-gradient mask narrows the column to a point at the origin
     (50% 100%) and widens it as it goes up — soft edges on the cone
     boundary come from gradient stops interpolating the mask alpha,
     so the silhouette reads as soft-edged haze, not a hard clip.
   transform-origin pins the pivot at the bottom of the element, which
   (because of top: -80% + height: 200%) corresponds to the logo's
   bottom edge — so each beam swings from ground level. */
.project__logo-beam {
    --beam-color: 255, 246, 220;
    position: absolute;
    top: -80%;
    left: 50%;
    width: 22%;
    height: 200%;
    background: linear-gradient(
        to bottom,
        transparent 0%,
        rgba(var(--beam-color), 0.08) 20%,
        rgba(var(--beam-color), 0.3)  50%,
        rgba(var(--beam-color), 0.72) 78%,
        rgba(var(--beam-color), 1)    90%,
        rgba(var(--beam-color), 0.6)  96%,
        transparent 100%
    );
    -webkit-mask-image: conic-gradient(
        from 0deg at 50% 100%,
        rgba(0,0,0,1) 0deg,
        rgba(0,0,0,1) 3deg,
        rgba(0,0,0,0) 9deg,
        rgba(0,0,0,0) 351deg,
        rgba(0,0,0,1) 357deg,
        rgba(0,0,0,1) 360deg
    );
            mask-image: conic-gradient(
        from 0deg at 50% 100%,
        rgba(0,0,0,1) 0deg,
        rgba(0,0,0,1) 3deg,
        rgba(0,0,0,0) 9deg,
        rgba(0,0,0,0) 351deg,
        rgba(0,0,0,1) 357deg,
        rgba(0,0,0,1) 360deg
    );
    filter: blur(3px);
    transform-origin: 50% 100%;
    mix-blend-mode: screen;
    will-change: transform;
}

/* Beam 1: warmer amber (tungsten with age), left-positioned, longest
   period so it's the one that looks like it's running slightly out of
   sync. cubic-bezier(0.7, 0, 0.3, 1) dwells harder at the extremes
   than ease-in-out — an operator pausing at the edge of the sweep. */
.project__logo-beam--1 {
    --beam-color: 255, 234, 198;
    left: 22%;
    animation: searchlight-a 17s cubic-bezier(0.7, 0, 0.3, 1) -3s infinite alternate;
}
/* Beam 2: neutral center beam, narrower, counter-sweeps 1 and 3. */
.project__logo-beam--2 {
    width: 16%;
    left: 50%;
    animation: searchlight-b 13s cubic-bezier(0.7, 0, 0.3, 1) -6s infinite alternate;
}
/* Beam 3: cooler ivory (fresher arc lamp), slowest sweep. */
.project__logo-beam--3 {
    --beam-color: 255, 252, 232;
    left: 78%;
    animation: searchlight-c 19s cubic-bezier(0.7, 0, 0.3, 1) -10s infinite alternate;
}

@keyframes searchlight-a {
    from { transform: translateX(-50%) rotate(-34deg); }
    to   { transform: translateX(-50%) rotate(22deg); }
}
@keyframes searchlight-b {
    from { transform: translateX(-50%) rotate(28deg); }
    to   { transform: translateX(-50%) rotate(-28deg); }
}
@keyframes searchlight-c {
    from { transform: translateX(-50%) rotate(-22deg); }
    to   { transform: translateX(-50%) rotate(34deg); }
}

@media (prefers-reduced-motion: reduce) {
    .project__logo-beam { animation: none; }
}

/* Stereo / chromatic-aberration variant — two faint logo copies
   (warm red and cool cyan) offset horizontally and breathing in/out.
   At the extremes of each cycle the copies sit at ±1.6% of wrap
   width, giving the logo a clear 3D-glasses-style fringe; at mid-
   cycle they converge to zero offset, letting the base gradient
   read cleanly, and the base itself briefly brightens — reads as
   the stereo pair "locking into focus" before drifting apart again.
   overflow: hidden keeps the shifted copies inside the wrap box
   at the extremes. */
.project__logo-wrap--stereo {
    overflow: hidden;
}
.project__logo-stereo {
    position: absolute;
    inset: 0;
    pointer-events: none;
    -webkit-mask-image: var(--logo-mask);
            mask-image: var(--logo-mask);
    -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    mix-blend-mode: screen;
    will-change: transform;
}
.project__logo-stereo--red {
    /* Warm peach-red — less harsh than pure (255,40,80), but still
       clearly the "warm" half of the anaglyph pair. */
    background: rgba(255, 80, 110, 0.42);
    animation: stereo-red 6s cubic-bezier(0.55, 0.1, 0.45, 0.9) infinite;
}
.project__logo-stereo--cyan {
    /* Sky-cyan — desaturated a touch from pure (0,220,255) so it
       doesn't swamp the teal side of the native gradient. */
    background: rgba(40, 210, 240, 0.42);
    animation: stereo-cyan 6s cubic-bezier(0.55, 0.1, 0.45, 0.9) infinite;
}

/* Breath: wide offset at the extremes (±1.6%) converging to true
   zero at mid-cycle, so there's a real alignment beat each pass. */
@keyframes stereo-red {
    0%, 100% { transform: translateX(-1.6%); }
    50%      { transform: translateX(0%); }
}
@keyframes stereo-cyan {
    0%, 100% { transform: translateX(1.6%); }
    50%      { transform: translateX(0%); }
}

/* Convergence pulse — base img brightens to 1.15× right as the
   stereo copies converge, then back to 1 as they diverge again.
   Without this the alignment moment is just "absence of fringe";
   with it, alignment reads as the 3D image snapping into focus. */
.project__logo-wrap--stereo .project__logo {
    animation: stereo-converge 6s cubic-bezier(0.55, 0.1, 0.45, 0.9) infinite;
}
@keyframes stereo-converge {
    0%, 100% {
        filter:
            drop-shadow(0 1px 4px rgba(0, 0, 0, 0.20))
            brightness(1);
    }
    50% {
        filter:
            drop-shadow(0 1px 4px rgba(0, 0, 0, 0.20))
            brightness(1.15);
    }
}

@media (prefers-reduced-motion: reduce) {
    .project__logo-stereo { animation: none; }
    .project__logo-wrap--stereo .project__logo { animation: none; }
}

/* SynaBrain title — particle canvas renders the text "SynaBrain Labs"
   as glowing nodes. The h2 keeps its semantics; the <canvas> is the
   visual layer and the .visually-hidden <span> carries the accessible
   name for screen readers. Canvas height is a clamp that roughly
   matches what `.project__name` would have been at default font-size,
   with a little extra vertical room for arc wobble and spark arcs. */
.project__name--particles {
    position: relative;
    display: block;
    min-height: clamp(96px, 14vw, 160px);
    max-width: 960px;
    text-shadow: none;
}
.project__particles-canvas {
    display: block;
    width: 100%;
    height: clamp(96px, 14vw, 160px);
}

/* Reduced motion / no-JS fallback: canvas hides itself via inline
   style and the hidden text becomes visible. Style it to look like
   the default .project__name treatment so the fallback is
   indistinguishable from the other project titles. */
@media (prefers-reduced-motion: reduce) {
    .project__name--particles .project__particles-canvas { display: none; }
    .project__name--particles .visually-hidden {
        position: static;
        width: auto;
        height: auto;
        padding: 0;
        margin: 0;
        overflow: visible;
        clip: auto;
        white-space: normal;
        font-size: clamp(48px, 9vw, 128px);
        line-height: 0.98;
        text-shadow: 0 2px 28px rgba(0, 0, 0, 0.5);
    }
}

/* Generic screen-reader-only helper. */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* SynaBrain bg canvas — fills the .bg--synabrain layer. */
.bg__neurons {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
    pointer-events: none;
}

/* Tagline — matched to the hero's "THE MOST VALUABLE CAPITAL IS A
   TIME." treatment. Roboto 13px / 0.32em uppercase / muted with the
   same subtle shadow. Produces a consistent "small label beneath the
   big headline" motif across the intro and every project. */
.project__tagline {
    margin-top: 24px;
    font-family: "Roboto", var(--font-display);
    font-size: 13px;
    font-weight: 400;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    color: var(--fg-muted);
    line-height: 1.5;
    text-shadow: 0 2px 18px rgba(0, 0, 0, 0.5);
    max-width: 40ch;
}

/* =========================================================
   Subsections
   ========================================================= */

/* gap-based spacing so inter-paragraph distance is the same
   regardless of how long any individual paragraph is. Previously
   .subsection used min-height + align-items: center, which gave
   consistent SLOT sizes but made the VISUAL gap between text blocks
   vary with paragraph length ("jumping"). Explicit gap keeps the
   distance between consecutive text blocks constant.
   padding-top bumped 25dvh → 27.5dvh (+10%) for a clearer separation
   between the title trio and the first paragraph. padding-bottom
   provides matched spacing before the CTA. */
.subsections {
    display: flex;
    flex-direction: column;
    padding-top: calc(var(--vh, 1vh) * 27.5);
    padding-bottom: calc(var(--vh, 1vh) * 12);
    gap: calc(var(--vh, 1vh) * 18);
}

/* Each paragraph wears a small "kicker rule" above it — same motif
   as the hero's Est. 2020 rule lines (.hero__kicker-rule). Ties the
   body copy into the rest of the site's typographic vocabulary; each
   paragraph reads as its own deliberate beat in the project's story
   rather than three anonymous blocks of Inter.
   Suppressed when the subsection has its own .subsection__kicker
   (Fund block), where the kicker text + extending rule already
   serves the same role. */
.subsection {
    position: relative;
}
.subsection::before {
    content: '';
    display: block;
    width: clamp(28px, 4vw, 48px);
    height: 1px;
    background: var(--line-strong);
    margin-bottom: clamp(18px, 2.5vh, 28px);
}
.subsection:has(.subsection__kicker)::before {
    content: none;
}

/* Body copy refinements — Inter benefits from a touch of negative
   tracking at these sizes, and 1.5 line-height reads more like the
   tight display headings than the prior 1.55 looseness. The stronger
   text-shadow acts as a per-character scrim so paragraphs stay
   legible over any bg variant without needing a separate overlay.
   :not(.subsection__kicker) excludes the kicker <p>, which is also
   inside .subsection but needs its own (small uppercase Roboto)
   styling — without the :not() the body rule's higher specificity
   was overriding the kicker rules for shared properties. */
.subsection p:not(.subsection__kicker) {
    max-width: 640px;
    font-size: clamp(18px, 2.1vw, 22px);
    line-height: 1.5;
    letter-spacing: -0.01em;
    color: var(--fg);
    text-shadow: 0 1px 28px rgba(0, 0, 0, 0.6);
}

/* =========================================================
   CTA
   ========================================================= */

.cta {
    padding: clamp(24px, 5vh, 60px) 0;
    display: flex;
}

/* Unified button surface — shared by project CTAs (.cta__link)
   and the form submit (.contact-form__submit). Glass plate that
   matches the form input chrome: rgba(5,8,20,0.45) fill, 8px
   backdrop-blur, 6px radius, 1px --line border. Space Grotesk
   16px label sits on the page's display-font line, the existing
   → arrow translates right on hover. */
.cta__link,
.contact-form__submit {
    position: relative;
    overflow: hidden;
    display: inline-flex;
    align-items: center;
    gap: 14px;
    padding: 16px 28px;
    border: 1px solid transparent;
    border-radius: 6px;
    background: rgba(5, 8, 20, 0.45);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    font-family: "Roboto", var(--font-display);
    font-weight: 400;
    font-size: 13px;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    color: var(--fg-muted);
    cursor: pointer;
    transition: transform 200ms ease, border-color 200ms ease;
}

/* Hover shine — same gradient vocabulary as the .hero__title
   GRAPES FOUNDATION shine (white peaks over a base, sweeping
   right→left), accelerated to 2s for button-interaction pace.
   Two layered shines that BOTH leave the glass bg untouched:

   1. Text shine: on hover the bg becomes layered — layer 1 a
      sweeping peak gradient clipped to the text glyphs, layer 2
      the existing glass clipped to padding-box. color:transparent
      reveals the gradient only where text glyphs sit (the →
      arrow span inherits transparent so it gets the sweep too).
   2. Border shine: a ::before with the classic
      padding+mask-composite ring trick — the gradient shows
      ONLY on the 1px border outline. Interior bg masked away. */
.cta__link::before,
.contact-form__submit::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    background: linear-gradient(
        100deg,
        transparent 0%,
        transparent 35%,
        rgba(255, 255, 255, 0.55) 48%,
        rgba(255, 255, 255, 1)    50%,
        rgba(255, 255, 255, 0.55) 52%,
        transparent 65%,
        transparent 100%
    );
    background-size: 250% 100%;
    background-position: 100% 0;
    background-repeat: no-repeat;
    -webkit-mask:
        linear-gradient(#000 0 0) content-box,
        linear-gradient(#000 0 0);
            mask:
        linear-gradient(#000 0 0) content-box,
        linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
            mask-composite: exclude;
    pointer-events: none;
    opacity: 0;
    transition: opacity 200ms ease;
}
.cta__link:hover::before,
.contact-form__submit:hover:not(:disabled)::before {
    opacity: 1;
    animation: button-border-shine 10.08s linear infinite;
}

.cta__link:hover,
.contact-form__submit:hover:not(:disabled) {
    background-color: rgba(5, 8, 20, 0.65);
    border-color: var(--line);
    transform: translateY(-1px);
    /* Same 18s linear cycle as the .hero__title shine — and as
       button-border-shine on ::before — so the white-halo peak
       on the text passes the center at the same moment the
       border-ring peak does. */
    animation: button-text-glow 10.08s linear infinite;
}
.cta__link:active,
.contact-form__submit:active:not(:disabled) {
    transform: translateY(0);
}
.cta__link:focus-visible,
.contact-form__submit:focus-visible {
    outline: 2px solid var(--accent-strong);
    outline-offset: 3px;
}

.cta__arrow {
    display: inline-block;
    transition: transform 200ms ease;
}
/* Mobile-only line break inside the CTA label ("Go to" / "www.X.com").
   Hidden by default so desktop reads as one line; the mobile @media
   block flips it to display:inline. align-items:center on .cta__link
   keeps the arrow vertically centered next to the now-2-line label. */
.cta__break { display: none; }
.cta__link:hover .cta__arrow,
.contact-form__submit:hover:not(:disabled) .cta__arrow {
    transform: translateX(4px);
}

@keyframes button-border-shine {
    from { background-position: 100% 0; }
    to   { background-position: -150% 0; }
}
/* Text-halo glow synced to button-border-shine — same duration,
   same linear timing, peak at 50%. Halo blur + alpha both ramp
   0 → 14px @ 0.7 → 0, so the visible flash on the text crosses
   the middle of the button at the same moment the bright
   gradient peak crosses the middle of the border ring. */
@keyframes button-text-glow {
    0%, 100% { text-shadow: 0 0 0 rgba(255, 255, 255, 0); }
    50%      { text-shadow: 0 0 14px rgba(255, 255, 255, 0.7); }
}

/* =========================================================
   Intro hero
   ========================================================= */

.project--intro {
    min-height: calc(var(--vh, 1vh) * 100);
    display: grid;
    place-items: center;
    text-align: center;
    padding-top: 40px;
    padding-bottom: 60px;
}

.hero { max-width: 1080px; }

.hero__kicker {
    font-family: "Roboto", var(--font-display);
    font-size: 13px;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    color: var(--fg-muted);
    margin: 0 0 40px;
    display: inline-flex;
    align-items: center;
    gap: 16px;
    font-weight: 400;
}
.hero__kicker-rule {
    display: inline-block;
    width: clamp(32px, 6vw, 64px);
    height: 1px;
    background: var(--line-strong);
}

/* Shine sweep on "GRAPES FOUNDATION". The letters render at 20%
   white (translucent base, so the fixed background shows through);
   a moving bright band sweeps across and paints those letters at
   100% for the duration of the pass.

   Two bright stops 50% apart in the gradient, paired with
   background-size: 200%. As one exits the right edge, the next
   enters from the left — the loop has no dead zone and no jump. */
.hero__title {
    font-family: "Roboto", var(--font-display);
    font-weight: 900;
    font-size: clamp(56px, 13vw, 180px);
    line-height: 0.95;
    letter-spacing: -0.01em;
    text-transform: uppercase;
    background-image: linear-gradient(
        100deg,
        rgba(255, 255, 255, 0.2) 0%,
        rgba(255, 255, 255, 0.2) 12%,
        rgba(255, 255, 255, 0.4) 18%,
        rgba(255, 255, 255, 0.75) 23%,
        rgba(255, 255, 255, 1)    25%,
        rgba(255, 255, 255, 0.75) 27%,
        rgba(255, 255, 255, 0.4) 32%,
        rgba(255, 255, 255, 0.2) 38%,
        rgba(255, 255, 255, 0.2) 62%,
        rgba(255, 255, 255, 0.4) 68%,
        rgba(255, 255, 255, 0.75) 73%,
        rgba(255, 255, 255, 1)    75%,
        rgba(255, 255, 255, 0.75) 77%,
        rgba(255, 255, 255, 0.4) 82%,
        rgba(255, 255, 255, 0.2) 88%,
        rgba(255, 255, 255, 0.2) 100%
    );
    background-size: 200% 100%;
    background-repeat: repeat;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
    filter:
        drop-shadow(0 2px 6px rgba(0, 0, 0, 0.55))
        drop-shadow(0 14px 48px rgba(0, 0, 0, 0.45));
    animation: hero-shine 18s linear infinite;
    will-change: background-position;
}

@keyframes hero-shine {
    from { background-position: 100% 0; }
    to   { background-position: -100% 0; }
}

.hero__subtitle {
    margin: 40px auto 0;
    font-family: "Roboto", var(--font-display);
    font-size: 13px;
    font-weight: 400;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    color: var(--fg-muted);
    line-height: 1.5;
    text-shadow: 0 2px 18px rgba(0, 0, 0, 0.5);
}

/* Scroll cue — slim animated bar, no outer container.
   Hidden once the user starts scrolling (one-shot, see main.js).
   Fade-out: opacity + slight downward drift over 700ms. */
.scroll-cue {
    position: absolute;
    bottom: 40px;
    left: 50%;
    transform: translateX(-50%);
    width: 2px;
    height: 24px;
    display: block;
    opacity: 1;
    transition: opacity 700ms ease-out, transform 700ms ease-out;
}
.scroll-cue.is-hidden {
    opacity: 0;
    transform: translateX(-50%) translateY(14px);
    pointer-events: none;
}
.scroll-cue span {
    display: block;
    width: 2px;
    height: 12px;
    border-radius: 1px;
    background: var(--fg);
    animation: scrollcue 1.8s ease-in-out infinite;
}
.scroll-cue:focus-visible {
    outline: 2px solid var(--accent-strong);
    outline-offset: 6px;
    border-radius: 2px;
}
@keyframes scrollcue {
    0%   { transform: translateY(0);    opacity: 1; }
    60%  { transform: translateY(14px); opacity: 0.1; }
    100% { transform: translateY(0);    opacity: 0; }
}

/* =========================================================
   About (founder bio + quote)
   ========================================================= */

.project--about {
    min-height: calc(var(--vh, 1vh) * 100);
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
    padding-top: clamp(60px, 9vh, 100px);
    padding-bottom: clamp(60px, 9vh, 100px);
    gap: clamp(40px, 6vh, 72px);
}

.founder {
    display: grid;
    grid-template-columns: minmax(260px, 360px) 1fr;
    gap: clamp(32px, 5vw, 72px);
    align-items: center;
    max-width: 1100px;
    width: 100%;
    margin: 0 auto;
}

.founder__photo {
    display: block;
    border-radius: clamp(20px, 2.6vw, 32px);
    overflow: hidden;
    box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
    background: rgba(5, 8, 20, 0.4);
}
.founder__photo img {
    width: 100%;
    height: auto;
    display: block;
}

.founder__bio { max-width: 56ch; }

.founder__kicker {
    font-family: "Roboto", var(--font-display);
    font-size: 13px;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    color: var(--fg-dim);
    margin: 0 0 24px;
    font-weight: 400;
}

.founder__name {
    font-size: clamp(40px, 7vw, 88px);
    line-height: 0.98;
    letter-spacing: -0.02em;
    font-weight: 700;
    text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5);
}

.founder__role {
    margin-top: 12px;
    font-family: var(--font-display);
    font-size: clamp(16px, 1.6vw, 20px);
    color: var(--fg-muted);
    letter-spacing: 0.02em;
    font-weight: 500;
}

.founder__bio-text {
    margin-top: 20px;
    font-size: clamp(16px, 1.5vw, 18px);
    line-height: 1.6;
    color: var(--fg);
    text-shadow: 0 1px 12px rgba(0, 0, 0, 0.4);
}

.founder__quote {
    max-width: 880px;
    width: 100%;
    margin: 0 auto;
    padding: clamp(32px, 5vh, 56px) 0 0;
    border-top: 1px solid var(--line);
    text-align: center;
}
.founder__quote p {
    margin: 0;
    font-family: "Instrument Serif", Georgia, serif;
    font-style: italic;
    font-size: clamp(22px, 2.6vw, 32px);
    line-height: 1.42;
    color: var(--fg);
    text-shadow: 0 2px 18px rgba(0, 0, 0, 0.45);
}
.founder__quote cite {
    display: block;
    margin-top: 18px;
    font-style: normal;
    font-family: "Roboto", var(--font-display);
    font-size: 12px;
    letter-spacing: 0.28em;
    text-transform: uppercase;
    color: var(--fg-dim);
}

/* =========================================================
   Project Overview (transition slate)
   ========================================================= */

/* Content anchored to the bottom of the section, so the last line of
   the lede sits at overview_bottom == anser_top. Combined with the
   125dvh anser header placing "01" at 100dvh into the next section,
   the moment the lede exits the top of the viewport is the same tick
   that "01" enters the bottom — same handoff rule as the project-to-
   project transitions further down. */
.project--overview {
    min-height: calc(var(--vh, 1vh) * 100);
    display: grid;
    place-items: end center;
    text-align: center;
}
.overview { max-width: 760px; }

.overview__kicker {
    font-family: "Roboto", var(--font-display);
    font-size: 13px;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    color: var(--fg-dim);
    margin: 0 0 24px;
    font-weight: 400;
}

.overview__title {
    font-size: clamp(48px, 8vw, 104px);
    line-height: 0.98;
    letter-spacing: -0.02em;
    font-weight: 700;
    text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5);
}

.overview__lede {
    margin-top: 32px;
    font-size: clamp(18px, 2vw, 22px);
    line-height: 1.55;
    color: var(--fg-muted);
    text-shadow: 0 1px 14px rgba(0, 0, 0, 0.45);
}

/* =========================================================
   Contact
   ========================================================= */

/* Fund has no CTA (no public product domain) — its closing beat is
   the last paragraph. Extra padding-bottom gives it a deliberate
   end-of-section pause before Contact starts, without going all the
   way to the 100dvh project-to-project handoff. Fund and Contact
   share the hero bg anyway, so the transition is felt as a change
   in content, not a change in bg. */
.project--fund {
    padding-bottom: clamp(320px, 50vh, 600px);
    margin-bottom: clamp(80px, 12vh, 160px);
}

/* Per-subsection kicker pattern — small uppercase Roboto matching
   the Est. plaque vocabulary, with a flex-grow rule extending to
   the right edge of the paragraph below. Scope is content-driven:
   any .subsection that contains a .subsection__kicker gets the
   max-width constraint that aligns the kicker rule with the body
   paragraph's right edge. Subsections without a kicker (other
   projects) are unaffected. */
.subsection:has(.subsection__kicker) {
    max-width: 640px;
}
.subsection__kicker {
    font-family: "Roboto", var(--font-display);
    font-size: 13px;
    font-weight: 400;
    letter-spacing: 0.32em;
    color: var(--fg-muted);
    text-transform: uppercase;
    display: flex;
    align-items: center;
    gap: 16px;
    width: 100%;
    margin: 0 0 24px;
    padding-inline-end: 0.32em;
}
.subsection__rule {
    flex-grow: 1;
    height: 1px;
    background: var(--line-strong);
}
/* Mobile-only line break inside the kicker text. Hidden by default
   so the kicker reads as one line on desktop; the mobile @media block
   below flips it to display:inline so the <br> creates a real wrap.
   The wrapped 2-line text + align-items:center on the flex container
   then puts the side rules at the vertical middle of the text block. */
.subsection__kicker-break { display: none; }

.project--contact {
    min-height: calc(var(--vh, 1vh) * 100);
    display: flex;
    flex-direction: column;
    align-items: center;
    padding-bottom: 40px;
}

.project--contact .project__header {
    min-height: auto;
    padding-top: clamp(80px, 14vh, 160px);
    padding-bottom: 48px;
    align-items: center;
    text-align: center;
}
.project--contact .project__tagline-group {
    align-self: center;
    text-align: center;
}

.contact-grid {
    display: flex;
    justify-content: center;
    max-width: 1100px;
    width: 100%;
    margin-bottom: 48px;
    flex: 1;
}
.contact-grid .contact-form {
    width: 100%;
    max-width: 560px;
}

/* Underline-only form: every field is a typographic line, not
   a box. The bottom border of each input picks up the same
   --line-strong rule the page uses for .subsection__rule,
   .project__index-rule, and the hero kicker rules — the form
   reads as part of the same "rule line" family. */
.contact-form {
    display: flex;
    flex-direction: column;
    gap: 36px;
}

.contact-form__field {
    display: flex;
    flex-direction: column;
    gap: 10px;
}

/* Labels match the page's kicker vocabulary — same Roboto
   13px / 0.32em uppercase / fg-muted as .subsection__kicker
   and .project__index — centered between two flex-grow rule
   lines (::before for the leading rule, .contact-form__label-
   rule for the trailing one), echoing the .subsection__kicker
   mobile pattern on every breakpoint here. */
.contact-form__label {
    font-family: "Roboto", var(--font-display);
    font-size: 13px;
    font-weight: 400;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    color: var(--fg-muted);
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    gap: 16px;
    width: 100%;
}
.contact-form__label::before {
    content: '';
    flex-grow: 1;
    height: 1px;
    background: var(--line-strong);
}
.contact-form__label-rule {
    flex-grow: 1;
    height: 1px;
    background: var(--line-strong);
}

/* Inputs use the same glassy/translucent treatment as the rest of
   the form chrome — fill is a medium gray (rgba(120,120,120,0.45))
   that reads ~half as bright as the previous near-white tint, so
   the field sits midway between the dark page bg and a true white
   card. Text flips back to light to keep contrast against the now-
   darker fill. Everything OUTSIDE the inputs (labels, subsection
   text, button, footer) stays unchanged. */
.contact-form input,
.contact-form textarea {
    width: 100%;
    padding: 12px 14px;
    background: rgba(120, 120, 120, 0.45);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    border: none;
    border-radius: 6px;
    color: var(--fg);
    font-family: var(--font-body);
    font-size: 18px;
    line-height: 1.4;
    transition: background-color 200ms ease, box-shadow 200ms ease;
}
.contact-form input:focus,
.contact-form textarea:focus {
    outline: none;
    background: rgba(120, 120, 120, 0.65);
    box-shadow: 0 0 0 1px var(--accent-strong);
}
.contact-form textarea {
    resize: vertical;
    min-height: 88px;
}
.contact-form input::placeholder,
.contact-form textarea::placeholder {
    color: var(--fg-dim);
    opacity: 1;
}

.contact-form__field.is-error input,
.contact-form__field.is-error textarea,
.contact-form__field.is-error select,
.contact-form__field.is-error .contact-form__phone-row {
    box-shadow: 0 0 0 1px #ef4444;
}

/* Phone field: dial + national-format input share a single
   underline rule, separated by a thin vertical hairline. The
   row carries the bottom border so the two sub-fields read as
   one composite phone control instead of two independent boxes. */
.contact-form__phone-row {
    display: grid;
    grid-template-columns: minmax(96px, max-content) 1fr;
    align-items: center;
    background: rgba(120, 120, 120, 0.45);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    border-radius: 6px;
    transition: background-color 200ms ease, box-shadow 200ms ease;
}
.contact-form__phone-row:focus-within {
    background: rgba(120, 120, 120, 0.65);
    box-shadow: 0 0 0 1px var(--accent-strong);
}
.contact-form__phone-dial {
    padding: 12px 28px 12px 14px;
    background-color: transparent;
    /* Chevron stroke now matches the light text color of the field. */
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='rgba(245,245,245,0.55)' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'><path d='M1 1.5l5 5 5-5'/></svg>");
    background-repeat: no-repeat;
    background-position: right 8px center;
    background-size: 10px 7px;
    border: none;
    /* Divider tinted light to read on the medium-gray field bg. */
    border-right: 1px solid var(--line);
    border-radius: 0;
    color: var(--fg);
    font-family: var(--font-body);
    font-size: 18px;
    line-height: 1.4;
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
}
.contact-form__phone-dial:focus { outline: none; }
/* The native dropdown popup is OS-styled — keep it dark since it
   floats over the page and reads better as a contrast surface. */
.contact-form__phone-dial option {
    background: #050814;
    color: var(--fg);
}
.contact-form__phone-row input[type="tel"] {
    background: transparent;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border-bottom: none;
    padding: 12px 14px 12px 16px;
}
.contact-form__phone-row input[type="tel"]:focus {
    background: transparent;
}

/* Honeypot — bots auto-fill, humans never see. Off-screen so
   the field still ships a value (vs display:none, which some
   bots skip). */
.contact-form__hp {
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
    pointer-events: none;
}

/* Consent — custom checkbox + glass card. The whole label sits on
   the same medium-gray fill as the input fields so it reads as part
   of the form's chrome family.
   The checkbox itself is a custom-rendered square with a
   background-image SVG checkmark on :checked (pseudo-elements
   don't reliably render on <input> across browsers — the
   prior ::after checkmark didn't show in some engines). The
   unchecked state has the same translucent gray fill as the card
   so the box reads as a tiny extension of the consent panel; on
   :checked it flips to the brand accent. */
.contact-form__consent {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 14px 16px;
    background: rgba(60, 60, 60, 0.45);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    border-radius: 6px;
    font-size: 13px;
    line-height: 1.55;
    color: var(--fg-muted);
    cursor: pointer;
}
/* Checkbox bg matches the INPUT-field family (medium gray) rather
   than the consent panel it sits in. With the panel at rgba(60,60,
   60,0.45) and the checkbox formerly at the same value, the box bg
   disappeared into the panel — only the bright border read as the
   shape, which made the control look like a floating outline. The
   medium-gray fill gives the checkbox a distinct body, and the
   border softens to --line so the outline doesn't overpower the
   small box. */
.contact-form__consent input[type="checkbox"] {
    flex-shrink: 0;
    appearance: none;
    -webkit-appearance: none;
    width: 18px;
    height: 18px;
    margin: 0;
    border: 1px solid var(--line);
    border-radius: 6px;
    background-color: rgba(120, 120, 120, 0.45);
    background-repeat: no-repeat;
    background-position: center;
    background-size: 12px 12px;
    cursor: pointer;
    transition: border-color 180ms ease, background-color 180ms ease;
}
.contact-form__consent input[type="checkbox"]:hover {
    border-color: var(--line-strong);
}
.contact-form__consent input[type="checkbox"]:checked {
    background-color: var(--accent-strong);
    border-color: var(--accent-strong);
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23050814' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='2.5 6.5 5 9 9.5 3.5'/></svg>");
}
.contact-form__consent input[type="checkbox"]:focus-visible {
    outline: 2px solid var(--accent-strong);
    outline-offset: 2px;
}
.contact-form__consent span { user-select: none; }

/* Submit — picks up the unified .cta button surface defined above
   for the layout/shine/hover-translate vocabulary, but overrides the
   bg + label color to the contact-form's medium-gray-glass family
   instead of the dark-navy-glass that .cta__link uses for the
   project CTAs. Hover bg also bumped accordingly. */
.contact-form__submit {
    align-self: flex-start;
    margin-top: 12px;
    background: rgba(60, 60, 60, 0.45);
    color: var(--fg);
}
.contact-form__submit:hover:not(:disabled) {
    background-color: rgba(60, 60, 60, 0.65);
}
.contact-form__submit:disabled {
    opacity: 0.45;
    cursor: default;
}

.contact-form__status {
    margin: 0;
    font-size: 14px;
    line-height: 1.55;
    color: var(--accent-strong);
}
.contact-form__status--error { color: #ff8a8a; }

/* =========================================================
   Site footer
   ========================================================= */

.site-footer {
    padding-top: 40px;
    border-top: 1px solid var(--line);
    color: var(--fg-dim);
    font-size: 13px;
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 24px;
    max-width: 1100px;
    width: 100%;
}
.site-footer__seal,
.site-footer__copy {
    font-family: "Roboto", var(--font-display);
    font-size: 11px;
    font-weight: 400;
    letter-spacing: 0.28em;
    text-transform: uppercase;
    color: var(--fg-muted);
    line-height: 1.5;
    opacity: 0.6;
}

/* Footer feature flag (FEATURE_FOOTER_MENU in main.js).
   - Default (flag off): seal text shows on the left, nav hidden.
   - body.has-footer-menu (flag on): nav shows on the left in place
     of the seal. Both elements stay in the DOM; visibility swaps. */
.site-footer__links {
    display: none;
    gap: 18px;
    font-family: "Roboto", var(--font-display);
    font-size: 11px;
    font-weight: 400;
    letter-spacing: 0.28em;
    text-transform: uppercase;
    color: var(--fg-muted);
    opacity: 0.6;
}
body.has-footer-menu .site-footer__seal { display: none; }
body.has-footer-menu .site-footer__links { display: flex; }
.site-footer__links a {
    color: inherit;
    transition: opacity 200ms ease, color 200ms ease;
}
.site-footer__links a:hover {
    opacity: 1;
    color: var(--fg);
}

/* =========================================================
   Version badge (preview-only via 404-hide mechanism)
   ========================================================= */

#version-btn {
    position: fixed;
    right: 12px;
    bottom: 12px;
    padding: 4px 8px;
    font: 500 12px/1 var(--font-display);
    color: rgba(255, 255, 255, 0.82);
    background: rgba(0, 0, 0, 0.4);
    border: 1px solid rgba(255, 255, 255, 0.25);
    border-radius: 4px;
    cursor: default;
    z-index: 20;
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
}

/* =========================================================
   Responsive refinements
   ========================================================= */

@media (max-width: 768px) {
    body { font-size: 16px; }

    /* Longhand padding so it doesn't clobber padding-bottom from
       more-specific section rules like .project--contact { padding-
       bottom: 40px } and .project--fund { padding-bottom: clamp(...) }.
       The previous shorthand `padding: 0 24px` reset all four sides
       and wiped the section-specific bottom padding on mobile. */
    .project {
        padding-left: 24px;
        padding-right: 24px;
    }

    .project__header { padding-bottom: 24px; }

    /* Logos centered + bigger on mobile. The header's align-items
       centers the h2 (logo wrapper) without text-align: center —
       that would propagate to the tagline-group below, which the
       user wants kept LEFT-aligned. The Fund title is plain text
       (no logo) and gets explicit text-align: center to stay
       centered like the other project logos. */
    .project__header {
        align-items: center;
    }
    .project__name--logo {
        justify-content: center;
        min-height: clamp(140px, 22vw, 200px);
    }
    .project__name--shine {
        text-align: center;
        /* Bigger font on mobile so the two-line "GRAPES VISION /
           FUND I" reads at the same visual weight as the image
           logos. Default clamp(48,9vw,128) bottoms out at 48px
           on phones, which gives only ~90px of total title height
           — versus ~140px for the other projects' logos. */
        font-size: clamp(72px, 16vw, 120px);
    }
    /* SynaBrain canvas on mobile: synabrain-title.js stacks the two
       words vertically (one per row) below 768 px, so the canvas
       needs roughly double the height to give each row room to
       breathe. Each word fills the canvas width on its own row, so
       the width-bound font ends up much bigger than in the
       single-line desktop layout — letters now match the visual
       weight of the other projects' logos on mobile. */
    .project__name--particles {
        width: 100%;
        max-width: min(95vw, 540px);
        min-height: clamp(220px, 36vw, 300px);
    }
    .project__particles-canvas {
        height: clamp(220px, 36vw, 300px);
        max-width: 100%;
    }
    .project__logo-wrap {
        width: clamp(280px, 75vw, 420px);
    }
    .project__logo {
        max-height: clamp(140px, 22vw, 200px);
    }
    /* Zorachka +15% on mobile (matches the desktop +10% from the
       .project__logo-wrap--mono overrides, scaled up for mobile).
       translateX nudges the wrap ~3% right of its centered position
       so the WORDMARK reads as horizontally centered rather than
       the full bounding box. The Zorachka SVG has a small star at
       its far right (~6% of the viewBox width) — without the
       nudge, the wordmark itself sits ~3% left of optical center. */
    .project__logo-wrap--mono {
        width: clamp(322px, 86vw, 483px);
        transform: translateX(3%);
    }
    .project__logo-wrap--mono .project__logo {
        max-height: clamp(161px, 25vw, 230px);
    }
    /* HoollyWoody +20% on mobile (matches the desktop +15% from
       the .project__logo-wrap--searchlight overrides, scaled up
       slightly for the mobile sizing baseline). */
    .project__logo-wrap--searchlight {
        width: clamp(336px, 90vw, 504px);
    }
    .project__logo-wrap--searchlight .project__logo {
        max-height: clamp(168px, 26vw, 240px);
    }
    /* Tagline + Est plaque centered on mobile, with rules on
       BOTH sides of the EST line. The trailing-rule + flex-grow
       trick from desktop becomes symmetric here: a ::before
       pseudo-rule on .project__index gives a matching left rule,
       and the existing right .project__index-rule still flex-
       grows. Equal grow on both sides ⇒ "Est. YYYY" centered
       between equal-length rules. The padding-inline-end that
       compensated trailing letter-spacing on desktop is removed
       so the layout stays symmetric. */
    .project__tagline-group {
        align-self: center;
    }
    .project__tagline {
        text-align: center;
    }
    .project__index {
        padding-inline-end: 0;
    }
    .project__index::before {
        content: '';
        flex-grow: 1;
        height: 1px;
        background: var(--line-strong);
    }

    /* Sub-block kickers (e.g. "FLIGHT THAT FEELS LIKE DRIVING")
       get the same symmetric two-rule + centered treatment. Plus
       align-self: center on the subsection so the kicker reads
       as a centered caption above its (left-aligned) paragraph. */
    .subsection:has(.subsection__kicker) {
        align-self: center;
    }
    .subsection__kicker {
        padding-inline-end: 0;
        /* Center the wrapped 2-line text inside its flex item. The
           item's width is the longer line's max-content width; the
           shorter line centers within that. */
        text-align: center;
    }
    .subsection__kicker::before {
        content: '';
        flex-grow: 1;
        height: 1px;
        background: var(--line-strong);
    }
    /* Activate the kicker line break on mobile (hidden by default —
       see the .subsection__kicker-break rule above). */
    .subsection__kicker-break { display: inline; }

    /* CTA buttons on mobile: split label to "Go to" / "www.X.com".
       Label stays left-aligned (default) so "Go to" sits flush with
       the URL below. The arrow stays a separate flex item, vertically
       centered against the 2-line label. */
    .cta__break { display: inline; }

    /* Generous bottom padding on Contact so the footer text isn't
       crammed against the viewport edge / iOS home indicator. */
    .project--contact {
        padding-bottom: max(56px, env(safe-area-inset-bottom, 0) + 32px);
    }
    .site-footer {
        flex-direction: column;
        align-items: flex-start;
        gap: 8px;
        padding-top: 28px;
    }
    .site-footer__seal,
    .site-footer__copy {
        font-size: 10px;
        letter-spacing: 0.24em;
    }
    .site-footer__links {
        gap: 14px;
        font-size: 10px;
        letter-spacing: 0.24em;
    }

    .contact-grid {
        grid-template-columns: 1fr;
        gap: 32px;
    }

    .contact-grid .contact-form { max-width: 100%; }

    .founder {
        grid-template-columns: 1fr;
        gap: 28px;
    }
    .founder__photo {
        max-width: 260px;
        margin: 0 auto;
    }
    /* Center the whole founder bio on mobile to match the centered
       photo above and quote below. margin-inline centers the box
       itself (a grid item capped by its 56ch max-width otherwise
       hugs the start of the single column); text-align cascades to
       the kicker, name, role, and both bio paragraphs. */
    .founder__bio {
        margin-inline: auto;
        text-align: center;
    }

    #version-btn { right: 8px; bottom: 8px; }
}

@media (max-width: 420px) {
    .hero__title { letter-spacing: -0.04em; }
}

/* =========================================================
   Reduced motion — strip the blur transition and scroll smooth
   ========================================================= */

@media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    /* Keep a short opacity crossfade between background layers even with
       reduced motion — !important so the catch-all `* { transition-
       duration: 0.01ms !important }` below doesn't collapse it into a
       hard cut between full-screen images (which reads as a jarring
       flash, worse than a gentle fade). An opacity fade isn't the kind
       of movement the setting targets; the duration is just shortened. */
    .bg {
        transition: opacity 200ms linear !important;
        filter: none !important;
    }
    .scroll-cue span { animation: none; opacity: 0.6; }
    .hero__title {
        animation: none;
        background: none;
        -webkit-text-fill-color: rgba(255, 255, 255, 0.85);
        color: rgba(255, 255, 255, 0.85);
    }
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}
