
  /* Aspekta — self-hosted variable sans (SIL OFL 1.1, see
     /hero-v2/assets/fonts/aspekta/LICENSE.txt), replacing DM Sans and
     Archivo Expanded as the site's one typeface. One variable file
     covers the whole 100-900 weight range we use (regular body copy
     through the statement/services display weights), so no separate
     @font-face per weight the way the two Google Fonts it replaces
     needed. DM Sans stays listed as the fallback in every font-family
     below rather than being removed, so a failed/blocked font load
     degrades to the previous look instead of the browser default. */
  @font-face {
    font-family: 'Aspekta Variable';
    font-weight: 100 900;
    font-display: swap;
    font-style: normal;
    src: url('https://cdn.prod.website-files.com/6aaab8aa5da02fa0b7406b4a/6aad41da541e9e7bb7608099_AspektaVF.woff2') format('woff2');
  }
  :root{
    /* PALETTE — the whole site's six colors, each with exactly one
       job. Every other color in this file (the CSS below, the JS
       configs, and the canvas/shader draw calls) reads from these
       var()s or from window.PALETTE (the matching JS mirror, defined
       at the top of the first script block) rather than a hard-coded
       hex, so the whole palette is a one-line edit in either place. */
    --color-near-black: #0B0B0F; /* dark section backgrounds, text on light */
    --color-cream: #E9E6DF;      /* light section backgrounds, text on dark */
    --color-orange: #FF4F00;     /* brand accent + anything interactive */
    --color-violet: #7A5CFF;     /* footer; situation card hover fill; also the hero blob glow on pages that have one — own fix, explicit request: consolidated from a separate --color-teal var that had been renamed to hold this same violet */
    --color-yellow: #E6FF00;     /* motion only — cursor trail + doodle sparks. Never static text/backgrounds/UI. */

    --bg: var(--color-cream);
    --ink: var(--color-near-black);
    --logo: var(--color-orange);
    --blob-core: var(--color-violet);
    --blob-mid: color-mix(in srgb, var(--color-violet) 70%, var(--color-cream) 30%);
    --blob-edge: color-mix(in srgb, var(--color-violet) 40%, var(--color-cream) 60%);
  }
  *{ box-sizing: border-box; }
  html, body{
    margin: 0;
    background: var(--bg);
    color: var(--ink);
    overflow-x: hidden;
    font-family: 'Aspekta Variable', 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
  }

  /* ============================================================
     SITE NAV — fixed top nav, own build modeled on rejouice.com's
     own layout (logo far left, small text links center-right,
     "Let's talk ↗" far right — read live via DOM/CSSOM, not their
     code/fonts/images). mix-blend-mode: difference (own choice, the
     spec's own alternative to per-section color switching) is what
     keeps the white/grey text readable over both the dark and cream
     sections in one rule: a white element differenced against a
     near-black backdrop (#0B0B0F) resolves to near-white, and against
     the cream backdrop (#E9E6DF) resolves to near-black — both read
     as strong, correctly-inverted contrast automatically, including
     at every in-between scroll position, with nothing to recompute
     against section boundaries. The scroll-spy "current" state still
     needs real section-boundary tracking (see the script's own
     IntersectionObserver) — that's link *state*, not color; the
     color itself (white vs muted grey below) still differences the
     same way regardless of which section is behind it.
  ============================================================ */
  :root{
    --nav-height: 72px;
  }
  .site-nav{
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 200;
    display: flex;
    align-items: center;
    height: var(--nav-height);
    padding: 0 clamp(20px, 4vw, 48px);
    background: transparent;
    mix-blend-mode: difference;
    transition: transform .25s ease;
  }
  .site-nav.is-hidden{
    transform: translateY(-100%);
  }
  .site-nav__logo{
    display: inline-flex;
    align-items: center;
    color: #fff;
    text-decoration: none;
  }
  .site-nav__logo-icon{
    width: 28px;
    height: auto;
    opacity: 1;
    transition: opacity .3s ease;
  }
  /* hidden while the hero (own large flame-hand centerpiece) is in
     view — own follow-up. contact.html has no #hero, so the nav
     script's own IntersectionObserver never applies this class and
     the icon just stays visible here. */
  .site-nav__logo-icon.is-hidden-on-hero{
    opacity: 0;
  }
  /* ---- shared flame-hand icon — the same artwork/animation now
     used in two places (the nav logo above, and the hero blob
     centerpiece below), so the flicker keyframes live once here
     rather than duplicated per instance. ---- */
  .flame-hand-icon .flame{
    opacity: 0;
    animation: flameHandFlicker .6s steps(1) infinite;
  }
  @keyframes flameHandFlicker{
    0%{ opacity: 1; }
    16.667%{ opacity: 0; }
    100%{ opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce){
    .flame-hand-icon .flame{ animation: none; }
    .flame-hand-icon .flame.f0{ opacity: 1; }
  }
  .site-nav__links{
    display: flex;
    align-items: center;
    gap: 28px; /* CONFIRMED tight spacing between links, per spec */
    margin-left: auto;
    margin-right: 40px;
  }
  .site-nav__links a,
  .site-nav__cta{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 200;
    font-size: 16px; /* CONFIRMED ~16px, per spec */
    color: #fff;
    text-decoration: none; /* no underlines, per spec */
    white-space: nowrap;
    transition: color .2s ease;
  }
  .site-nav__links a:hover,
  .site-nav__cta:hover{
    color: #808080; /* fades to muted grey on hover, per spec — same value the scroll-spy "current" state below uses */
  }
  .site-nav__links a.is-current{
    color: #808080; /* the section in view, per spec's own "like Home in the reference" */
  }
  .site-nav__cta{
    display: inline-flex;
    align-items: center;
    gap: 6px;
  }
  .site-nav__arrow{
    display: inline-block;
    transition: transform .2s ease;
  }
  .site-nav__cta:hover .site-nav__arrow{
    transform: translate(3px, -3px); /* nudges up-right a few px on hover, per spec */
  }
  .site-nav__logo:focus-visible,
  .site-nav__links a:focus-visible,
  .site-nav__cta:focus-visible,
  .site-nav__menu-btn:focus-visible{
    outline: 2px solid #fff;
    outline-offset: 4px;
  }
  .site-nav__menu-btn{
    display: none; /* desktop: no menu button — see the max-width override below */
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 16px;
    color: #fff;
    background: none;
    border: none;
    cursor: pointer;
    padding: 8px;
  }

  /* ---- mobile: logo + "Menu" text button only (see the max-width
     override below), opening this full-screen overlay of the same
     four links in big type — own build, not blended (solid
     near-black ground instead), so it stays legible full-bleed
     regardless of whatever's scrolled behind it. ---- */
  .site-nav__mobile{
    position: fixed;
    inset: 0;
    z-index: 199;
    background: var(--color-near-black);
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: 24px;
    padding: 0 clamp(24px, 8vw, 60px);
    opacity: 0;
    visibility: hidden;
    transition: opacity .3s ease;
  }
  .site-nav__mobile.is-open{
    opacity: 1;
    visibility: visible;
  }
  .site-nav__mobile a{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: clamp(32px, 10vw, 56px); /* big type, per spec */
    color: var(--color-cream);
    text-decoration: none;
  }
  .site-nav__mobile a:hover,
  .site-nav__mobile a:focus-visible{
    color: var(--color-orange);
  }

  @media (max-width: 767px){
    .site-nav__links, .site-nav__cta{ display: none; }
    /* own fix, synced with index.html: .site-nav__links carried the
       margin-left:auto spacer that pushes everything after the logo
       to the right edge — hiding it above also removed that spacer,
       landing the menu button right next to the logo. Moving
       margin-left:auto onto the menu button itself keeps "logo left,
       Menu right" regardless of which siblings are hidden. */
    .site-nav__menu-btn{ display: inline-block; margin-left: auto; min-width: 44px; min-height: 44px; }
    /* own fix, synced with index.html: mix-blend-mode: difference (see
       the base .site-nav rule) read as broken on mobile — logo/"Menu"
       turning unreadable colors (green/blue) over the contact page's
       own violet footer and other saturated sections. Solid ground,
       no blend mode. */
    .site-nav{
      background: #0B0B0F;
      mix-blend-mode: normal;
      height: 64px;
    }
    .site-nav__logo, .site-nav__menu-btn{ color: var(--color-cream); }
    .site-nav__logo-icon{ color: var(--color-cream); }
  }

  /* invisible scroll targets for the two sections whose own id is
     already used elsewhere in this file's own script (see the HTML
     comment at each one) — scroll-margin-top makes each the right
     landing spot even for a plain, JS-free hash jump, not just the
     script's own smooth-scroll-with-offset. */
  .nav-anchor{
    display: block;
    height: 0;
    scroll-margin-top: var(--nav-height);
  }
  @media (prefers-reduced-motion: reduce){
    .site-nav{ transition: none; } /* no hide/show slide, per spec — the script's own scroll handler skips hiding it entirely under reduced motion too, so this is belt-and-suspenders */
    .site-nav__mobile{ transition: opacity .15s ease; } /* a quick fade only — no motion beyond that */
  }

  /* shared doodle base rule — same as index.html's own (missing
     here was the footer doodle bug: without this, the footer's own
     mountain doodle and moon doodle had no intrinsic sizing and
     collapsed to 0 height). */
  .doodle{
    position: absolute;
    display: block;
    height: auto;
    aspect-ratio: 1 / 1;
    pointer-events: none;
    user-select: none;
  }


/* ============================================================
     FOOTER v2 — own build, structure modeled on the Neue Montreal
     product-page footer at pangrampangram.com/products/neue-montreal
     (confirmed live: a small logo mark top-left beside a grid of
     titled link columns, each with a thin rule under its title and a
     thin rule down its left edge, a full-width divider, then a
     dominant display-text line lower in the footer, and a slim
     credits row at the very bottom). Own colors, own copy, own "To
     The Moon" line, own columns/content throughout — matched
     structure only, nothing copied. Replaces the earlier "EBD"
     wordmark footer (see git history). Violet background + white
     text/lines (data-theme="dark" so the cursor trail keeps its
     light/bright default color here, same mechanism every other
     data-theme switch in this file uses — this background reads
     dark enough to want that). Columns, moon line, and bottom row
     all built by script from FOOTER_CONFIG, same convention as every
     other generated section.
  ============================================================ */
  .site-footer{
    position: relative;
    background: var(--footer-bg, var(--color-violet));
    padding: 8vh 4vw 5vh;
    overflow: hidden; /* clips the moon line's pre-reveal translateY offset */
  }
  /* subtle paper-grain overlay — a tiny tiled SVG feTurbulence noise
     tile, painted behind every other footer child (z-index) at low
     opacity so it never touches text sharpness (it's a background
     layer, not a filter/blur on the content itself). */
  .site-footer-grain{
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    opacity: .05;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
    background-repeat: repeat;
    background-size: 120px 120px;
  }

  .site-footer-top{
    position: relative;
    z-index: 1;
    display: flex;
    align-items: flex-start;
    gap: 4vw;
  }
  /* logo-mark slot — the doodle + its "Made in Boulder" caption,
     stacked and centered as one unit so the caption always stays
     aligned under the drawing at any breakpoint (flex-column on the
     wrapper, not on the doodle itself) — same static-position
     override every other inline doodle placement in this file uses;
     sized bigger than a typical small logo mark so the skier itself
     stays readable, per this task's own instruction. */
  .site-footer-logo-slot{
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    flex: none;
  }
  /* ~2x the original size so the skier + lava read clearly, per
     explicit feedback; shrinks a step on tablet so it doesn't crowd
     the 4 columns, and drops to a comfortable in-between size on
     mobile where it stacks above the columns instead of beside them
     (see .site-footer-top's own max-width:560px rule). Own fix, per
     explicit follow-up ("still not very legible"): another ~30% on
     top of that at every step (192->250, 140->182, 112->146). */
  #doodleFooterFlatironsSki{ position: static; width: 250px; }
  @media (max-width: 900px){
    #doodleFooterFlatironsSki{ width: 182px; }
  }
  @media (max-width: 560px){
    #doodleFooterFlatironsSki{ width: 146px; }
  }
  /* matches the column titles exactly (font/size/weight/tracking/
     uppercase/white) per this task's own spec — no rule line under
     it, that's specific to the column titles. */
  .site-footer-logo-caption{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 13px;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--color-cream);
  }
  .site-footer-columns{
    flex: 1;
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 24px;
  }
  .site-footer-col{
    border-left: 1px solid var(--color-cream);
    padding-left: 20px;
  }
  .site-footer-col-title{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 13px;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--color-cream);
    margin: 0 0 16px;
    padding-bottom: 12px;
    border-bottom: 1px solid var(--color-cream);
  }
  .site-footer-col-list{
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .site-footer-col-list li{ display: flex; align-items: center; gap: 6px; }
  .site-footer-col-list a, .site-footer-col-list span{
    display: inline-block;
    color: var(--color-cream);
    text-decoration: none;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 15px;
    transition: transform .15s ease-out;
  }
  /* subtle hover — text nudges right + underlines, per this task's
     own spec, in place of the old footer's color-swap hover. */
  .site-footer-col-list a:hover{
    transform: translateX(4px);
    text-decoration: underline;
    text-underline-offset: 3px;
  }
  /* banana-peel moved here (was the old footer's Contact-nav
     accent) — sits small next to "Contact us" in the Echo Boomer
     column, the one link it fits next to cleanly. */
  #doodleFooterBananaPeel{ position: static; width: 16px; flex: none; }

  .site-footer-divider{
    position: relative;
    z-index: 1;
    width: 100%;
    border-top: 1px solid var(--color-cream);
    margin: 5vh 0;
  }

  /* "To The Moon" — same treatment as the hero headline: real text
     (this wrap's child span) stays in the DOM for SEO/a11y, visually
     hidden via opacity:0, while a WebGL <canvas> on top renders the
     warped/hole-cut version through the shared window.HeadlineFX
     factory (see that block comment for why one factory, not a
     duplicate implementation). The wrap itself only carries layout +
     the reveal transform; typography lives on the text span so
     fitMoon() can measure its true shrink-to-fit width (the wrap's
     own width:100% would otherwise always report the full footer
     width, not the text's real content width — the original bug). */
  .site-footer-moon{
    position: relative;
    z-index: 1;
    width: 100%;
    transform: translateY(0.22em); /* pre-reveal offset — "rises up slightly into place" */
    transition-property: transform;
  }
  .site-footer.is-revealed .site-footer-moon{ transform: translateY(0); }
  @media (prefers-reduced-motion: reduce){
    .site-footer-moon{ transform: translateY(0) !important; transition: none !important; }
  }
  .site-footer-moon-text{
    display: inline-block; /* shrink-to-fit — see the block comment above */
    white-space: nowrap;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 800;
    letter-spacing: -0.01em;
    line-height: .85;
    color: var(--footer-text-color, var(--color-cream));
    font-size: var(--moon-fs, 14vw); /* fluid fallback before the script measures+fits the real width — see fitMoon in the script */
    opacity: 0;
  }
  .reduce-motion .site-footer-moon-text{ opacity: 1; }
  .site-footer-moon-canvas{
    position: absolute;
    inset: 0;
    z-index: 1;
    display: block;
    pointer-events: none;
  }
  .reduce-motion .site-footer-moon-canvas{ display: none; }
  /* rocket-mars-talking doodle — a normal (non-warped) image layered above
     the warp canvas, positioned in JS from the measured end of "Moon"
     (see positionRocketDoodle in the script): height/width/top/left
     are all set inline from there, not from CSS, since they depend on
     live text metrics. This rule only carries what doesn't change. */
  .site-footer-moon-doodle{
    position: absolute;
    z-index: 2;
    pointer-events: none;
  }

  .site-footer-bottom{
    position: relative;
    z-index: 1;
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 14px;
    color: var(--color-cream);
  }

  @media (max-width: 560px){
    .site-footer-top{ flex-direction: column; gap: 24px; }
    .site-footer-columns{ grid-template-columns: 1fr; }
    .site-footer-bottom{ flex-direction: column; align-items: flex-start; gap: 8px; }
  }


  /* ============================================================
     CONTACT HEADLINE — own build emulating rhythminfluence.com/
     contact's own headline reveal mechanics (read live: two words
     mask-wipe in, a photo "window" between them grows open in two
     width/margin stages — see the block comment on the reveal
     script below for the exact numbers read off their site). Own
     fonts/colors/copy throughout, and a wobble-on-landing keyframe
     added that their site does not have, per explicit request.
  ============================================================ */
  .contact-hero{
    position: relative;
    background: var(--color-near-black);
    color: var(--color-cream);
    padding: clamp(120px, 18vh, 200px) 6vw clamp(80px, 12vh, 140px);
    overflow: hidden;
  }
  .contact-headline{
    margin: 0;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 800;
    text-transform: uppercase;
    font-size: clamp(48px, 11vw, 168px); /* CONFIRMED: same rendered scale as the reference's own 14rem headline at their 10.5px root (~147px) */
    line-height: 0.95;
    text-align: center;
  }
  .contact-headline p{
    margin: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
  }
  /* ---- word reveal: a mask-image wipe, own reuse of the same
     technique the reference site uses for its own word reveal (a
     common, genuinely nice effect — not their code, not their
     copy). Hidden state: fully masked (mask-position 100% 100%,
     i.e. transparent through 300%-wide gradient's dark end). "in"
     class (added by the script below on load) slides the mask to
     0 100%, revealing the word. ---- */
  .contact-headline .w{
    display: inline-block;
    -webkit-mask-image: linear-gradient(90deg, #fff 33.3%, transparent 66.6%);
    mask-image: linear-gradient(90deg, #fff 33.3%, transparent 66.6%);
    -webkit-mask-size: 300% 100%;
    mask-size: 300% 100%;
    -webkit-mask-position: 100% 100%;
    mask-position: 100% 100%;
    transition: -webkit-mask-position 1s cubic-bezier(0.22, 1, 0.36, 1);
    transition: mask-position 1s cubic-bezier(0.22, 1, 0.36, 1);
  }
  .contact-headline.in .w{
    -webkit-mask-position: 0 100%;
    mask-position: 0 100%;
  }
  .contact-headline.in .w[data-i="2"]{
    transition-delay: .15s; /* CONFIRMED-adjacent: staggers the second word slightly behind the first, same idea as the reference's own per-word stagger */
  }
  /* ---- photo card window — sits inline between the two words.
     Two-stage width/margin grow on "in" (see the script's own
     comment for why two stages, not one) is what makes the two
     words visibly part to "open a gap" rather than the card just
     appearing on top of them. ---- */
  .contact-headline__window{
    display: inline-block;
    position: relative;
    width: 0;
    height: clamp(120px, 15vw, 220px); /* matches the final width below — round at rest, per explicit request ("taller and more round", sized like the statement section's own Poppi card) */
    margin: 0 -0.15em;
    opacity: 0;
    transform: translateY(0.15em) rotate(0deg);
    vertical-align: middle;
    will-change: width, margin, transform;
    transition: opacity .4s linear;
  }
  /* ---- inner blob shape — own reuse of the statement section's own
     blobMorph/blobBreathe keyframes (organic border-radius morph +
     gentle scale pulse), per explicit request ("blobby and floaty
     like the Poppi blob"). Split into its own inner element so the
     continuous morph/breathe transform doesn't fight the outer
     element's own reveal transform (translateY + rotate) — each
     animates its own element's transform independently. ---- */
  .contact-headline__blob{
    position: relative;
    display: block;
    width: 100%;
    height: 100%;
    overflow: hidden;
    animation: blobMorphA 10s ease-in-out infinite, contactBlobBreathe 5s ease-in-out infinite;
  }
  /* own stronger breathe than the shared blobBreathe (used elsewhere
     for the statement section's own, more subtle floating cards) —
     more pronounced amplitude + a touch faster, per explicit request. */
  @keyframes contactBlobBreathe{
    0%{ transform: scale(1); }
    50%{ transform: scale(1.1); }
    100%{ transform: scale(1); }
  }
  .contact-headline.in .contact-headline__window{
    opacity: 1;
  }
  /* stage A: small peek */
  .contact-headline.in .contact-headline__window{
    width: clamp(36px, 5vw, 70px);
    transition: width .5s cubic-bezier(0.22, 1, 0.36, 1) .15s, opacity .4s linear .15s;
  }
  /* stage B: full card, delayed until stage A finishes (.15s + .5s = .65s) */
  .contact-headline.in.is-stage-2 .contact-headline__window{
    width: clamp(120px, 15vw, 220px);
    margin: 0 clamp(-20px, -2vw, -40px);
    transform: translateY(0.15em) rotate(var(--card-tilt, -4deg));
    transition: width .8s cubic-bezier(0.22, 1, 0.36, 1), margin .8s cubic-bezier(0.22, 1, 0.36, 1), transform .8s cubic-bezier(0.22, 1, 0.36, 1);
  }
  /* landing wobble — own addition, not on the reference site. Fires
     once (script toggles the class on, then off after it completes)
     right as stage B settles. */
  .contact-headline.is-landed .contact-headline__window{
    animation: contactCardWobble .5s cubic-bezier(0.22, 1, 0.36, 1);
  }
  @keyframes contactCardWobble{
    0%{ transform: translateY(0.15em) rotate(var(--card-tilt, -4deg)) scale(1); }
    35%{ transform: translateY(0.15em) rotate(calc(var(--card-tilt, -4deg) - 4deg)) scale(1.04); }
    65%{ transform: translateY(0.15em) rotate(calc(var(--card-tilt, -4deg) + 2deg)) scale(0.98); }
    100%{ transform: translateY(0.15em) rotate(var(--card-tilt, -4deg)) scale(1); }
  }
  /* ---- blobMorphA + blobBreathe — same exact keyframes the
     statement section's own floating-card__blob uses (see that
     block comment on index.html for the full citation), reused here
     verbatim rather than redefined, per explicit request. ---- */
  @keyframes blobMorphA{
    0%{ border-radius: 58% 42% 51% 49% / 46% 55% 45% 54%; }
    33%{ border-radius: 42% 58% 47% 53% / 53% 46% 54% 47%; }
    66%{ border-radius: 51% 49% 63% 37% / 40% 58% 42% 60%; }
    100%{ border-radius: 58% 42% 51% 49% / 46% 55% 45% 54%; }
  }
  @keyframes blobBreathe{
    0%{ transform: scale(1); }
    50%{ transform: scale(1.03); }
    100%{ transform: scale(1); }
  }
  /* ---- cycling crossfade + flip-shuffle — two <img> elements
     stacked on top of each other (own fix: a single <img> that fades
     out, swaps src, then fades back in has a real gap of full
     transparency in between — reads as an abrupt disappear/reappear,
     not a crossfade). Both sit in the same spot at all times; the
     script only ever toggles which one carries .is-current, so the
     outgoing and incoming photos always overlap while transitioning,
     never both fully invisible at once. Own reuse of the "rotate a
     few degrees, scale 0.95 -> 1" look for both directions — the
     same rest state (off, scaled down, tilted) works whether an
     image is about to appear or has just been swapped away from. ---- */
  .contact-headline__img{
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0;
    transform: scale(0.95) rotate(var(--card-photo-tilt, -3deg));
    transition: opacity .7s cubic-bezier(0.22, 1, 0.36, 1), transform .7s cubic-bezier(0.22, 1, 0.36, 1);
  }
  .contact-headline__img.is-current{
    opacity: 1;
    transform: scale(1) rotate(0deg);
  }

  @media (prefers-reduced-motion: reduce){
    .contact-headline .w{ transition: none; -webkit-mask-position: 0 100% !important; mask-position: 0 100% !important; }
    .contact-headline__window{ transition: none !important; animation: none !important; width: clamp(120px, 15vw, 220px) !important; margin: 0 clamp(-20px, -2vw, -40px) !important; opacity: 1 !important; transform: translateY(0.15em) rotate(var(--card-tilt, -4deg)) !important; }
    .contact-headline__blob{ animation: none !important; border-radius: 50% !important; } /* static round shape, no morph/breathe — same "fade, don't move" rule this file uses everywhere else under reduced motion */
    .contact-headline__img{ transition: none !important; }
  }

  @media (max-width: 767px){
    .contact-headline{ font-size: clamp(36px, 15vw, 64px); }
    .contact-headline p{ flex-direction: column; gap: 0.15em; }
    .contact-headline__window{
      display: block;
      margin: 0.2em auto !important;
      height: clamp(120px, 42vw, 200px);
    }
    .contact-headline.in.is-stage-2 .contact-headline__window{
      width: clamp(120px, 42vw, 200px); /* matches height above — stays circular on mobile too */
    }
    /* own fix, per real-device report: .contact-hero's own bottom
       padding (clamp(80px, 12vh, 140px), base rule) was sized for a
       desktop hero with more content breathing room below the
       headline — on a phone that reads as a large dead gap of empty
       black space under "WEIRD" before the contact rows start.
       Roughly halved. */
    .contact-hero{ padding-bottom: clamp(40px, 6vh, 70px); }
  }

  /* ============================================================
     CONTACT ROWS + BLURB — big-type link rows (mailto / LinkedIn /
     Instagram) on the left, a short blurb on the right, same
     rhythm as the reference's own NAME/COMPANY/EMAIL/MESSAGE form
     rows (full-width divider lines, big type) but every row here
     is a real link, not a form field, per explicit request.
  ============================================================ */
  .contact-rows{
    background: var(--color-cream);
    color: var(--color-near-black);
    padding: 8vh 6vw 12vh;
  }
  .contact-rows__grid{
    display: grid;
    grid-template-columns: 1fr minmax(240px, 340px);
    gap: 6vw;
    align-items: start;
  }
  .contact-rows__links{
    display: flex;
    flex-direction: column;
  }
  .contact-rows__side{
    display: flex;
    flex-direction: column;
  }
  /* ---- blob photo — own reuse of the statement section's own
     floating-card__blob (organic border-radius morph + gentle
     breathe) and its wander/mouse-avoid JS, verbatim per explicit
     request ("reuse the hero blob's CSS/JS; don't write a new
     version"). Split wrap/inner exactly like that source: the wrap
     is the JS mouse-drift transform target, the inner element owns
     the continuous morph/breathe animation — two transforms, two
     elements, nothing to fight over (same reasoning as
     .floating-card__wrap/.floating-card__blob on index.html). ---- */
  .contact-rows__blob-wrap{
    width: 336px; /* ~420px per original spec, sized down 20% per explicit follow-up request */
    max-width: 100%; /* never overflows a narrower column (mobile) */
    aspect-ratio: 1 / 1;
    margin-top: 48px; /* ~48px below the paragraph, per spec */
    will-change: transform;
  }
  .contact-rows__blob{
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: var(--color-near-black); /* shows briefly while the image loads */
    will-change: border-radius, transform;
    animation: blobMorphA 13s ease-in-out infinite, blobBreathe 9s ease-in-out infinite; /* same keyframes as the statement section's blobs, own duration pick within their own 8-14s range since this is a single instance, not six needing to desync */
  }
  .contact-rows__blob-img{
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover; /* image is already a centered square crop of the TV + the guy looking up at it, so no object-position adjustment needed */
  }
  @media (prefers-reduced-motion: reduce){
    .contact-rows__blob{ animation: none; border-radius: 50%; } /* static shape, no morph/breathe — same rule as every other blob in this file */
  }
  .contact-row{
    position: relative;
    z-index: 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 24px;
    padding: clamp(20px, 3.5vh, 36px) 0;
    border-top: 1px solid color-mix(in srgb, var(--color-near-black) 18%, transparent);
    color: inherit;
    text-decoration: none;
    transition: color .3s ease;
  }
  /* full-bleed inverse hover backdrop — own reuse of the same
     technique the homepage's own .service-row uses (see that rule's
     own block comment for the citation): a pseudo-element re-centered
     on the viewport via the 50vw/-50vw offset pair, sat behind the
     row's own content, so the whole section inverts edge-to-edge on
     hover rather than just the row's own (narrower) content column. */
  .contact-row::before{
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: -6vw; /* bleeds left to the section's own edge (padding is 6vw) — own fix: the earlier 100vw full-viewport version spilled past the row's own right edge into the blurb column beside it, so this only extends left, stopping at the row's own natural right edge (right:0, i.e. unset) */
    right: 0;
    z-index: -1;
    background: transparent;
    transition: background-color .3s ease;
    pointer-events: none;
  }
  .contact-row:hover::before,
  .contact-row:focus-visible::before{
    background: var(--color-orange); /* own follow-up: orange instead of near-black, per explicit request */
  }
  .contact-row:hover,
  .contact-row:focus-visible{
    color: var(--color-cream);
  }
  .contact-rows__links .contact-row:last-child{
    border-bottom: 1px solid color-mix(in srgb, var(--color-near-black) 18%, transparent);
  }
  .contact-row__label{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 400;
    font-size: clamp(28px, 5.2vw, 68px); /* CONFIRMED-adjacent scale to comfortably fit the email address on one line at desktop widths */
    line-height: 1.05;
    white-space: nowrap;
    transition: transform .3s cubic-bezier(0.22, 1, 0.36, 1);
  }
  .contact-row__arrow{
    flex-shrink: 0;
    width: clamp(28px, 3vw, 44px);
    height: clamp(28px, 3vw, 44px);
    opacity: 0;
    transform: translate(-6px, 6px);
    transition: opacity .3s cubic-bezier(0.22, 1, 0.36, 1), transform .3s cubic-bezier(0.22, 1, 0.36, 1);
  }
  .contact-row__arrow svg{ width: 100%; height: 100%; display: block; }
  .contact-row:hover .contact-row__label,
  .contact-row:focus-visible .contact-row__label{
    transform: translateX(12px); /* CONFIRMED 12px, per spec */
  }
  .contact-row:hover .contact-row__arrow,
  .contact-row:focus-visible .contact-row__arrow{
    opacity: 1;
    transform: translate(0, 0);
  }
  .contact-row:focus-visible{
    outline: 2px solid var(--color-near-black); /* not orange here — the row's own background is orange on focus now, so an orange ring would vanish against it */
    outline-offset: -2px;
  }
  .contact-rows__blurb{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: clamp(17px, 1.4vw, 22px);
    line-height: 1.5;
    padding-top: clamp(20px, 3.5vh, 36px);
  }

  @media (max-width: 900px){
    .contact-rows__grid{
      grid-template-columns: 1fr;
      gap: 4vh;
    }
    .contact-rows__blurb{ padding-top: 0; }
  }
  @media (max-width: 560px){
    /* own fix: white-space:normal here let the longest row (the
       email address) wrap onto a second line at the narrowest phone
       widths, contradicting "never wraps" — a pure vw size (no px
       floor) scales the type in lockstep with the viewport instead,
       so the same character count keeps the same fit at any width
       down to this breakpoint; nowrap now genuinely holds. */
    .contact-row__label{ font-size: 6.2vw; white-space: nowrap; }
  }
  @media (max-width: 380px){
    .contact-row__label{ font-size: 5.6vw; } /* a touch smaller still on the narrowest phones (iPhone SE class) */
  }

