/* ===================================================================
   PAPI — Premium Business Template Studio
   Loader + Hero
=================================================================== */

:root{
  --ink: #0b0b0c;
  --ink-soft: #17171a;
  --cream: #f3ede2;
  --cream-dim: #cfc7b8;
  --gold: #cba869;
  --gold-soft: #e4cd9a;
  --gold-deep: #8a6a1f; /* darker gold for legibility over light backgrounds */

  /* luxury white — used for the loader and the showcase section */
  --paper: #f8f6f2;
  --paper-ink: #17181a;
  --paper-dim: rgba(23,24,26,.5);

  /* --accent/--style-contrast used to be driven live, every frame, by
     the old hero particle field's color sweep — nothing updates them
     anymore (the hero background is a static white+bokeh now, see
     #hero below), so these are just fixed defaults. --style-contrast
     in particular used to default to a light cream (right for the old
     near-black hero); switched to a dark tone here since it's what the
     brand mark/CTA border/"purpose" fall back to against the new white
     hero background. */
  --accent: 201, 168, 105;      /* r,g,b */
  --style-contrast: 26, 20, 13;
  /* soft cream-blended tint of --accent, used for supporting text */
  --accent-tint: 222, 208, 180;

  --ease-out: cubic-bezier(.16,1,.3,1);
  --ease-in-out: cubic-bezier(.65,0,.35,1);
}

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

html,body{
  min-height:100%;
  background:var(--ink);
  color:var(--cream);
  font-family:'Inter', system-ui, sans-serif;
}

/* overflow-x:hidden on body only (not on html, and not on both) —
   both was already ruled out (see below): it makes each one's
   overflow-y compute to auto, turning body into a competing scroll
   container that breaks position:sticky for the showcase section. But
   html alone has its own, separate, well-documented WebKit/iOS bug:
   *any* explicit overflow on the root <html> element makes fixed-
   position descendants lay out in normal document flow instead of
   pinning to the viewport — confirmed directly as the cause of the
   showcase section's expanded fan card (reparented onto <body> with
   position:fixed) rendering at the very bottom of the page, among the
   last elements in body's own document flow, instead of centering
   on-screen. Putting it on body alone avoids both: only one element
   ends up with explicit overflow (no competing scroll container), and
   it isn't the root element (no fixed-position bug). */
body{ overflow-x:hidden; }

/* briefly locks scrolling — while the hero's background field grows
   to fill the screen, while the cube section has a face focused, and
   while a showcase fan card is expanded. Plain overflow:hidden on
   html/body (what used to live here) isn't reliably enough to block
   iOS Safari's own touch-driven momentum scrolling: a stray touch
   could still drag the page slightly during a "locked" animation, and
   the snap-back once the lock released was what showed up as a pause/
   freeze right as you scrolled from the hero into the next section.
   window.Papi.lockScroll()/unlockScroll() (see accent.js) actually
   fixes that by intercepting the touchmove/wheel input itself — NOT
   via position:fixed on body, which would hijack the containing block
   of every OTHER position:fixed element on the page (.site-header,
   .title-dock, an expanded fan card...) for the duration of the lock.
   This class just hides the root's own scrollbar/rubber-band to match. */
html.scroll-lock{ overflow:hidden; }

html.no-scroll{ overflow:hidden; }
body.no-scroll{ overflow:hidden; }

::selection{ background:rgba(var(--accent),.4); color:var(--cream); }

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

/* custom cursor replaces the system pointer across the whole page */
body, a, .cta{ cursor:none; }

/* ===================================================================
   LOADER — black and gold, on brand: a single stylized "Papi"
   wordmark fills in from white to gold, then dissolves into the hero.
=================================================================== */
#loader{
  position:fixed;
  inset:0;
  z-index:1000;
  background:var(--ink);
  overflow:hidden;
}

/* the same italic Fraunces treatment as "purpose" in the hero title
   (see .hero-title em) — ties the very first thing a visitor sees to
   the same on-brand serif/gold-italic moment they see again seconds
   later, rather than a generic sans wordmark that has nothing to do
   with the rest of the page. The rise settles the whole word into
   place, then loader.js fades each letter in on its own before
   pouring the gold fill through them in turn. */
.loader-word{
  position:absolute;
  left:50%;
  top:50%;
  transform:translate(-50%,-50%) translateY(20px) scale(.94);
  font-family:'Fraunces', serif;
  font-style:italic;
  font-weight:400;
  letter-spacing:.02em;
  font-size:clamp(2.6rem, 11vw, 6.2rem);
  line-height:1;
  display:flex;
  will-change:transform, opacity;
  opacity:0;
}
.loader-letter{
  display:inline-block;
  opacity:0;
  color:#fff;
  transform:translateY(16px);
  will-change:opacity, transform, color;
}

/* loader exit */
#loader.is-done{
  pointer-events:none;
}

/* ===================================================================
   HERO / CANVAS FIELD
=================================================================== */
#hero{
  position:relative;
  min-height:100vh;
  min-height:100svh;
  width:100%;
  background:#ffffff;
  visibility:hidden;
  opacity:0;
  transition:opacity 1.6s var(--ease-in-out);
  overflow:hidden;
}
#hero.is-visible{
  visibility:visible;
  opacity:1;
}

/* ---- pilot: native scroll-driven fade/lift for the hero copy ----
   title-dock.js drives this same fade/lift by reading window.scrollY
   in a requestAnimationFrame loop — which works, but iOS Safari (the
   actual app, not other browsers using the same underlying engine)
   deliberately pauses JS execution, including rAF callbacks, while a
   finger is actively dragging the screen, to keep the native scroll
   itself smooth, then runs everything it deferred in one burst once
   the gesture settles. On a page with this much scroll-tied JS, that
   surfaces as the hero visibly freezing mid-scroll and then jumping —
   confirmed on video, and reproducible only in Safari specifically,
   not in another iOS browser hitting the same page.
   This does the same fade/lift as a native, compositor-driven
   scroll-linked animation instead — not gated behind JS at all, so
   Safari's JS-during-scroll pause can't touch it. Gated behind
   @supports so it only applies where the browser actually understands
   it; everywhere else, title-dock.js's own JS version (unchanged)
   keeps working exactly as it already does. Scoped to just the
   clearest win for now (eyebrow/title opacity, the hero-copy lift) —
   the CTA and subtitle keep their existing JS-driven fade since both
   also run their own separate entrance-reveal animation that would
   otherwise race this. */
@supports (animation-timeline: view()) {
  #hero{
    view-timeline-name: --hero-exit;
    view-timeline-axis: block;
  }
  @keyframes heroExitFade{
    0%, 42%{ opacity:1; }
    52%{ opacity:.84; }
    62%{ opacity:.5; }
    72%{ opacity:.16; }
    82%, 100%{ opacity:0; }
  }
  @keyframes heroExitLift{
    0%, 42%{ transform:translateY(0); }
    52%{ transform:translateY(-5px); }
    62%{ transform:translateY(-17px); }
    72%{ transform:translateY(-29px); }
    82%, 100%{ transform:translateY(-34px); }
  }
  #heroEyebrow, #heroTitle{
    animation: heroExitFade linear both;
    animation-timeline: --hero-exit;
    animation-range: exit 0% exit 100%;
  }
  #heroCopy{
    animation: heroExitLift linear both;
    animation-timeline: --hero-exit;
    animation-range: exit 0% exit 100%;
  }

}

/* container for the WebGL gold slime — js/hero-slime.js renders into
   the <canvas> inside it. See that file's header for how the
   simulation and shader work. */
#heroSlime{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  display:block;
  pointer-events:none;
  opacity:0;
  transition:opacity 1.4s ease;
}
#heroSlime.is-visible{ opacity:1; }
/* js/hero-slime.js reparents the canvas into .contrast-sticky while
   that section is on screen (position:absolute there works exactly
   like it does inside #hero — .contrast-sticky is position:sticky,
   which is a positioned element same as #hero's position:relative) so
   the mass keeps wandering there instead of just fading out at the
   boundary. z-index:1 keeps it above that section's own background
   (.contrast-intro/.contrast-stage are both z-index:2, above this) —
   only needed while roaming there, since inside #hero the plain DOM
   order already puts it behind .hero-content with no z-index at all.
   transition:none since the exit fade near the end of that section is
   set directly, every frame, from scroll position — the 1.4s reveal
   transition above would otherwise lag it into a smeared mismatch. */
#heroSlime.is-roaming{ z-index:1; transition:none; }

/* ---- header / brand mark — fixed, always top right ---- */
.site-header{
  position:fixed;
  top:0; left:0; right:0;
  z-index:200;
  display:flex;
  justify-content:flex-end;
  padding:clamp(1.5rem, 4vw, 2.75rem) clamp(1.5rem, 4vw, 3rem) 0;
  pointer-events:none;
  transition:opacity .5s var(--ease-out);
}
/* ducked on narrow screens for the stretch of the contrast section —
   see js/title-dock.js — where the brand mark would otherwise sit
   directly on top of the mock's own nav/CTA at this width */
.site-header.is-hidden{ opacity:0; }

.brand-mark{
  font-family:'Fraunces', serif;
  font-weight:500;
  font-size:clamp(1.1rem, 2vw, 1.5rem);
  letter-spacing:.28em;
  display:flex;
  color:var(--gold-soft);
  text-shadow:0 2px 20px rgba(0,0,0,.5);
  transition:color .8s var(--ease-out), text-shadow .5s var(--ease-out);
}
/* dark-on-light while over the hero's own white background — every
   other section has a fixed dark background, where the static gold
   above already reads fine on its own. */
body.on-light-section .brand-mark{ color:rgb(var(--style-contrast)); }
.brand-mark span{ display:inline-block; opacity:0; transition:opacity .9s var(--ease-out); }

/* small title label that crossfades in below the brand mark once the
   visitor scrolls past the hero — stays put through the showcase */
.title-dock{
  position:fixed;
  top:clamp(3.6rem, 8vw, 5.1rem);
  right:clamp(1.5rem, 4vw, 3rem);
  z-index:190;
  font-family:'Fraunces', serif;
  font-size:.95rem;
  font-weight:400;
  color:var(--cream);
  text-shadow:0 2px 14px rgba(0,0,0,.6);
  opacity:0;
  transform:translateY(-10px);
  transition:opacity .6s var(--ease-out), transform .6s var(--ease-out), color .5s var(--ease-out), text-shadow .5s var(--ease-out);
  pointer-events:none;
  text-align:right;
}

/* static gold, not the live hero-sweep contrast color — this label
   only ever shows up after leaving the hero (it fades in well past
   the point the visitor has scrolled away from the canvas), sitting
   over each section's own fixed dark background from then on, so it
   needs a reliably-visible color rather than one that can cycle to
   near-black */
.title-dock em{
  font-style:italic;
  color:#ffdd7a;
}
.title-dock.is-visible{ opacity:1; transform:translateY(0); }

.title-dock-word{
  margin-top:.35rem;
  font-size:.72rem;
  font-weight:500;
  letter-spacing:.3em;
  text-transform:uppercase;
  color:#ffdd7a;
  opacity:0;
  transform:translateY(-6px);
  transition:opacity .7s var(--ease-out), transform .7s var(--ease-out);
}
.title-dock-word.is-visible{ opacity:1; transform:translateY(0); }

/* dark-on-light for the brief window where the docked label has
   already faded in but the visitor hasn't fully scrolled past the
   hero's white background yet (see body.on-light-section, above) —
   the fixed gold/cream colors above assume a dark backdrop */
body.on-light-section .title-dock{ color:rgb(var(--style-contrast)); text-shadow:none; }
body.on-light-section .title-dock em{ color:var(--gold-deep); }
body.on-light-section .title-dock-word{ color:var(--gold-deep); }

/* closing brand signature — bottom-center, fades in only once the
   quote section is reached, as a closing echo of the top-right brand
   mark/tagline rather than just ending on the form. Opacity/transform
   driven directly, continuously, by scroll position in quote-form.js
   (same convention as the rest of that section's reveal) rather than
   a CSS transition, which would fight a value set fresh every frame. */
.closing-brand{
  position:relative;
  z-index:1;
  margin-top:clamp(2.5rem, 6vw, 4rem);
  text-align:center;
  pointer-events:none;
  opacity:0;
  will-change:opacity, transform;
}
.closing-brand-mark{
  font-family:'Fraunces', serif;
  font-weight:500;
  font-size:clamp(1.2rem, 2.4vw, 1.7rem);
  letter-spacing:.28em;
  color:var(--gold-soft);
  text-shadow:0 2px 20px rgba(0,0,0,.5);
}
.closing-brand-line{
  margin-top:.35rem;
  font-size:.85rem;
  color:rgba(243,237,226,.75);
}
.closing-brand-line em{
  font-style:italic;
  color:#ffdd7a;
}

/* ---- hero copy (no card, floats directly over the field) ---- */
.hero-content{
  position:relative;
  z-index:15;
  display:flex;
  align-items:center;
  justify-content:center;
  min-height:100vh;
  min-height:100svh;
  padding: 6vw 1.5rem 4rem;
  perspective:1400px;
}

.hero-copy{
  position:relative;
  max-width:980px;
  width:100%;
  text-align:center;
}

.hero-title-row{
  display:flex;
  align-items:center;
  justify-content:center;
  gap:clamp(1.6rem, 4vw, 3rem);
  flex-wrap:wrap;
}

.eyebrow{
  font-size:.72rem;
  font-weight:700;
  letter-spacing:.32em;
  text-transform:uppercase;
  /* dark, matching the main title — the hero background is now white,
     not the near-black this was originally tuned for */
  color:var(--paper-ink);
  margin-bottom:1.4rem;
  transition:opacity .3s ease;
}
.eyebrow .char{
  display:inline-block;
  white-space:pre;
  will-change:transform, opacity;
}
/* per-letter entrance for the eyebrow: a bright gold flash that settles
   to the resting dark color, echoing the loader's own gold-fill-
   through-the-letters reveal (see loader.js) rather than a plain fade */
@keyframes eyebrowShine{
  0%{ opacity:0; transform:translateY(8px); color:#fff6d6; text-shadow:0 0 18px rgba(255,205,110,.9), 0 0 34px rgba(255,205,110,.5); }
  55%{ opacity:1; transform:translateY(0); color:#fff6d6; text-shadow:0 0 18px rgba(255,205,110,.9), 0 0 34px rgba(255,205,110,.5); }
  100%{ opacity:1; transform:translateY(0); color:var(--paper-ink); text-shadow:none; }
}

.hero-title{
  font-family:'Fraunces', serif;
  font-weight:400;
  font-size:clamp(2.4rem, 6.4vw, 5rem);
  line-height:1.04;
  letter-spacing:-.01em;
  color:var(--paper-ink);
  transform-style:preserve-3d;
  will-change:transform;
  display:inline-block;
  transition:opacity .3s ease;
}
.hero-title em{
  font-style:italic;
  font-weight:400;
  /* a fixed, defined gold glow — always visible and legible on its own,
     rather than swapping color based on whether the slime mass happens
     to be behind it (the previous approach, removed: it meant "purpose"
     read differently depending on the mass's current position). The
     glow itself lives here (text-shadow is inherited, so it reaches the
     per-letter .char spans below); very subtly pulses rather than
     sitting at one fixed intensity forever. */
  color:var(--gold-deep);
  animation:purposeGlowPulse 3.6s ease-in-out infinite;
}
@keyframes purposeGlowPulse{
  0%, 100%{
    text-shadow:
      0 0 9px rgba(203,168,105,.4),
      0 0 20px rgba(203,168,105,.24),
      0 0 36px rgba(203,168,105,.14);
  }
  50%{
    text-shadow:
      0 0 13px rgba(203,168,105,.62),
      0 0 28px rgba(203,168,105,.4),
      0 0 48px rgba(203,168,105,.24);
  }
}
/* the metallic gold fill itself — a vertical sweep (bright near the
   top, richer bronze toward the bottom) applied per-letter rather than
   across the whole word, since "purpose" is split into individual
   .char spans for the push/ripple physics elsewhere in this file; a
   vertical gradient looks identical regardless of which letter it's
   on, so it doesn't need the letters to share one continuous
   horizontal sweep to read as one cohesive metallic surface */
.hero-title em .char{
  background:linear-gradient(180deg, #fff6d8 0%, #e8c874 32%, #cba869 62%, #8a6a1f 100%);
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
}
.hero-title .char{
  display:inline-block;
  /* a lone space character inside an inline-block collapses to zero
     width by default (the browser trims leading/trailing whitespace
     within each inline box) — pre keeps the space's actual width, so
     "Built with" and "not just design" don't run their words together */
  white-space:pre;
  will-change:transform, opacity;
}
/* the subtitle's own per-letter spans (title.js) run the exact same
   per-frame transform physics as the title's chars above, but lived
   under a different ancestor (#heroSub, not .hero-title) so they never
   picked up that rule's will-change hint — meaning the browser had no
   reason to pre-promote any of these 70+ elements to their own
   compositor layer ahead of time. The moment they actually started
   moving by a meaningful amount every frame (exactly what happens as
   soon as scrolling begins, via the explode effect below), that's 70+
   elements suddenly needing real paint work instead of a cheap
   GPU-composited transform — a very plausible source of a hitch
   landing right at that exact moment. */
#heroSub .char{
  display:inline-block;
  white-space:pre;
  will-change:transform, opacity;
}
/* keeps each word (and any punctuation stuck directly to it, like a
   trailing comma) together as one unbreakable unit — without this,
   splitting the subtitle into individual letter spans let the browser
   wrap a line between a word and its own attached punctuation, which
   is what showed up on iPhone as a comma stranded alone on its own line */
.word-wrap{
  display:inline-block;
  white-space:nowrap;
}

/* the subtitle's rotating word (title.js scrambles its text between
   "trust"/"direction"/"action") — a fixed min-width sized to the
   longest of the three keeps the sentence from visibly reflowing as
   the word's length changes, both mid-scramble and once it settles.
   text-align:left (rather than inheriting the hero copy's centered
   alignment) keeps the word itself hugging "build" — without it,
   shorter words like "trust" centered within the reserved min-width
   box instead, opening up a visible gap right after "build" */
.sub-word{
  display:inline-block;
  min-width:9.2ch;
  text-align:left;
  color:var(--gold-deep);
  font-weight:600;
}
/* on narrow screens "Designed to build" wraps onto its own line above
   the word instead of sharing a line with it, so there's no "build"
   immediately to its left to hug anymore — the left-align above then
   just reads as the whole word sitting off-centre inside its own
   reserved box. Centering it here, only at this width, fixes that
   without touching the desktop reasoning above. */
@media (max-width:640px){
  .sub-word{ text-align:center; }
}

.hero-sub{
  max-width:640px;
  margin:1.8rem auto 2.6rem;
  font-size:clamp(1.15rem, 2.4vw, 1.6rem);
  line-height:1.4;
  /* was --paper-dim (50%-opacity ink) — too pale against the hero's
     white background; a solid, much darker tone reads clearly while
     still sitting a step below the main title's full-strength ink */
  color:rgba(23,24,26,.78);
  font-weight:300;
  opacity:0;
  transition:opacity .3s ease;
}

.cta{
  display:inline-flex;
  align-items:center;
  gap:.7rem;
  padding:.95rem 1.8rem;
  border-radius:999px;
  border:1px solid rgba(var(--style-contrast),.55);
  /* backdrop-filter used to sit here — continuously re-composited by
     title-dock.js's magnetic-pull loop, which writes a fresh transform
     on this element every frame from the moment the page loads, for as
     long as the hero is on screen. Combined with backdrop-filter that's
     a real, sustained cost specifically on iOS WebKit. A slightly
     stronger flat tint instead, no blur sampling needed. */
  background:rgba(var(--accent),.22);
  font-size:.85rem;
  letter-spacing:.08em;
  text-transform:uppercase;
  color:rgb(var(--style-contrast));
  opacity:0;
  will-change:transform;
  transition:border-color .8s var(--ease-out), background .8s var(--ease-out), color .8s var(--ease-out), opacity .3s ease;
}
.cta i{
  width:8px; height:8px;
  border-right:1.5px solid currentColor;
  border-bottom:1.5px solid currentColor;
  transform:rotate(-45deg);
  transition:transform .4s var(--ease-out);
}
.cta:hover{
  background:rgba(var(--accent),.32);
  border-color:rgba(var(--style-contrast),.9);
}
.cta:hover i{ transform:rotate(-45deg) translate(2px,-2px); }

/* ===================================================================
   CONTRAST — the same fake business ("Anderson Plumbing") built two
   ways: a flat generic template underneath, an immersive Papi-style
   version wiping across on top as the visitor scrolls (js/contrast.js
   drives --wipe, a 0%..100% custom property, off scroll position —
   same tall-outer-section-plus-sticky-inner-viewport pattern as the
   showcase/testimonials sections below it). The heading/cue intro and
   the stage both live inside the sticky now, sequenced by scroll
   position rather than the heading sitting in normal flow before it.
=================================================================== */
.contrast-section{
  position:relative;
  height:400vh;
  /* svh ("small" viewport height) instead of plain vh for this and
     every other scroll-distance-defining height on the page — plain
     vh tracks the browser chrome's CURRENT collapsed/expanded state on
     several in-app browsers (Instagram/Facebook/TikTok's own embedded
     WebViews chief among them; iOS Safari itself only recalculates
     vh once, on the first scroll of a session, but these apps' own
     chrome keeps sliding in and out continuously as you scroll). At a
     4x multiplier (400vh) that live recompute doesn't just nudge this
     section's own height — it swings the ENTIRE page's total
     scrollable height by hundreds of pixels in real time while
     scrolling, which is what read as sections "jumping" back and
     forth between the numbers section and the form near the bottom of
     the page: the visitor's actual scrollY (a fixed pixel offset) kept
     landing on a completely different section every time the layout
     above it re-settled. svh is pinned to the chrome's fully-expanded
     (smallest) state and never changes as chrome auto-hides, so this
     section's height — and the page's total height along with it —
     stays put for the entire scroll. Old browsers without svh support
     just ignore this line and keep the vh value above. */
  height:400svh;
  background:#f2efe8;
  z-index:1;
}
.contrast-sticky{
  position:sticky;
  top:0;
  height:100vh;
  height:100svh;
  overflow:hidden;
  display:flex;
  align-items:center;
  justify-content:center;
}

/* the heading/cue intro layer — absolutely positioned over the sticky
   rather than a flex sibling of the stage, so it never competes with
   the stage for vertical space (that fight is exactly what caused the
   mobile empty-space bug the stage sizing already had to solve once).
   It fades/rises in, then js/contrast.js fades it back out as the
   stage fades/scales in over the same spot — a handoff, not two boxes
   stacked in a column. */
.contrast-intro{
  position:absolute;
  inset:0;
  z-index:2;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:flex-start;
  text-align:center;
  padding:clamp(4rem, 12vh, 7rem) 1.5rem 0;
  pointer-events:none;
}
.contrast-eyebrow{
  font-size:.78rem;
  font-weight:600;
  letter-spacing:.16em;
  text-transform:uppercase;
  color:var(--gold-deep);
  margin-bottom:.9rem;
}
/* Space Grotesk, not Fraunces — a firmer, more confident sans rather
   than the softer editorial serif everything else near here uses;
   already loaded elsewhere on the page (the testimonials heading) so
   this doesn't add another font fetch */
.contrast-heading{
  font-family:'Space Grotesk', 'Inter', sans-serif;
  font-weight:700;
  letter-spacing:-.01em;
  font-size:clamp(1.5rem, 3.4vw, 2.35rem);
  line-height:1.3;
  max-width:640px;
  margin:0 auto;
  color:var(--paper-ink);
}
/* "One" gets its own scroll-driven entrance (see js/contrast.js) — comes
   in separately from the rest of the sentence and settles into gold
   rather than just fading in the same ink color as everything else */
.contrast-heading-one{
  display:inline-block;
}
/* typed out character-by-character as the visitor scrolls (see
   js/contrast.js) rather than just fading in — a deliberate reveal to
   match how deliberately dismissive the line itself reads. Red rather
   than the rest of the heading's ink/gold palette: this is the one
   negative beat in the sequence ("...gets ignored"), and reads as a
   warning precisely because it breaks from every other color used here */
.contrast-heading em{
  font-style:italic;
  font-weight:400;
  color:#b3392c;
}
.contrast-heading em .type-char{ opacity:0; }
/* a thin blinking caret right after the last typed character — only
   meaningful while typing is actually in progress; js/contrast.js
   toggles .is-typing on/off around that window */
.contrast-heading-em-cursor{
  display:inline-block;
  width:2px;
  margin-left:1px;
  color:#b3392c;
  opacity:0;
}
.contrast-heading-em-cursor.is-typing{
  animation:contrastCaretBlink .9s step-end infinite;
}
@keyframes contrastCaretBlink{
  0%, 49%{ opacity:1; }
  50%, 100%{ opacity:0; }
}

/* "keep scrolling" cue — same pulsing-ring technique as
   .fan-card-exit-dot's hintPulse, retuned to gold-deep so the ring
   reads against this section's light backdrop instead of the dark
   sections that pattern was built for elsewhere */
.contrast-scroll-cue{
  margin-top:2.2rem;
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:.6rem;
}
.contrast-scroll-cue-dot{
  width:8px;
  height:8px;
  border-radius:50%;
  background:var(--gold-deep);
  animation:contrastCuePulse 1.8s ease-out infinite;
}
@keyframes contrastCuePulse{
  0%{ box-shadow:0 0 0 0 rgba(138,106,31,.45); }
  70%{ box-shadow:0 0 0 9px rgba(138,106,31,0); }
  100%{ box-shadow:0 0 0 0 rgba(138,106,31,0); }
}
.contrast-scroll-cue-label{
  font-size:clamp(.92rem, 2.1vw, 1.08rem);
  font-weight:700;
  letter-spacing:.12em;
  text-transform:uppercase;
  color:var(--gold-deep);
  animation:contrastCueBlink 1.1s ease-in-out infinite;
}
@keyframes contrastCueBlink{
  0%, 100%{ opacity:1; }
  50%{ opacity:.32; }
}

/* wraps the stage-title + the stage itself so they stack as one flex
   child of .contrast-sticky, instead of .contrast-sticky needing its
   own flex-direction just to stack two real children */
.contrast-stage-wrap{
  position:relative;
  z-index:2;
  display:flex;
  flex-direction:column;
  align-items:center;
  flex-shrink:0;
}

/* sits above the stage with room to breathe — reads "Old Website"
   until the wipe crosses its own halfway point, then crossfades (the
   same before/after-label technique used elsewhere in this section)
   into "Papi Website" in gold, right in the same spot */
.contrast-stage-title{
  position:relative;
  z-index:2;
  height:1.7em;
  min-width:14ch;
  margin-bottom:1.3rem;
}
.contrast-stage-title-before,
.contrast-stage-title-after{
  position:absolute;
  inset:0;
  display:flex;
  align-items:center;
  justify-content:center;
  white-space:nowrap;
  font-family:'Fraunces', serif;
  font-weight:600;
  font-size:clamp(1rem, 2.4vw, 1.35rem);
  letter-spacing:.03em;
}
.contrast-stage-title-before{ color:var(--paper-ink); }
.contrast-stage-title-after{ color:var(--gold-deep); opacity:0; }

.contrast-stage{
  position:relative;
  width:min(980px, 90vw);
  aspect-ratio:16/10;
  border-radius:16px;
  overflow:hidden;
  box-shadow:0 40px 90px rgba(11,11,12,.22);
  flex-shrink:0;
}

/* shared base for both mocked-up "websites" — identical box, stacked
   exactly on top of each other so the wipe reads as one screen
   changing, not two separate panels */
.mock{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  overflow:hidden;
}

/* ---- BEFORE: deliberately bland — default system font, a harsh
   "generic web" blue, boxy shadowless cards, no motion. Not a strawman
   so much as what a huge number of small-business sites actually look
   like. ---- */
.mock-before{
  background:#ffffff;
  font-family:Arial, Helvetica, sans-serif;
  color:#333;
  z-index:1;
}
.mock-before .mock-nav{
  display:flex;
  align-items:center;
  justify-content:space-between;
  padding:1.1rem 1.6rem;
  border-bottom:1px solid #e2e2e2;
  font-size:.85rem;
}
.mock-before .mock-logo{ font-weight:700; color:#222; }
.mock-before .mock-links{ color:#1a5fb4; letter-spacing:.01em; }
.mock-before .mock-hero{
  text-align:center;
  padding:clamp(1.6rem,5vw,3.2rem) 1.5rem clamp(1.2rem,3vw,2rem);
}
.mock-before .mock-hero h3{
  font-size:clamp(1.15rem, 3vw, 1.7rem);
  font-weight:700;
  color:#222;
  margin-bottom:.6rem;
}
.mock-before .mock-hero p{
  font-size:.85rem;
  color:#555;
  margin-bottom:1.1rem;
}
.mock-before .mock-btn{
  display:inline-block;
  background:#2563eb;
  color:#fff;
  font-size:.82rem;
  font-weight:700;
  padding:.55rem 1.3rem;
  border-radius:3px;
  box-shadow:0 2px 4px rgba(0,0,0,.2);
}
.mock-before .mock-cards{
  display:flex;
  gap:.9rem;
  padding:0 1.5rem 1.5rem;
}
.mock-before .mock-card{
  flex:1;
  background:#fff;
  border:1px solid #ddd;
  border-radius:4px;
  padding:.9rem .7rem;
  text-align:center;
  font-size:.78rem;
  font-weight:700;
  color:#444;
  box-shadow:0 1px 3px rgba(0,0,0,.08);
}
/* dated "follow us" badges — a small, very recognizable "this hasn't
   been touched in years" signal. Text-only approximations of the old
   platforms' own look rather than reproductions of their actual marks. */
.mock-social-row{
  display:flex;
  gap:.5rem;
  padding:.6rem 1.6rem 0;
}
.mock-social-badge{
  font-size:.66rem;
  font-weight:700;
  padding:.25rem .6rem;
  border-radius:3px;
  color:#fff;
  font-family:Arial, Helvetica, sans-serif;
}
.mock-social-badge--myspace{ background:#003399; }
.mock-social-badge--tumblr{ background:#001935; }
/* the classic broken/empty-state placeholder — the fastest possible
   signal that a page was never finished */
.mock-photo-broken{
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:.3rem;
  margin:0 1.6rem 1.2rem;
  padding:1.4rem 1rem;
  border:1.5px dashed #ccc;
  border-radius:4px;
  background:#fafafa;
  color:#999;
  font-size:.72rem;
}
.mock-photo-broken-icon{ font-size:1.3rem; opacity:.6; }

/* ---- AFTER: the Papi treatment of the exact same business/content —
   Fraunces serif, gold accents, generous space, the site's own real
   pill-button language. Clipped by --wipe (js/contrast.js), revealing
   left-to-right over .mock-before beneath it. ---- */
.mock-after{
  background:linear-gradient(160deg, #fffaf0 0%, #ffffff 60%);
  font-family:'Fraunces', serif;
  color:var(--paper-ink);
  z-index:2;
  clip-path:inset(0 var(--wipe, 100%) 0 0);
}
.mock-nav--after{
  display:flex;
  align-items:center;
  justify-content:space-between;
  padding:1.3rem 1.8rem;
}
.mock-logo--after{ font-size:1rem; letter-spacing:.02em; }
.mock-logo--after em{ font-style:italic; color:#2e6bff; }

/* the "after" hero isn't just the same layout in nicer type — a dark,
   immersive banner (a real video behind the text, ambient blurred
   glows drifting slowly, a shine-sweep on the emphasized word) is
   what actually reads as "immersive," where the first attempt at this
   (same content, just Fraunces + gold instead of Arial + blue) only
   ever read as "a nicer-looking version of the same page." Blue/black
   rather than the original gold — a deliberate palette swap so this
   mock doesn't just look like a smaller copy of the real Papi hero. */
.mock-hero--after{
  position:relative;
  overflow:visible;
  background:radial-gradient(120% 100% at 50% 15%, #0d1b2e 0%, #060a10 65%, #020305 100%);
  text-align:center;
  padding:clamp(1.8rem,4.5vw,2.8rem) 1.5rem clamp(2rem,4.5vw,2.8rem);
  color:#eaf1fb;
}
.mock-hero-bg{
  position:absolute;
  inset:0;
  overflow:hidden;
  pointer-events:none;
}
/* the real video, once one is attached at videos/plumbing-hero.mp4 —
   until then there's simply no source to load, and .mock-hero--after's
   own dark gradient (behind this element) shows through untouched */
.mock-hero-video{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  object-fit:cover;
  /* pans the visible crop of the frame slowly from top to bottom and
     back, on a loop independent of the video's own runtime — reads as
     the page in the footage being scrolled down (and back up) so more
     of the "after" template is visible over time, rather than the
     video just sitting on one fixed crop the whole time it plays */
  animation:mockVideoPan 14s ease-in-out infinite alternate;
}
@keyframes mockVideoPan{
  0%{ object-position:50% 0%; }
  100%{ object-position:50% 100%; }
}
/* darkens/evens out whatever the video actually shows so the copy and
   CTA stay legible regardless of its own brightness/color */
.mock-hero-overlay{
  position:absolute;
  inset:0;
  background:linear-gradient(180deg, rgba(2,4,8,.5) 0%, rgba(2,4,8,.72) 100%);
}
/* filter:blur() removed — even while contrast.js pauses the drift
   keyframe below once scrolled out of view, the blur filter itself
   still costs a WebKit recomposite on every frame the drift animation
   IS running (the whole time this section is actually being viewed).
   Multiple gradient stops give the same soft glow without it. */
.mock-hero-glow{
  position:absolute;
  border-radius:50%;
}
.mock-hero-glow--a{
  width:60%;
  aspect-ratio:1;
  top:-22%;
  left:-12%;
  background:radial-gradient(circle, rgba(79,157,255,.32) 0%, rgba(79,157,255,.16) 35%, rgba(79,157,255,.05) 60%, transparent 80%);
  animation:mockGlowDrift 9s ease-in-out infinite;
}
.mock-hero-glow--b{
  width:50%;
  aspect-ratio:1;
  bottom:-26%;
  right:-10%;
  background:radial-gradient(circle, rgba(46,107,255,.26) 0%, rgba(46,107,255,.13) 35%, rgba(46,107,255,.04) 60%, transparent 80%);
  animation:mockGlowDrift 11s ease-in-out infinite reverse;
}
@keyframes mockGlowDrift{
  0%, 100%{ transform:translate(0,0) scale(1); }
  50%{ transform:translate(5%,6%) scale(1.15); }
}
/* a real two-column hero — headline/CTA/stats on one side, a photo
   panel + a floating customer review on the other — rather than the
   single centred text block every generic template (and the first
   "after" attempt) uses. This is the structural change that actually
   reads as a different, richer page, not just a recolored one. */
.mock-hero-grid{
  position:relative;
  z-index:1;
  display:grid;
  grid-template-columns:1.1fr .9fr;
  gap:1.8rem;
  align-items:center;
  text-align:left;
  max-width:840px;
  margin:0 auto;
}
.mock-eyebrow--after{
  font-size:.68rem;
  font-weight:600;
  letter-spacing:.16em;
  text-transform:uppercase;
  color:#7db2ff;
  margin-bottom:.7rem;
}
.mock-hero--after h3{
  font-size:clamp(1.2rem, 2.8vw, 1.8rem);
  font-weight:500;
  line-height:1.25;
  margin-bottom:.7rem;
  color:#f4f8ff;
}
/* the same metallic shine-sweep technique as .quote-form-title — a
   moving gradient clipped to the text, icy blue/white rather than gold */
.mock-hero--after h3 em{
  font-style:italic;
  font-weight:400;
  background:linear-gradient(100deg, #0a3d91 0%, #4f9dff 30%, #eaf4ff 50%, #4f9dff 70%, #0a3d91 100%);
  background-size:250% 100%;
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
  animation:mockShine 5s ease-in-out infinite;
}
@keyframes mockShine{
  0%{ background-position:0% 50%; }
  50%{ background-position:100% 50%; }
  100%{ background-position:0% 50%; }
}
.mock-sub--after{
  font-size:.85rem;
  color:rgba(234,241,251,.7);
  max-width:32ch;
  margin:0 0 1.2rem;
}
.mock-btn--after{
  display:inline-flex;
  align-items:center;
  gap:.4rem;
  border:1px solid rgba(23,24,26,.3);
  background:rgba(203,168,105,.14);
  color:var(--paper-ink);
  font-size:.76rem;
  letter-spacing:.06em;
  text-transform:uppercase;
  padding:.7rem 1.3rem;
  border-radius:999px;
}
/* the hero's own CTA sits directly on top of a video background, so
   it needs to hold up against arbitrary, unpredictable footage — a
   solid, opaque, high-contrast fill reads reliably regardless of what
   plays behind it, unlike the subtle bordered-pill look that's fine
   over a flat light background elsewhere */
.mock-hero--after .mock-btn--after{
  border:1px solid rgba(240,220,168,.55);
  background:linear-gradient(135deg, #f5e2b3 0%, #cba869 45%, #8a6a1f 100%);
  color:#241a04;
  font-weight:700;
  box-shadow:0 10px 26px rgba(0,0,0,.4);
}
.mock-btn--small{ font-size:.68rem; padding:.5rem 1rem; }

/* animated stat pair under the CTA — the numbers count up once (see
   js/contrast.js) the first time this mock scrolls into view */
.mock-stats{
  display:flex;
  gap:1.6rem;
  margin-top:1.1rem;
}
.mock-stat{ display:flex; flex-direction:column; }
.mock-stat strong{
  font-size:1.3rem;
  font-weight:600;
  color:#eaf4ff;
  line-height:1.1;
}
.mock-stat span{
  font-size:.62rem;
  color:rgba(234,241,251,.55);
  text-transform:uppercase;
  letter-spacing:.08em;
}

/* the "picture" side of the hero — an actual generated photo now
   (img/mock/plumber-handshake.jpg), with the dark gradient kept as the
   card's own background so it still reads as an intentional framed
   panel the instant it loads, rather than a flash of empty white */
.mock-hero-visual{
  position:relative;
  display:flex;
  justify-content:center;
}
.mock-photo-card{
  width:100%;
  max-width:200px;
  aspect-ratio:4/5;
  border-radius:16px;
  overflow:hidden;
  background:linear-gradient(150deg, #16233a 0%, #060a12 70%);
  box-shadow:0 20px 50px rgba(0,0,0,.35);
  animation:mockPhotoFloat 6s ease-in-out infinite;
}
.mock-photo-card img{
  display:block;
  width:100%;
  height:100%;
  object-fit:cover;
}
@keyframes mockPhotoFloat{
  0%, 100%{ transform:translateY(0); }
  50%{ transform:translateY(-8px); }
}
/* a real customer review, not just a "trust badge" — an avatar, a
   rating, a quote, a name — floating over the seam between the photo
   card and the seam below it, on its own gentle bob so it doesn't
   read as a static sticker */
.mock-review-float{
  position:absolute;
  left:-4%;
  bottom:-8%;
  z-index:2;
  display:flex;
  gap:.55rem;
  align-items:flex-start;
  background:#fff;
  border-radius:14px;
  padding:.7rem .9rem;
  max-width:190px;
  text-align:left;
  box-shadow:0 16px 34px rgba(11,11,12,.28);
  animation:mockReviewIn .6s var(--ease-out) .2s both, mockReviewFloat 5s ease-in-out 1s infinite;
}
.mock-review-avatar{
  flex:none;
  width:26px;
  height:26px;
  border-radius:50%;
  overflow:hidden;
  background:linear-gradient(135deg, #4f9dff, #0a3d91);
  color:#fff;
  font-size:.6rem;
  font-weight:700;
  display:flex;
  align-items:center;
  justify-content:center;
}
.mock-review-avatar img{
  display:block;
  width:100%;
  height:100%;
  object-fit:cover;
  /* focuses on the face rather than the centre of the frame, which
     for this particular photo would land between two people */
  object-position:75% 30%;
}
.mock-review-stars{ display:block; color:#e0a92e; font-size:.62rem; letter-spacing:.02em; }
.mock-review-body p{
  font-size:.66rem;
  color:var(--paper-ink);
  line-height:1.35;
  margin:.2rem 0;
}
.mock-review-name{ font-size:.58rem; color:var(--paper-dim); }
@keyframes mockReviewFloat{
  0%, 100%{ transform:translateY(0); }
  50%{ transform:translateY(-6px); }
}
@keyframes mockReviewIn{
  from{ opacity:0; transform:translateY(10px) scale(.94); }
  to{ opacity:1; transform:translateY(0) scale(1); }
}
/* replaces the old static three-card row — an actual scrolling element
   the visitor watches move, continuing the "immersive" idea into the
   services area instead of stopping at the hero. Two identical copies
   of the same pill list back to back, translated by exactly -50%,
   is what makes the loop seamless rather than visibly snapping. */
.mock-services-marquee{
  overflow:hidden;
  padding:1.1rem 0;
  background:#04070d;
  -webkit-mask-image:linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
  mask-image:linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.mock-services-track{
  display:flex;
  gap:.7rem;
  width:max-content;
  animation:mockMarquee 16s linear infinite;
}
.mock-service-pill{
  flex:none;
  background:rgba(79,157,255,.12);
  border:1px solid rgba(79,157,255,.35);
  color:#cfe6ff;
  font-size:.72rem;
  font-weight:500;
  padding:.5rem 1rem;
  border-radius:999px;
  white-space:nowrap;
}
@keyframes mockMarquee{
  from{ transform:translateX(0); }
  to{ transform:translateX(-50%); }
}

/* the wipe seam itself — tracks --wipe so it always sits exactly at
   the current reveal edge */
.contrast-divider{
  position:absolute;
  top:0;
  bottom:0;
  left:calc(100% - var(--wipe, 100%));
  width:2px;
  background:rgba(255,255,255,.9);
  box-shadow:0 0 16px rgba(11,11,12,.35);
  z-index:3;
  transform:translateX(-50%);
  pointer-events:none;
}
.contrast-divider i{
  position:absolute;
  top:50%;
  left:50%;
  transform:translate(-50%,-50%);
  width:34px;
  height:34px;
  border-radius:50%;
  background:#fff;
  box-shadow:0 4px 14px rgba(11,11,12,.3);
}
.contrast-divider i::before{
  content:'';
  position:absolute;
  inset:0;
  margin:auto;
  width:12px;
  height:12px;
  border-left:1.5px solid var(--paper-ink);
  border-bottom:1.5px solid var(--paper-ink);
  transform:rotate(45deg);
}

@media (max-width:640px){
  /* sized to the after mock's actual content (nav + hero + services
     marquee) plus a small buffer — taller than this just leaves empty
     background showing below the marquee, which is exactly the "big
     empty white space" this was tuned to remove */
  .contrast-stage{ aspect-ratio:2/3.3; }
  .contrast-stage-title{ height:1.4em; margin-bottom:.7rem; }
  .contrast-stage-title-before, .contrast-stage-title-after{ font-size:.85rem; }
  .mock-before .mock-links{ display:none; }
  .mock-cards{ flex-direction:column; gap:.5rem; padding:0 1.2rem 1rem; }
  .mock-before .mock-hero{ padding:1.2rem 1.2rem .8rem; }
  .mock-hero--after{ padding:.8rem 1rem .5rem; }
  .mock-before .mock-nav, .mock-nav--after{ padding:.85rem 1.2rem; }
  .mock-services-marquee{ padding:.8rem 0; }
  .mock-service-pill{ font-size:.66rem; padding:.4rem .8rem; }
  /* the two-column hero (copy + photo/review) stacks into one column;
     the review card drops out of absolute positioning into normal
     flow, right below the photo card, rather than risking it
     overlapping/clipping on a narrow screen. The photo card itself
     shrinks a lot here too — at its desktop size, stacked *and*
     centred above the review card and stats, it alone accounted for
     a large share of the overflow this layout had at this width. */
  .mock-hero-grid{ grid-template-columns:1fr; text-align:center; gap:.6rem; }
  .mock-hero-copy{ text-align:center; }
  .mock-sub--after{ margin:0 auto .8rem; }
  .mock-stats{ justify-content:center; margin-top:.7rem; }
  .mock-hero-visual{ flex-direction:column; align-items:center; margin-top:.2rem; padding-bottom:1.8rem; }
  .mock-photo-card{ max-width:110px; }
  .mock-review-float{
    position:absolute;
    left:50%;
    bottom:-1rem;
    transform:translateX(-50%);
    padding:.55rem .7rem;
    max-width:170px;
    animation:mockReviewIn .6s var(--ease-out) .2s both;
  }
}

/* ===================================================================
   SHOWCASE — fan of live-demo previews (left) + business list (right),
   both driven by scroll position via a sticky inner viewport
=================================================================== */
/* on brand: black background with gold-toned cards/text, matching the
   cube/testimonials/form sections instead of the old white "paper" look */
.showcase{
  position:relative;
  z-index:1;
}
.showcase-bg{
  position:absolute;
  inset:0;
  z-index:0;
}
.showcase-bg--dark{
  background:radial-gradient(120% 90% at 50% 0%, #14121a 0%, #07060a 55%, #000 100%);
}
/* NOTE: a previous fix here bumped this section's own z-index above
   the expanded fan-card's while one was open, meaning to let the quote
   nested inside it stack back on top of the card. It actually dragged
   this section's own opaque background above the card instead, hiding
   the card entirely — the quote is re-parented onto <body> directly in
   showcase.js now (the same way the card itself already is), which
   doesn't have that problem. */

/* stacked, in reading order: eyebrow, then the scrolling trade name,
   then the fan of preview cards underneath — was a two-column grid
   (fan left, name+eyebrow right); this reads top-to-bottom instead */
.showcase-sticky{
  position:sticky;
  top:0;
  height:100vh;
  /* see the long comment on .contrast-section above — same fix, same
     reason. This section's own outer height is set from JS in
     showcase.js using the matching svh unit, for the same reason. */
  height:100svh;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  /* the trade-name carousel's own text visually overflows its
     container's nominal height (large type, translated in/out around
     a shared centre) — a bigger gap here keeps that overflow clear of
     the fan cards underneath instead of the two overlapping */
  gap:clamp(2rem, 6vw, 3.6rem);
  padding:2rem clamp(1.5rem, 5vw, 4.5rem);
  overflow:hidden;
  text-align:center;
}
/* opened up only while a fan card is expanded — normally this clips
   the fan's own rotated/off-centre cards to the section, but the
   expanded card is deliberately larger than that footprint so it can
   actually be read. The big gap above exists to keep the trade-name
   carousel clear of the cards — moot while expanded (those names are
   faded out and the quote takes their place instead), so it's pulled
   in tight here to bring the card right up under the quote instead of
   leaving it stranded far below. */
.showcase-sticky.has-expanded-card{
  overflow:visible;
  gap:clamp(.5rem, 2vw, 1rem);
}

.showcase-fan{
  position:relative;
  z-index:2;
  width:100%;
  height:min(38vh, 340px);
  display:flex;
  align-items:center;
  justify-content:center;
}
.showcase-fan.has-expanded-card{ overflow:visible; }

/* above ~860px there's enough width to give the trade names their own
   column on the left instead of stacking above the fan — scrolling
   through them no longer competes with the fan/cards for the same
   vertical space the way it did stacked. The fan gets the rest of the
   width (and more height) to spread and expand into. The quote lives
   inside .showcase-fan in the markup for exactly this — it rides along
   with the fan's own column instead of staying centered over the old,
   now-vacated stacked layout. Everything here is scoped to this
   min-width query on purpose: the stacked, centered layout below it
   (iPhone, iPad portrait) is untouched. */
@media (min-width:861px){
  .showcase-sticky{
    --showcase-left-col: clamp(200px, 24vw, 300px);
    --showcase-col-gap: clamp(2.5rem, 5vw, 4.5rem);
    display:grid;
    grid-template-columns: var(--showcase-left-col) 1fr;
    column-gap: var(--showcase-col-gap);
    /* tighter than the flex layout's own gap (that one's sized for the
       *stacked* mobile layout, where the trade names need clearance
       from the fan below them — here they're side by side instead) —
       title+subtitle+items were reading as pushed down into the
       bottom-left corner of the column, well below the fan's own
       vertical centre, with that much space between each of them */
    row-gap:.7rem;
    align-items:center;
  }
  .showcase-sticky.has-expanded-card{ column-gap: var(--showcase-col-gap); }
  .showcase-title{ grid-column:1; grid-row:1; text-align:left; }
  .showcase-subtitle{ grid-column:1; grid-row:2; text-align:left; }
  /* pulled up to sit level with the fan's own vertical centre instead
     of trailing at the bottom of the title+subtitle stack — align-self
     here (rather than relying on the row's own auto height) is what
     actually lets it move independently of how tall the title renders */
  .showcase-items{ grid-column:1; grid-row:3; align-self:center; margin-top:-1.4rem; }
  .showcase-item{ text-align:left; }
  .showcase-fan{
    grid-column:2;
    grid-row:1 / span 3;
    height:min(48vh, 440px);
  }
}

.fan-card{
  --card-scale:1;
  position:absolute;
  width:clamp(210px, 22vw, 300px);
  height:clamp(280px, 29vw, 400px);
  border-radius:18px;
  /* backdrop-filter removed everywhere on this card (it briefly lived
     on .is-active/.is-expanded only) — that was still a real WebKit
     cliff: dynamically ADDING backdrop-filter to a newly-expanding,
     position:fixed card forces an expensive synchronous layer/backdrop
     creation right at the moment of the tap, which is what showed up
     as a several-second freeze (and the card appearing to just vanish
     instead of opening) specifically on iPhone. A gradient tint gives
     it some depth without ever sampling anything behind it, at any
     card count or interaction. */
  background:linear-gradient(160deg, rgba(255,255,255,.13), rgba(255,255,255,.05));
  border:1px solid rgba(255,255,255,.22);
  box-shadow:0 30px 70px -26px rgba(0,0,0,.6), 0 0 30px rgba(255,255,255,.06);
  overflow:hidden;
  transform-origin:center bottom;
  will-change:transform, opacity;
  transition:transform .35s var(--ease-out), opacity .3s var(--ease-out), box-shadow .35s var(--ease-out), border-color .35s var(--ease-out), width .35s var(--ease-out), height .35s var(--ease-out);
  cursor:none;
  /* backface-visibility/translateZ alone didn't fix the expanded blur
     — the real cause is that border-radius + overflow:hidden +
     backdrop-filter force this element onto its own rasterized
     texture, and CSS transform:scale() then just stretches that
     already-rasterized bitmap rather than re-rendering its content
     (the name text and the image) sharp at the larger size. Fixed at
     the source: the expanded state resizes the card for real (width/
     height in showcase.js) instead of scaling it, so the browser
     lays out and paints it fresh at its true displayed size. Padding/
     font-size/etc. below scale via the --card-scale variable that
     goes with it, since a real resize doesn't proportionally grow
     rem-based children on its own the way transform:scale() did. */
}
.fan-card:hover{ box-shadow:0 34px 80px -24px rgba(0,0,0,.7), 0 0 30px rgba(255,255,255,.1); }
.fan-card.is-active{
  background:linear-gradient(160deg, rgba(255,255,255,.2), rgba(255,255,255,.08));
  border-color:rgba(255,255,255,.45);
  box-shadow:0 46px 110px -22px rgba(0,0,0,.8), 0 0 0 1px rgba(255,255,255,.25);
  /* a slow breathing glow, so the front card feels alive rather than
     static once it's settled into place — safe to animate box-shadow
     here since (unlike transform/opacity) nothing else writes to it
     every frame, so it never fights the scroll-driven JS above */
  animation:fanCardGlow 3.4s ease-in-out infinite;
}
@keyframes fanCardGlow{
  0%, 100%{ box-shadow:0 46px 110px -22px rgba(0,0,0,.8), 0 0 0 1px rgba(255,255,255,.25); }
  50%{ box-shadow:0 46px 120px -20px rgba(0,0,0,.8), 0 0 42px 4px rgba(255,255,255,.35); }
}
.fan-card.is-expanded{
  /* fixed to the viewport, rather than scaled up from its normal
     position within the fan — the fan sits wherever the section's
     scroll happens to have it, which could be anywhere from top to
     bottom of a very tall section, and scaling up in place could push
     the card's edges past the screen on shorter monitors regardless of
     how the scale itself was capped. left is fixed at centre; top is
     set directly by showcase.js, which measures the actual space left
     below the quote and centers the card in that, rather than the
     whole viewport — dead-centering it here could put it right on top
     of the quote (and the section title above that). */
  position:fixed;
  left:50%;
  z-index:500;
  cursor:none;
  /* explicit here too (not just inherited from .is-active) — a card
     can be expanded by clicking any card in the fan, not only the one
     currently front-and-center, so the two classes don't always
     coincide */
  background:linear-gradient(160deg, rgba(255,255,255,.2), rgba(255,255,255,.08));
  box-shadow:0 60px 140px -20px rgba(0,0,0,.85), 0 0 0 1.5px rgba(255,255,255,.6), 0 0 60px 6px rgba(255,255,255,.32);
  animation:none;
}

/* a quiet "there's a way out" affordance to go with the scroll lock
   while a card is expanded — a small pulsing dot rather than a
   labeled button, so it reads as a subtle cue instead of competing
   with the close (✕) button for attention. Still clickable: it falls
   through to the card's own click handler (tapping the already-
   expanded card again collapses it), it's just not a separate control. */
.fan-card-exit-dot{
  position:absolute;
  left:50%;
  bottom:.85rem;
  z-index:2;
  width:8px;
  height:8px;
  border-radius:50%;
  background:#ffdd7a;
  transform:translateX(-50%);
  opacity:0;
  transition:opacity .3s var(--ease-out);
}
.fan-card.is-expanded .fan-card-exit-dot{
  opacity:.9;
  animation:hintPulse 1.8s ease-out infinite;
}
/* used here and nowhere else now — used to also drive the cube
   section's own interact-hint pulse before that section was removed */
@keyframes hintPulse{
  0%{ box-shadow:0 0 0 0 rgba(255,221,122,.6); }
  70%{ box-shadow:0 0 0 9px rgba(255,221,122,0); }
  100%{ box-shadow:0 0 0 0 rgba(255,221,122,0); }
}

/* a short encouraging line that takes over the trade-name carousel's
   own spot (below the "Real websites. Every industry." eyebrow, above
   the fan) only while a card is expanded — picked at random each time
   (see PHRASES in showcase.js) — rather than sitting on top of the
   card/image itself, which would compete with the pan animation
   that's the actual point of expanding. The trade names underneath
   are untouched: they simply fade out (existing opacity logic in
   showcase.js) while this is visible, and fade back in once collapsed.
   Anchored to the top of that space (not vertically centered in it)
   with its own top margin — keeps clear room both above (between it
   and the eyebrow) and below (between it and the expanded card, which
   also now grows from its own centre instead of its bottom edge, so
   it no longer reaches as far up into this space). */
.showcase-phrase{
  position:absolute;
  top:0;
  left:0;
  right:0;
  margin:1.75rem 0 0;
  padding:0 1.5rem;
  font-family:'Fraunces', serif;
  font-style:italic;
  font-weight:500;
  font-size:clamp(1.2rem, 3vw, 1.7rem);
  text-align:center;
  color:#ffdd7a;
  text-shadow:0 2px 16px rgba(0,0,0,.5);
  opacity:0;
  pointer-events:none;
}
.showcase-phrase::before{ content:'\201C'; }
.showcase-phrase::after{ content:'\201D'; }

/* .showcase-phrase's containing block is .showcase-fan, which above
   ~860px is only the right-hand column (see the showcase-sticky grid
   below), off-centre from the section as a whole — but the expanded
   card centers itself on the full viewport (position:fixed; left:50%)
   same as it always has. Left alone, the quote stayed centered on the
   fan's own narrower box instead, visibly drifting toward the
   expanded card's right edge/corner instead of sitting over its
   centre. Extending the quote's own left edge back out by exactly the
   width the left column + gap take re-spans it across the *whole*
   section again, so text-align:center centers it on the same middle
   the expanded card uses. (Placed after the base rule above, not
   inside the grid media query further up, so it actually wins the
   cascade against that rule's own left:0 at equal specificity.) */
@media (min-width:861px){
  .showcase-phrase{
    left: calc(-1 * (var(--showcase-left-col) + var(--showcase-col-gap)));
    /* pulled up further from the base rule's 1.75rem — moving the
       quote closer to the top of the fan raises the floor (topBound in
       showcase.js) the expanded card centers itself against, so the
       card sits higher too instead of just the quote moving on its own */
    margin-top:-.75rem;
  }
}

/* a different entrance plays each time (picked at random in
   showcase.js, one class added per expand) — the point is that the
   phrase itself keeps drawing the eye every time a card opens, rather
   than becoming background noise after the first couple of times it's
   seen fade in the exact same way */
@keyframes phraseRise{
  from{ opacity:0; transform:translateY(18px); }
  to{ opacity:1; transform:translateY(0); }
}
@keyframes phrasePop{
  0%{ opacity:0; transform:scale(.8); }
  70%{ opacity:1; transform:scale(1.06); }
  100%{ opacity:1; transform:scale(1); }
}
@keyframes phraseSlideLeft{
  from{ opacity:0; transform:translateX(-30px); }
  to{ opacity:1; transform:translateX(0); }
}
@keyframes phraseSlideRight{
  from{ opacity:0; transform:translateX(30px); }
  to{ opacity:1; transform:translateX(0); }
}
@keyframes phraseBlurIn{
  from{ opacity:0; filter:blur(7px); transform:translateY(8px); }
  to{ opacity:1; filter:blur(0); transform:translateY(0); }
}
.showcase-phrase.anim-rise{ animation:phraseRise .6s var(--ease-out) both; }
.showcase-phrase.anim-pop{ animation:phrasePop .6s var(--ease-out) both; }
.showcase-phrase.anim-slide-left{ animation:phraseSlideLeft .6s var(--ease-out) both; }
.showcase-phrase.anim-slide-right{ animation:phraseSlideRight .6s var(--ease-out) both; }
.showcase-phrase.anim-blur{ animation:phraseBlurIn .7s var(--ease-out) both; }
.fan-card .fan-bar{
  display:flex;
  gap:calc(5px * var(--card-scale));
  padding:calc(.7rem * var(--card-scale)) calc(.8rem * var(--card-scale));
  background:rgba(255,221,122,.06);
}
.fan-card .fan-bar span{
  width:calc(7px * var(--card-scale));
  height:calc(7px * var(--card-scale));
  border-radius:50%;
  background:rgba(255,221,122,.2);
}
.fan-card .fan-hero{
  position:relative;
  /* was 44% — too tight a crop to read as an actual site, more like a
     sliver peeking out above the card's own name/lines. Taller window
     (closer to a mini tablet screen than a thin letterbox) shows
     meaningfully more of the real design underneath */
  height:66%;
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:calc(2.4rem * var(--card-scale));
  overflow:hidden;
}
/* real live-site screenshots are tall full-page captures — cropped to
   just their own top (the hero) rather than squashed to fit, cover +
   top alignment reads as a proper preview thumbnail instead of a
   shrunk full page */
.fan-card .fan-hero img{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  object-fit:cover;
  object-position:top center;
}
/* while expanded, a slow gentle pan reveals more of the page below the
   hero crop these otherwise sit on — full-page screenshots have a lot
   more to show than the sliver a static top-aligned crop lets through.
   Only runs while expanded; every other card stays on its static crop. */
.fan-card.is-expanded .fan-hero img{
  animation:fanHeroPan 10s ease-in-out infinite alternate;
}
@keyframes fanHeroPan{
  0%{ object-position:top center; }
  100%{ object-position:bottom center; }
}
.fan-card .fan-body{ padding:calc(1rem * var(--card-scale)) calc(1.15rem * var(--card-scale)); }
.fan-card .fan-name{
  font-family:'Fraunces', serif;
  font-size:calc(1.05rem * var(--card-scale));
  margin-bottom:calc(.7rem * var(--card-scale));
  color:#ffdd7a;
}
.fan-card .fan-line{
  height:calc(6px * var(--card-scale));
  border-radius:3px;
  background:rgba(255,221,122,.1);
  margin-bottom:calc(.45rem * var(--card-scale));
}
.fan-card .fan-line.short{ width:55%; }

/* the section's own mission statement — rises in with the rest of the
   sticky's own fade (see js/showcase.js's updateEntrance) rather than a
   separate effect of its own */
/* Inter, not Fraunces — the mission statement is a full sentence, not
   a short display headline, and reads more like modern editorial copy
   in a clean sans at a light weight than as another serif headline */
.showcase-title{
  position:relative;
  z-index:2;
  font-family:'Inter', sans-serif;
  font-weight:300;
  letter-spacing:.01em;
  font-size:clamp(1.05rem, 2.3vw, 1.4rem);
  line-height:1.6;
  max-width:42ch;
  color:var(--cream);
  /* smooths the scroll-driven fade-out js/showcase.js writes every
     frame (raw scroll steps, not one per pixel) into something that
     reads as a continuous fade rather than a choppy stepped one, and
     also softens the instant hide/restore when a card expands/collapses */
  transition:opacity .25s var(--ease-out);
}
/* a slow, smooth breathing glow once the title has fully settled into
   place (js/showcase.js toggles this class once its entrance reaches
   1) — a plain CSS text-shadow pulse, no JS driving it per frame */
.showcase-title.is-glowing{
  animation:showcaseTitleGlow 3.6s ease-in-out infinite;
}
@keyframes showcaseTitleGlow{
  0%, 100%{ text-shadow:0 0 16px rgba(255,221,122,.18); }
  50%{ text-shadow:0 0 32px rgba(255,221,122,.4); }
}

/* metallic gold, same shine-sweep technique as .quote-form-title, with
   a slow breathing scale/opacity layered on top for "pulsing" — gold
   already reads fine on both this section's dark start and its cream
   end, so this needs no light/dark variant of its own */
.showcase-subtitle{
  position:relative;
  z-index:2;
  font-size:.76rem;
  font-weight:600;
  letter-spacing:.32em;
  text-transform:uppercase;
  margin-top:.9rem;
  background:linear-gradient(100deg, #8a6a1f 0%, #d4af37 25%, #fff6d8 50%, #d4af37 75%, #8a6a1f 100%);
  background-size:250% 100%;
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
  animation:showcaseSubShine 6s ease-in-out infinite, showcaseSubPulse 2.6s ease-in-out infinite;
}
@keyframes showcaseSubShine{
  0%{ background-position:0% 50%; }
  50%{ background-position:100% 50%; }
  100%{ background-position:0% 50%; }
}
@keyframes showcaseSubPulse{
  0%, 100%{ opacity:.88; transform:scale(1); }
  50%{ opacity:1; transform:scale(1.035); }
}
.showcase-items{
  position:relative;
  z-index:2;
  width:100%;
  height:min(26vh, 260px);
}
.showcase-item{
  position:absolute;
  top:50%;
  left:0;
  width:100%;
  text-align:center;
  font-family:'Fraunces', serif;
  font-size:clamp(2.2rem, 5.6vw, 4.6rem);
  font-weight:400;
  letter-spacing:-.01em;
  line-height:1.1;
  color:rgba(228,205,154,.28);
  white-space:nowrap;
  will-change:transform, opacity;
  /* shorter than before (was .5s) — this is re-triggered on every
     scroll frame with a fresh target position, so a shorter duration
     tracks the actual scroll position more closely (less lag chasing
     a target that's already moved on) instead of feeling like it's
     drifting a beat behind the page */
  transition:transform .35s var(--ease-out), opacity .35s var(--ease-out), color .6s var(--ease-out), text-shadow .6s var(--ease-out);
  cursor:none;
}
.showcase-item.is-active{
  color:#ffdd7a;
  text-shadow:0 0 64px rgba(255,221,122,.35), 0 0 120px rgba(255,221,122,.18);
}

@media (max-width:640px){
  .showcase-item{ font-size:clamp(1.7rem, 8vw, 2.6rem); }
}

@media (max-width:860px){
  .showcase-sticky{ padding-top:5rem; }
  .fan-card{ width:min(64vw, 240px); height:min(36vh, 320px); }
  /* a bit more clearance below the eyebrow/trade-name area than the
     base rule gives — on the stacked mobile layout the quote was
     sitting close enough to read as crowding the section title above it */
  .showcase-phrase{ margin-top:2.5rem; }
}

/* ===================================================================
   CUSTOM CURSOR — elegant, lagging "drag" feel
=================================================================== */
.custom-cursor{
  position:fixed;
  top:0; left:0;
  width:0; height:0;
  z-index:2000;
  pointer-events:none;
  opacity:0;
  transition:opacity .4s var(--ease-out);
}
/* only revealed once the loader has fully finished (see loader.js) */
.custom-cursor.is-ready{ opacity:1; }
.custom-cursor.is-ready.is-hidden{ opacity:0; }
/* fades away if the pointer sits still for a while */
.custom-cursor.is-ready.is-idle{ opacity:0; transition:opacity 1s ease; }
.cursor-ring{
  position:absolute;
  top:0; left:0;
  width:36px; height:36px;
  margin:-18px 0 0 -18px;
  border-radius:50%;
  border:1px solid rgba(243,237,226,.7);
  transition:width .35s var(--ease-out), height .35s var(--ease-out), margin .35s var(--ease-out), border-color .35s var(--ease-out), opacity .3s ease;
  /* a slow, subtle breathing glow rather than a flat static shadow —
     something about the cursor is always gently alive as it travels
     around the site, not just when it's over something interactive */
  animation:cursorGlowPulse 2.6s ease-in-out infinite;
}
@keyframes cursorGlowPulse{
  0%, 100%{ box-shadow:0 0 16px rgba(255,221,122,.3); }
  50%{ box-shadow:0 0 28px rgba(255,221,122,.55); }
}
.cursor-dot{
  position:absolute;
  top:0; left:0;
  width:5px; height:5px;
  margin:-2.5px 0 0 -2.5px;
  border-radius:50%;
  background:var(--cream);
}
.custom-cursor.is-active .cursor-ring{
  width:64px; height:64px;
  margin:-32px 0 0 -32px;
  border-color:#ffdd7a;
  /* brighter, faster pulse while hovering something interactive —
     the glow itself calling out that this is clickable */
  animation:cursorGlowPulseActive 1.3s ease-in-out infinite;
}
@keyframes cursorGlowPulseActive{
  0%, 100%{ box-shadow:0 0 24px rgba(255,221,122,.55); }
  50%{ box-shadow:0 0 40px rgba(255,221,122,.85); }
}

@media (hover:none), (pointer:coarse){
  .custom-cursor{ display:none; }
  body, a, .cta{ cursor:auto; }
}

/* ===================================================================
   TESTIMONIALS — businesses across industries, swipeable left/right.
   Deliberately NOT a tall scroll-hijacked section like the showcase
   and cube — a visitor shouldn't have to scroll through all of them
   vertically just to reach the form below. Browsing them (horizontal
   swipe/drag) and moving down the page (vertical scroll) are now two
   fully independent motions. Still dark, with the cube section's
   amber accent, bookending the light showcase in between.
=================================================================== */
.testimonials-section{
  position:relative;
  z-index:1;
  background:radial-gradient(120% 90% at 50% 0%, #14121a 0%, #07060a 55%, #000 100%);
  padding:clamp(3.5rem, 8vw, 6rem) 0;
  overflow:hidden;
}

.testimonials-bg{
  position:absolute;
  inset:0;
  z-index:0;
  overflow:hidden;
  pointer-events:none;
}
/* filter:blur(100px) used to sit here — a huge blur radius on two
   large (up to 560px) always-on circles that never pause is severely
   expensive specifically on iOS WebKit (both Safari and Chrome-on-iOS
   use the same underlying engine there), invisible on desktop
   Chromium. Multiple gradient stops instead of a single hard-edged
   one give the same soft, hazy falloff without needing an actual blur
   filter at all. */
.t-glow{
  position:absolute;
  width:min(50vw, 560px);
  height:min(50vw, 560px);
  border-radius:50%;
}
.t-glow--a{
  top:-10%; left:-10%;
  background:radial-gradient(circle, rgba(255,196,40,.22) 0%, rgba(255,196,40,.12) 35%, rgba(255,196,40,.04) 60%, transparent 80%);
}
.t-glow--b{
  bottom:-14%; right:-8%;
  background:radial-gradient(circle, rgba(202,153,20,.16) 0%, rgba(202,153,20,.09) 35%, rgba(202,153,20,.03) 60%, transparent 80%);
}

/* opacity/transform driven directly, continuously, by scroll position
   in testimonials.js as the section enters from below — not a fixed-
   duration animation triggered once, for the same reason as the cube
   title and quote form: it can never finish playing before the
   visitor actually scrolls to it, and reverses cleanly scrolling back up */
.testimonials-sticky{
  position:relative;
  z-index:1;
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:clamp(1.2rem, 3vw, 2rem);
  padding:0 1.5rem;
  opacity:0;
  will-change:opacity, transform;
}

.testimonials-eyebrow{
  position:relative;
  z-index:1;
  font-size:.72rem;
  letter-spacing:.4em;
  text-transform:uppercase;
  color:rgba(228,205,154,.65);
}
/* its own font on purpose — everything else on the site is Fraunces
   (headings) or Inter (body); Space Grotesk's confident, slightly
   geometric character gives this one section its own distinct voice */
.testimonials-heading{
  position:relative;
  z-index:1;
  font-family:'Space Grotesk', 'Inter', sans-serif;
  font-weight:700;
  letter-spacing:-.01em;
  font-size:clamp(2rem, 5vw, 3.4rem);
  background:linear-gradient(100deg, var(--cream) 30%, #ffdd7a 65%, #ffb400 100%);
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
  text-align:center;
  margin-bottom:.5rem;
}
/* each word signs itself in on its own slight rotation, cascading
   left to right — a flourish rather than the whole line just fading
   in flat. Driven per-frame from testimonials.js, tied to scroll.
   Solid gold rather than inheriting the parent's transparent/
   background-clip gradient — that clip only masks the ancestor
   element's own text runs, so a wrapped span with no background of
   its own would just render invisible (inherited color:transparent
   with nothing to clip against). */
.testimonials-heading-word{
  display:inline-block;
  color:#ffdd7a;
  opacity:0;
  will-change:opacity, transform;
}
/* a real mic icon (not an emoji) riding in right after "say" — solid
   gold like the word beside it, same reason as .testimonials-heading-
   word above: the parent's gradient text-clip only masks its own text
   runs, so this needs its own explicit color rather than inheriting
   the transparent fill */
.testimonials-say-icon{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  width:.72em;
  height:.72em;
  margin-left:.32em;
  vertical-align:-.06em;
  color:#ffb400;
  opacity:0;
  will-change:opacity, transform;
}
.testimonials-say-icon svg{ width:100%; height:100%; }

.testimonials-hint{
  position:relative;
  z-index:1;
  display:flex;
  align-items:center;
  gap:.4em;
  font-size:.78rem;
  color:rgba(243,237,226,.45);
  margin-top:-.5rem;
}
/* a small continuous side-to-side nudge on the icon itself — reinforces
   the swipe affordance for as long as the section is on screen, on top
   of the one-time actual-scroll nudge in testimonials.js */
.testimonials-hint-icon{
  display:inline-block;
  animation:testimonialHintNudge 1.6s ease-in-out infinite;
}
@keyframes testimonialHintNudge{
  0%, 100%{ transform:translateX(0); }
  50%{ transform:translateX(3px); }
}

/* the swipeable row itself — native horizontal scroll with snap, so
   touch drag/swipe just works with no JS needed for the gesture */
.testimonial-stack{
  position:relative;
  z-index:1;
  width:100%;
  max-width:1100px;
  display:flex;
  gap:clamp(1rem, 2.5vw, 1.6rem);
  overflow-x:auto;
  scroll-snap-type:x mandatory;
  -webkit-overflow-scrolling:touch;
  scrollbar-width:none;
  padding:.5rem clamp(1.5rem, 8vw, 12vw) 1.5rem;
  /* fades each card out toward the edges as it scrolls past, rather
     than an abrupt clip at the row's own bounding box — same
     mask-image technique as .mock-services-marquee */
  -webkit-mask-image:linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
  mask-image:linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
.testimonial-stack::-webkit-scrollbar{ display:none; }

.testimonial-card{
  position:relative;
  flex:0 0 auto;
  width:min(80vw, 420px);
  scroll-snap-align:center;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  text-align:center;
  padding:clamp(1.4rem, 3vw, 2.4rem);
  border-radius:22px;
  background:linear-gradient(165deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
  border:1px solid rgba(255,221,122,.16);
  box-shadow:0 40px 90px -30px rgba(0,0,0,.6);
  opacity:.5;
  transform:scale(.94);
  transition:opacity .35s var(--ease-out), transform .35s var(--ease-out), box-shadow .35s var(--ease-out);
}
.testimonial-card.is-active{
  opacity:1;
  transform:scale(1);
  box-shadow:0 46px 110px -26px rgba(0,0,0,.7), 0 0 0 1px rgba(255,221,122,.22);
}

.testimonial-icon{ font-size:2.2rem; margin-bottom:.5rem; }
.testimonial-industry{
  font-size:.72rem;
  letter-spacing:.32em;
  text-transform:uppercase;
  color:#ffdd7a;
  margin-bottom:.9rem;
}
.testimonial-quote{
  /* was Fraunces italic — a display serif in italic reads fine as a
     couple of words but got noticeably harder to read at full
     paragraph length; a plain sans-serif body face carries the actual
     review text much more comfortably */
  font-family:'Inter', sans-serif;
  font-weight:400;
  font-size:clamp(1.05rem, 2.2vw, 1.3rem);
  line-height:1.55;
  color:var(--cream);
  max-width:44ch;
  margin-bottom:1rem;
}
/* the "encouraging" beat: a concrete positive result, styled apart
   from the quote so it reads like a quick win at a glance */
.testimonial-result{
  display:inline-flex;
  align-items:center;
  gap:.4em;
  font-size:.8rem;
  font-weight:600;
  letter-spacing:.01em;
  color:#8CE99A;
  background:rgba(140,233,154,.12);
  border:1px solid rgba(140,233,154,.3);
  border-radius:999px;
  padding:.4em .9em;
  margin-bottom:1rem;
}
.testimonial-result::before{ content:'✓'; font-weight:700; }
.testimonial-stars{
  color:#ffdd7a;
  letter-spacing:.25em;
  font-size:.85rem;
  margin-bottom:.7rem;
}
.testimonial-name{
  font-size:.85rem;
  color:rgba(243,237,226,.7);
}
.testimonial-name span{ color:rgba(243,237,226,.45); }

.testimonials-controls{
  position:relative;
  z-index:1;
  display:flex;
  align-items:center;
  gap:1.4rem;
}
.testimonial-arrow{
  display:flex;
  align-items:center;
  justify-content:center;
  width:38px; height:38px;
  border-radius:50%;
  border:1px solid rgba(243,237,226,.25);
  background:transparent;
  color:rgba(243,237,226,.8);
  cursor:none;
  transition:border-color .3s var(--ease-out), color .3s var(--ease-out), background .3s var(--ease-out);
}
.testimonial-arrow:hover{ border-color:#ffdd7a; color:#ffdd7a; background:rgba(255,221,122,.08); }
@media (hover:none), (pointer:coarse){ .testimonial-arrow{ display:none; } }

.testimonial-dots{
  display:flex;
  gap:.55rem;
  align-items:center;
}
.testimonial-dot{
  width:8px;
  height:8px;
  border-radius:50%;
  border:none;
  padding:0;
  background:rgba(243,237,226,.25);
  cursor:none;
  transition:background .35s var(--ease-out), width .35s var(--ease-out);
}
.testimonial-dot:hover{ background:rgba(243,237,226,.5); }
.testimonial-dot.is-active{
  width:22px;
  border-radius:5px;
  background:#ffdd7a;
}

/* ===================================================================
   COMPARISON — a grouped column chart, an outdated site vs a Papi-
   built one, across a few common metrics. Bars grow from zero and the
   stat percentages count up once the chart scrolls into view.
=================================================================== */
.comparison-section{
  position:relative;
  background:radial-gradient(120% 90% at 50% 0%, #14121a 0%, #07060a 55%, #000 100%);
  padding:clamp(4rem, 10vw, 7rem) clamp(1.5rem, 5vw, 4.5rem);
}
.comparison-inner{
  max-width:900px;
  margin:0 auto;
  text-align:center;
}
.comparison-eyebrow{
  font-size:.76rem;
  letter-spacing:.38em;
  text-transform:uppercase;
  color:#ffdd7a;
  margin-bottom:1rem;
}
.comparison-title{
  font-family:'Fraunces', serif;
  font-weight:400;
  font-size:clamp(1.9rem, 4.4vw, 2.8rem);
  color:var(--cream);
  line-height:1.15;
}
.comparison-title em{
  font-style:italic;
  color:#ffdd7a;
  /* the same pulsing-glow treatment as "purpose" in the hero title
     (see purposeGlowPulse) — tuned to this heading's own gold
     (#ffdd7a rather than the hero's --gold-deep) so it reads as the
     same on-brand effect, not an identical color rebadged */
  animation:comparisonGlowPulse 3.6s ease-in-out infinite;
}
@keyframes comparisonGlowPulse{
  0%, 100%{
    text-shadow:
      0 0 9px rgba(255,221,122,.4),
      0 0 20px rgba(255,221,122,.24),
      0 0 36px rgba(255,221,122,.14);
  }
  50%{
    text-shadow:
      0 0 13px rgba(255,221,122,.62),
      0 0 28px rgba(255,221,122,.4),
      0 0 48px rgba(255,221,122,.24);
  }
}
.comparison-sub{
  max-width:520px;
  margin:1rem auto 0;
  color:rgba(243,237,226,.68);
  font-size:1rem;
  line-height:1.5;
}
.comparison-legend{
  display:flex;
  justify-content:center;
  gap:1.75rem;
  margin:2rem 0 3rem;
  font-size:.85rem;
  color:rgba(243,237,226,.75);
}
.comparison-legend-item{ display:inline-flex; align-items:center; gap:.5rem; }
.comparison-dot{ width:9px; height:9px; border-radius:50%; display:inline-block; }
.comparison-dot--old{ background:rgba(243,237,226,.3); }
.comparison-dot--papi{ background:linear-gradient(135deg, #ffd23f, #cba869); }

.comparison-chart{
  display:flex;
  justify-content:center;
  gap:clamp(2rem, 6vw, 4.5rem);
  flex-wrap:wrap;
}
.comparison-group{ display:flex; flex-direction:column; align-items:center; }
.comparison-bars{
  display:flex;
  align-items:flex-end;
  gap:.7rem;
  height:220px;
}
.comparison-bar{
  position:relative;
  width:clamp(30px, 6vw, 46px);
  height:0;
  border-radius:6px 6px 0 0;
  /* the actual grow only happens once .is-visible lands on the chart
     — kept a plain height transition (not tied to scroll) since this
     is a one-time reveal, not something scrubbed continuously */
  transition:height 1.1s var(--ease-out);
}
.comparison-bar--old{ background:rgba(243,237,226,.16); }
.comparison-bar--papi{
  background:linear-gradient(180deg, #fff6d8, #ffd23f 40%, #cba869);
  box-shadow:0 0 30px rgba(255,210,63,.35);
  overflow:hidden;
}
/* a slow shimmer sweeping up the Papi bar once it's grown in — the
   "live" bit: something about the chart keeps moving on its own
   rather than sitting static once revealed */
.comparison-bar--papi::after{
  content:'';
  position:absolute;
  inset:0;
  background:linear-gradient(180deg, rgba(255,255,255,.55), transparent 60%);
  opacity:0;
  transform:translateY(100%);
}
.comparison-chart.is-visible .comparison-bar--papi::after{
  opacity:1;
  animation:comparisonShimmer 2.6s ease-in-out .9s infinite;
}
@keyframes comparisonShimmer{
  0%{ transform:translateY(100%); }
  55%, 100%{ transform:translateY(-120%); }
}
.comparison-chart.is-visible .comparison-bar{ height:var(--fill); }
.comparison-bar-stat{
  position:absolute;
  top:-1.9rem;
  left:50%;
  transform:translateX(-50%);
  font-family:'Fraunces', serif;
  font-weight:600;
  font-size:1rem;
  color:#ffdd7a;
  white-space:nowrap;
}
.comparison-group-label{
  margin-top:1rem;
  font-size:.82rem;
  color:rgba(243,237,226,.6);
  text-align:center;
  max-width:110px;
}

@media (max-width:640px){
  .comparison-bars{ height:170px; }
}

/* ===================================================================
   FAQ — gold-on-black accordion, one answer open at a time.
=================================================================== */
.faq-section{
  position:relative;
  background:#000;
  padding:clamp(4rem, 10vw, 7rem) clamp(1.5rem, 5vw, 4.5rem);
}
.faq-inner{
  max-width:760px;
  margin:0 auto;
}
.faq-eyebrow{
  text-align:center;
  font-size:.76rem;
  letter-spacing:.38em;
  text-transform:uppercase;
  color:#ffdd7a;
  margin-bottom:1rem;
}
.faq-title{
  text-align:center;
  font-family:'Fraunces', serif;
  font-weight:400;
  font-size:clamp(1.9rem, 4.4vw, 2.8rem);
  color:var(--cream);
  margin-bottom:3rem;
}
.faq-title em{ font-style:italic; color:#ffdd7a; }

.faq-item{
  border-bottom:1px solid rgba(255,221,122,.18);
}
.faq-item:first-child{ border-top:1px solid rgba(255,221,122,.18); }
.faq-question{
  width:100%;
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:1rem;
  padding:1.4rem .25rem;
  background:none;
  border:none;
  text-align:left;
  font-family:'Fraunces', serif;
  font-size:clamp(1.02rem, 2vw, 1.2rem);
  color:var(--cream);
  cursor:none;
  transition:color .25s var(--ease-out);
}
.faq-question:hover{ color:#ffdd7a; }
.faq-chevron{
  flex:0 0 auto;
  width:10px;
  height:10px;
  border-right:1.5px solid #ffdd7a;
  border-bottom:1.5px solid #ffdd7a;
  transform:rotate(45deg);
  transition:transform .3s var(--ease-out);
}
.faq-item.is-open .faq-chevron{ transform:rotate(-135deg); }
.faq-answer{
  height:0;
  overflow:hidden;
  transition:height .35s var(--ease-out);
}
.faq-answer p{
  padding:0 .25rem 1.5rem;
  color:rgba(243,237,226,.68);
  font-size:.98rem;
  line-height:1.6;
}

/* ===================================================================
   QUOTE — request-a-quote form. On brand: black and gold only. The
   section behind the white card runs a slow, solid (not blurred/
   translucent like the cube's glow) sweep between black and gold —
   the same "moving colors" idea as the cube's background, just more
   saturated, and kept strictly within the brand pair rather than a
   rainbow. The form's own border rotates through black/gold/cream so
   it always reads as contrasted against whatever's behind it. Not a
   tall sticky section like the others: a form should feel direct,
   not gimmicky.
=================================================================== */
.quote-section{
  position:relative;
  /* the gold stop stays muted/deep rather than bright yellow — the
     white copy text sitting directly on top needs to stay legible
     through the whole sweep, not just the darker parts of it */
  background:linear-gradient(120deg, #0a0908, #2a1f08, #8a6a1f, #2a1f08, #0a0908);
  background-size:400% 400%;
  animation:quoteBgSweep 18s ease-in-out infinite;
  padding:clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem);
  overflow:hidden;
}
@keyframes quoteBgSweep{
  0%{ background-position:0% 50%; }
  50%{ background-position:100% 50%; }
  100%{ background-position:0% 50%; }
}

.quote-inner{
  position:relative;
  z-index:1;
  max-width:1100px;
  margin:0 auto;
  display:grid;
  grid-template-columns:1fr 1.1fr;
  gap:clamp(2.5rem, 6vw, 5rem);
  align-items:center;
}

/* opacity/transform are driven directly, continuously, by scroll
   position in quote-form.js — not a CSS transition triggered once by
   an IntersectionObserver. A fixed-duration reveal could finish
   playing before or after the visitor's own scroll speed caught up to
   it, which is what read as the form just "slapping" into place;
   tying it straight to scroll position means it's always exactly as
   revealed as how far they've scrolled, at any scroll speed. */
.quote-copy{
  opacity:0;
  will-change:opacity, transform, filter;
}

.quote-eyebrow{
  font-size:.72rem;
  letter-spacing:.36em;
  text-transform:uppercase;
  color:#fff;
  opacity:.85;
  font-weight:600;
  margin-bottom:1rem;
}
/* the heading sweeps through the same palette as the section behind
   it — its own animation, offset in timing so it doesn't just match
   the backdrop 1:1 the whole time */
.quote-heading{
  font-family:'Fraunces', serif;
  font-weight:500;
  font-size:clamp(2rem, 4.4vw, 3.1rem);
  line-height:1.1;
  margin-bottom:1rem;
  background:linear-gradient(100deg, #fff, #ffe27a, #ffd23f, #fff6d8, #fff);
  background-size:300% 100%;
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
  animation:quoteHeadingSweep 10s ease-in-out infinite;
}
@keyframes quoteHeadingSweep{
  0%{ background-position:0% 50%; }
  50%{ background-position:100% 50%; }
  100%{ background-position:0% 50%; }
}
/* "free" gets its own brighter, faster shine layered on top of the
   heading's own slow overall sweep — a distinct highlight sweeping
   across just that one word rather than blending into the rest */
.quote-heading-shine{
  background:linear-gradient(100deg, #ffd23f 0%, #fff6d8 20%, #fff 40%, #fff6d8 60%, #ffd23f 100%);
  background-size:250% 100%;
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
  animation:quoteFreeShine 2.4s ease-in-out infinite;
}
@keyframes quoteFreeShine{
  0%{ background-position:150% 50%; }
  100%{ background-position:-50% 50%; }
}
.quote-sub{
  font-size:clamp(1rem, 1.6vw, 1.15rem);
  line-height:1.55;
  color:rgba(255,255,255,.85);
  max-width:44ch;
  margin-bottom:2.4rem;
}

.quote-proof{
  display:flex;
  flex-direction:column;
  gap:1.1rem;
}
.quote-rating{ display:flex; align-items:center; gap:.7rem; }
.quote-stars{ color:#ffd23f; letter-spacing:.15em; font-size:1.05rem; }
.quote-rating-text{ font-size:.9rem; color:rgba(255,255,255,.8); }
.quote-rating-text strong{ color:#fff; }

.quote-social{ display:flex; align-items:center; gap:.9rem; }
.quote-social-label{ font-size:.85rem; color:rgba(255,255,255,.8); }
.quote-social-links{ display:flex; gap:.6rem; }
.quote-social-link{
  display:flex;
  align-items:center;
  justify-content:center;
  width:38px; height:38px;
  border-radius:50%;
  overflow:hidden;
  box-shadow:0 8px 20px -8px rgba(0,0,0,.35);
  transition:transform .3s var(--ease-out), box-shadow .3s var(--ease-out);
}
.quote-social-link:hover{
  transform:translateY(-3px) scale(1.06);
  box-shadow:0 14px 26px -8px rgba(0,0,0,.45);
}
.quote-social-link svg{ display:block; }

/* ---- the form itself ---- */
/* used to be wrapped in an oversized rotating conic-gradient clipped
   down to a slim animated-rainbow-gold ring (the classic animated-
   gradient-border trick) — removed outright. That wrap's own rounded-
   corner clipping needing to track the swipeable stack's height
   (itself resized in JS whenever the visitor swaps between the quote
   and call-back panels) was the actual root of the recurring "gold bar"
   glitch: any brief mismatch between the two exposed this wrap's own
   gold background in the gap. With no gold background left to expose,
   there's nothing left for that gap to reveal — the form's own white
   background (on .quote-form-tabs/.quote-form-stack below) is what's
   actually visible now, full stop. */
/* opacity/transform driven directly by scroll position — see the note
   on .quote-copy above */
.quote-form-wrap{
  position:relative;
  opacity:0;
  will-change:opacity, transform;
  /* a plain, static shadow for depth — not a "container background",
     just enough definition that the card doesn't look like it's
     floating flat against the page */
  box-shadow:0 40px 80px -40px rgba(0,0,0,.4);
}

/* two panels — the original quote request, and a call-back request —
   swipeable like the testimonials row (native horizontal scroll-snap,
   so touch drag just works), with tabs above as the click/keyboard
   equivalent and to show which panel is active */
.quote-form-tabs{
  position:relative;
  z-index:1;
  display:flex;
  background:#f0f2f6;
  border-radius:23px 23px 0 0;
  overflow:hidden;
}
.quote-form-tab{
  flex:1;
  padding:.9rem 1rem;
  border:none;
  border-bottom:2px solid transparent;
  background:transparent;
  font-family:'Inter', sans-serif;
  font-weight:600;
  font-size:.86rem;
  letter-spacing:.01em;
  color:#8b96ac;
  cursor:none;
  transition:color .25s var(--ease-out), background .25s var(--ease-out), border-color .25s var(--ease-out);
}
.quote-form-tab.is-active{
  color:#0f1f3d;
  background:#fff;
  border-bottom-color:#8a6a1f;
}

.quote-form-stack{
  position:relative;
  z-index:1;
  display:flex;
  /* fixed to whichever panel is tallest (see quote-form.js) rather
     than resized to match whichever is currently active — resizing
     per-panel shrank the whole section in normal document flow every
     time the visitor swiped, which shifted everything below the form
     (the closing Papi signature) up and down on every swipe, on top of
     occasionally exposing a gap at the card's own rounded corners. A
     background here (rather than leaving this transparent) means the
     shorter call-back panel's leftover space below it just reads as
     more white card, not a gap into whatever is behind the section. */
  align-items:flex-start;
  height:0;
  background:#fff;
  overflow-x:auto;
  scroll-snap-type:x mandatory;
  -webkit-overflow-scrolling:touch;
  scrollbar-width:none;
  border-radius:0 0 23px 23px;
}
.quote-form-stack::-webkit-scrollbar{ display:none; }
.quote-form-panel{
  flex:0 0 100%;
  min-width:100%;
  scroll-snap-align:start;
}

.quote-form{
  position:relative;
  z-index:1;
  background:#fff;
  padding:clamp(1.6rem, 3vw, 2.4rem);
  display:flex;
  flex-direction:column;
  gap:1.3rem;
}

/* a metallic gold sweep for a premium feel — darker bronze/gold stops
   (not just bright yellow) so it still reads clearly against the
   form's white background, with a brighter highlight band passing
   through periodically like light catching brushed metal */
.quote-form-title{
  font-family:'Fraunces', serif;
  font-weight:600;
  font-size:clamp(1.3rem, 2.6vw, 1.7rem);
  text-align:center;
  margin:0 0 -.3rem;
  background:linear-gradient(100deg, #8a6a1f 0%, #d4af37 25%, #fff6d8 50%, #d4af37 75%, #8a6a1f 100%);
  background-size:250% 100%;
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
  animation:quoteFormTitleShine 6s ease-in-out infinite;
}
@keyframes quoteFormTitleShine{
  0%{ background-position:0% 50%; }
  50%{ background-position:100% 50%; }
  100%{ background-position:0% 50%; }
}

.quote-row{
  display:grid;
  grid-template-columns:1fr 1fr;
  gap:1.3rem;
}

/* grid items default to a min-width based on their content's own
   intrinsic size, not the track's — a native date input's internal
   day/month/year + calendar-icon layout on iOS has a wide enough
   intrinsic minimum that it could force its whole grid track past the
   form's own edge instead of shrinking to fit, which is what showed up
   as the date field sticking out past the form on iPhone */
.quote-field{ position:relative; min-width:0; }
.quote-field input,
.quote-field textarea,
.quote-field select{
  width:100%;
  min-width:0;
  font-family:'Inter', sans-serif;
  font-size:1rem;
  color:#0f1f3d;
  background:#f7faff;
  border:1.5px solid rgba(15,31,61,.12);
  border-radius:12px;
  padding:1.1rem .9rem .5rem;
  transition:border-color .3s var(--ease-out), box-shadow .3s var(--ease-out), background .3s var(--ease-out);
  resize:none;
}
.quote-field textarea{ padding-top:1.3rem; }
.quote-field input:focus,
.quote-field textarea:focus,
.quote-field select:focus{
  outline:none;
  border-color:#8a6a1f;
  background:#fff;
  box-shadow:0 0 0 4px rgba(202,153,20,.16);
}
.quote-field label{
  position:absolute;
  left:.95rem;
  top:.95rem;
  font-size:1rem;
  color:#8b96ac;
  pointer-events:none;
  transform-origin:left top;
  transition:transform .22s var(--ease-out), color .22s var(--ease-out), top .22s var(--ease-out);
}
.quote-field label span{ color:#a8b2c4; }
.quote-field input:focus + label,
.quote-field input:not(:placeholder-shown) + label,
.quote-field textarea:focus + label,
.quote-field textarea:not(:placeholder-shown) + label{
  top:.4rem;
  transform:scale(.72);
  color:#8a6a1f;
}

/* the business/industry field — a native <select> (its options scroll
   on both a phone's native wheel picker and a desktop dropdown) rather
   than a free-text field, so a visitor picks their trade from the same
   list the showcase section uses instead of typing it out. Selects
   don't support :placeholder-shown, so the floated-label state is
   driven by a .has-value class toggled in quote-form.js instead. */
.quote-field--select select{
  appearance:none;
  -webkit-appearance:none;
  cursor:none;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b96ac' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat:no-repeat;
  background-position:right .9rem center;
  background-size:16px;
  padding-right:2.2rem;
}
.quote-field--select select:invalid{ color:#8b96ac; }
.quote-field--select select.has-value{ color:#0f1f3d; }
.quote-field--select select:focus + label,
.quote-field--select select.has-value + label{
  top:.4rem;
  transform:scale(.72);
  color:#8a6a1f;
}

/* the preferred-call-date field — a native date input so tapping it
   opens the platform's own calendar (day-of-month picker) rather than
   free-text. Like <select>, it doesn't support :placeholder-shown, so
   the floated-label state is driven by the same .has-value class
   (toggled in quote-form.js) instead.
   overflow:hidden is load-bearing here, not decorative — iOS Safari's
   native date control is a shadow-DOM layout of separate day/month/
   year segments plus the calendar glyph, and on a narrow field it can
   render wider than the input's own box without the browser clipping
   it on its own; only overflow:hidden directly on the input forces
   that shadow content back inside the border-box, which is what
   showed up as the control spilling out past the form's edge. */
.quote-field--date input{ color:#0f1f3d; overflow:hidden; }
.quote-field--date input:focus + label,
.quote-field--date input.has-value + label{
  top:.4rem;
  transform:scale(.72);
  color:#8a6a1f;
}

.quote-submit{
  position:relative;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:.5rem;
  padding:1rem 1.5rem;
  border:none;
  border-radius:14px;
  background:linear-gradient(120deg, #8a6a1f, #ffd23f, #cba869, #8a6a1f);
  background-size:300% 100%;
  animation:quoteBgSweep 8s ease-in-out infinite;
  color:#1a1408;
  font-size:1rem;
  font-weight:700;
  letter-spacing:.01em;
  cursor:none;
  overflow:hidden;
  transition:transform .3s var(--ease-out), box-shadow .3s var(--ease-out);
  box-shadow:0 16px 34px -14px rgba(138,106,31,.6);
}
.quote-submit:hover{ transform:translateY(-2px); box-shadow:0 20px 40px -14px rgba(138,106,31,.7); }
.quote-submit:active{ transform:translateY(0) scale(.98); }
.quote-submit-check{
  display:none;
}

.quote-form.is-sending .quote-submit{ opacity:.75; pointer-events:none; }
.quote-form.is-sent .quote-submit-label{ display:none; }
.quote-form.is-sent .quote-submit-check{ display:block; }
.quote-form.is-sent .quote-submit{ background:linear-gradient(135deg, #34c77b, #1fa863); color:#fff; animation:none; }

.quote-success{
  font-size:.9rem;
  color:#1fa863;
  text-align:center;
  opacity:0;
  max-height:0;
  overflow:hidden;
  transition:opacity .4s var(--ease-out), max-height .4s var(--ease-out), color .3s var(--ease-out);
}
.quote-form.is-sent .quote-success{ opacity:1; max-height:60px; }
.quote-form.is-error .quote-success{ opacity:1; max-height:60px; color:#e0483e; }

@media (max-width:860px){
  .quote-inner{ grid-template-columns:1fr; }
  .quote-copy{ text-align:center; }
  .quote-proof{ align-items:center; }
  .quote-rating{ justify-content:center; }
  .quote-social{ justify-content:center; }
  .quote-sub{ margin-left:auto; margin-right:auto; }
}

@media (max-width:560px){
  .quote-row{ grid-template-columns:1fr; }
}

/* ===================================================================
   NEWSLETTER POPUP — shown once, the first time the visitor scrolls
   past the before/after section (see js/newsletter-popup.js). The form
   itself reuses .quote-field/.quote-submit/.quote-form wholesale (a
   white-background card, same as the quote form further down the
   page) rather than a second parallel set of input styles.
=================================================================== */
.newsletter-popup{
  position:fixed;
  inset:0;
  z-index:900;
  display:flex;
  align-items:center;
  justify-content:center;
  padding:1.5rem;
  opacity:0;
  pointer-events:none;
  transition:opacity .35s var(--ease-out);
}
.newsletter-popup.is-visible{
  opacity:1;
  pointer-events:auto;
}
.newsletter-popup-backdrop{
  position:absolute;
  inset:0;
  background:rgba(5,4,2,.72);
}
.newsletter-popup-card{
  position:relative;
  z-index:1;
  width:min(440px, 100%);
  max-height:88vh;
  overflow-y:auto;
  background:#fff;
  border-radius:20px;
  padding:clamp(1.8rem, 4vw, 2.6rem);
  box-shadow:0 60px 120px -30px rgba(0,0,0,.6);
  transform:translateY(24px) scale(.96);
  transition:transform .35s var(--ease-out);
}
.newsletter-popup.is-visible .newsletter-popup-card{
  transform:translateY(0) scale(1);
}
.newsletter-popup-close{
  position:absolute;
  top:1rem;
  right:1rem;
  width:32px;
  height:32px;
  display:flex;
  align-items:center;
  justify-content:center;
  border-radius:50%;
  border:none;
  background:rgba(15,31,61,.06);
  color:#0f1f3d;
  cursor:none;
  transition:background .2s var(--ease-out);
}
.newsletter-popup-close:hover{ background:rgba(15,31,61,.12); }
.newsletter-popup-eyebrow{
  font-size:.72rem;
  font-weight:700;
  letter-spacing:.2em;
  text-transform:uppercase;
  color:#8a6a1f;
  margin-bottom:.7rem;
}
.newsletter-popup-title{
  font-family:'Fraunces', serif;
  font-weight:500;
  font-size:clamp(1.5rem, 4vw, 1.9rem);
  line-height:1.25;
  color:#0f1f3d;
  margin-bottom:.9rem;
  max-width:32ch;
}
/* same metallic gold sweep as .quote-form-title, tuned for the same
   white backdrop */
.newsletter-popup-highlight{
  background:linear-gradient(100deg, #8a6a1f 0%, #d4af37 25%, #fff6d8 50%, #d4af37 75%, #8a6a1f 100%);
  background-size:250% 100%;
  -webkit-background-clip:text;
  background-clip:text;
  color:transparent;
  animation:quoteFormTitleShine 6s ease-in-out infinite;
}
.newsletter-popup-sub{
  font-size:.94rem;
  line-height:1.55;
  color:rgba(15,31,61,.66);
  margin-bottom:1.6rem;
}
.newsletter-popup-card .quote-form{
  background:none;
  padding:0;
}

/* ===================================================================
   LIVE DEMOS — real, live iframe embeds of sites Papi has actually
   built (not screenshots) inside a browser-chrome-style frame; the
   visitor scrolls *inside* the frame to explore the real site. Cards
   are built by js/live-demo.js from a small data array — same
   swipeable-row pattern as .testimonial-stack (native horizontal
   scroll-snap, centered via side padding rather than justify-content,
   which stays reachable-from-the-first-pixel regardless of item count
   — justify-content:center on an overflowing flex row is a documented
   cross-browser scroll trap that can strand content before the
   visible start unreachable).
=================================================================== */
.live-demo-section{
  position:relative;
  z-index:1;
  background:radial-gradient(120% 90% at 50% 0%, #14121a 0%, #07060a 55%, #000 100%);
  padding:clamp(3.5rem, 8vw, 6rem) 0;
}
.live-demo-inner{
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:clamp(1.2rem, 3vw, 2rem);
  padding:0 1.5rem;
  text-align:center;
  opacity:0;
  will-change:opacity, transform;
}
.live-demo-eyebrow{
  font-size:.76rem;
  letter-spacing:.38em;
  text-transform:uppercase;
  color:#ffdd7a;
}
.live-demo-title{
  font-family:'Fraunces', serif;
  font-weight:400;
  font-size:clamp(1.9rem, 4.4vw, 2.8rem);
  line-height:1.15;
  color:var(--cream);
}
.live-demo-title em{
  font-style:italic;
  color:#ffdd7a;
}
.live-demo-sub{
  max-width:520px;
  margin-top:-.6rem;
  color:rgba(243,237,226,.68);
  font-size:1rem;
  line-height:1.5;
}

.live-demo-stack{
  position:relative;
  z-index:1;
  width:100%;
  max-width:1100px;
  display:flex;
  gap:clamp(1.2rem, 3vw, 2rem);
  overflow-x:auto;
  scroll-snap-type:x mandatory;
  -webkit-overflow-scrolling:touch;
  scrollbar-width:none;
  padding:.5rem clamp(1.5rem, 8vw, 12vw) 1.5rem;
}
.live-demo-stack::-webkit-scrollbar{ display:none; }

.live-demo-card{
  flex:0 0 auto;
  width:min(92vw, 640px);
  scroll-snap-align:center;
  display:flex;
  flex-direction:column;
  align-items:center;
}
.live-demo-browser{
  width:100%;
  border-radius:16px;
  overflow:hidden;
  background:#0d0d10;
  border:1px solid rgba(255,221,122,.16);
  box-shadow:0 40px 90px -30px rgba(0,0,0,.6);
}
.live-demo-browser-bar{
  display:flex;
  align-items:center;
  gap:.5rem;
  padding:.65rem .9rem;
  background:rgba(255,221,122,.06);
}
.live-demo-dot{
  width:8px;
  height:8px;
  border-radius:50%;
  background:rgba(255,221,122,.22);
  flex:0 0 auto;
}
.live-demo-url{
  flex:1;
  margin-left:.4rem;
  font-size:.72rem;
  color:rgba(243,237,226,.55);
  background:rgba(255,255,255,.04);
  border-radius:999px;
  padding:.3em .9em;
  overflow:hidden;
  text-overflow:ellipsis;
  white-space:nowrap;
}
/* fixed clamp rather than a tall vh-multiplied value — this only sets
   the iframe's own height, not anything that defines the page's total
   scroll distance, so it doesn't carry the same in-app-browser risk
   the site's tall sticky sections had to be fixed for elsewhere */
.live-demo-frame-wrap{
  position:relative;
  width:100%;
  height:clamp(420px, 70vh, 640px);
  background:#fff;
}
.live-demo-iframe{
  width:100%;
  height:100%;
  border:0;
  display:block;
  background:#fff;
}
/* shown in place of the iframe until it's actually scrolled near —
   see js/live-demo.js's lazy-load — so the frame never sits blank-white
   for a beat while the real site's own network request is in flight */
.live-demo-loading{
  position:absolute;
  inset:0;
  display:flex;
  align-items:center;
  justify-content:center;
  font-size:.8rem;
  letter-spacing:.1em;
  text-transform:uppercase;
  color:rgba(15,31,61,.4);
  background:#f8f6f2;
  transition:opacity .4s ease;
}
.live-demo-card.is-loaded .live-demo-loading{
  opacity:0;
  pointer-events:none;
}

.live-demo-name{
  margin-top:1.1rem;
  font-family:'Fraunces', serif;
  font-size:1.1rem;
  color:var(--cream);
}
.live-demo-industry{
  font-size:.72rem;
  letter-spacing:.28em;
  text-transform:uppercase;
  color:rgba(243,237,226,.5);
  margin-top:.3rem;
}
.live-demo-visit{
  margin-top:.8rem;
  font-size:.85rem;
  color:#ffdd7a;
  text-decoration:none;
  border-bottom:1px solid rgba(255,221,122,.4);
  padding-bottom:2px;
  cursor:none;
  transition:border-color .3s var(--ease-out);
}
.live-demo-visit:hover{ border-color:transparent; }

.live-demo-controls{
  position:relative;
  z-index:1;
  display:flex;
  align-items:center;
  gap:1.4rem;
}
.live-demo-arrow{
  display:flex;
  align-items:center;
  justify-content:center;
  width:38px; height:38px;
  border-radius:50%;
  border:1px solid rgba(243,237,226,.25);
  background:transparent;
  color:rgba(243,237,226,.8);
  cursor:none;
  transition:border-color .3s var(--ease-out), color .3s var(--ease-out), background .3s var(--ease-out);
}
.live-demo-arrow:hover{ border-color:#ffdd7a; color:#ffdd7a; background:rgba(255,221,122,.08); }
@media (hover:none), (pointer:coarse){ .live-demo-arrow{ display:none; } }
/* hidden outright with a single demo — no point browsing a list of one */
.live-demo-controls.is-single{ display:none; }

.live-demo-dots{
  display:flex;
  gap:.55rem;
  align-items:center;
}
.live-demo-dot-btn{
  width:8px;
  height:8px;
  border-radius:50%;
  border:none;
  padding:0;
  background:rgba(243,237,226,.25);
  cursor:none;
  transition:background .35s var(--ease-out), width .35s var(--ease-out);
}
.live-demo-dot-btn:hover{ background:rgba(243,237,226,.5); }
.live-demo-dot-btn.is-active{
  width:22px;
  border-radius:5px;
  background:#ffdd7a;
}
